import { Injectable, type OnModuleInit } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { Counter } from 'prom-client';
import { cacheMetaStorage } from './cache-meta.store';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { LoggerService } from './logger.service';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { RedisService } from './redis.service';

export const CACHE_HIT_TOTAL = 'cache_hit_total';
export const CACHE_MISS_TOTAL = 'cache_miss_total';
export const CACHE_DEGRADATION_TOTAL = 'cache_degradation_total';

export const CacheTTL = {
  /** Listing detail — moderate TTL, invalidated on mutation */
  LISTING_DETAIL: 300, // 5 min
  /** Search results — short TTL, invalidated on listing mutations */
  SEARCH_RESULTS: 120, // 2 min
  /** District stats — moderate TTL, invalidated on listing events */
  DISTRICT_STATS: 300, // 5 min
  /** Market report — moderate TTL, invalidated on listing events */
  MARKET_REPORT: 900, // 15 min
  /** Heatmap data — moderate TTL, invalidated on listing events */
  HEATMAP: 300, // 5 min
  /** [TEC-3055] Ward-level heatmap / listing-volume drill-down — 30 min TTL */
  HEATMAP_WARD: 1800, // 30 min
  /** Price trend — long TTL, historical data changes infrequently */
  MARKET_DATA: 1800, // 30 min
  /** User profile — moderate TTL, invalidated on mutation */
  USER_PROFILE: 600, // 10 min
  /** User quota — very short TTL, invalidated on usage metering and plan changes */
  USER_QUOTA: 60, // 1 min
  /** Subscription plan list — long TTL, rarely changes */
  PLAN_LIST: 3600, // 1 hour
  /** Reference data (districts, wards) — very long TTL, static data */
  REFERENCE_DATA: 86400, // 24 hours
  /** Market snapshot — 5 min TTL, dashboard tile data */
  MARKET_SNAPSHOT: 300, // 5 min
  /** Trending areas — 30 min TTL, aggregation is expensive */
  TRENDING_AREAS: 1800, // 30 min
  /** Price movers — 30 min TTL, aggregation over two time windows */
  PRICE_MOVERS: 1800, // 30 min
  /** Market history — 6 hour TTL, time-series data recomputed nightly */
  MARKET_HISTORY: 21600, // 6 hours
  /** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */
  VALUATION_LISTING: 86400, // 24 h
  /** [TEC-3072] Neighborhood score — 24h TTL, POI data changes infrequently */
  NEIGHBORHOOD_SCORE: 86400, // 24 h
} as const;

export enum CachePrefix {
  LISTING = 'cache:listing',
  SEARCH = 'cache:search',
  GEO_SEARCH = 'cache:geo_search',
  MARKET_REPORT = 'cache:market:report',
  MARKET_TREND = 'cache:market:trend',
  MARKET_HEATMAP = 'cache:market:heatmap',
  /** [TEC-3055] Listing volume drill-down by ward */
  LISTING_VOLUME_WARD = 'cache:market:listing_volume_ward',
  MARKET_DISTRICT = 'cache:market:district',
  USER_PROFILE = 'cache:user:profile',
  USER_QUOTA = 'cache:user:quota',
  VALUATION = 'cache:valuation',
  PLAN_LIST = 'cache:plan:list',
  REFERENCE = 'cache:reference',
  AGENT_LISTINGS = 'cache:agent:listings',
  MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
  TRENDING_AREAS = 'cache:analytics:trending_areas',
  PRICE_MOVERS = 'cache:analytics:price_movers',
  MARKET_HISTORY = 'cache:analytics:market_history',
  /** [TEC-3072] Neighborhood score per district */
  NEIGHBORHOOD_SCORE = 'cache:analytics:neighborhood_score',
}

@Injectable()
export class CacheService implements OnModuleInit {
  constructor(
    private readonly redis: RedisService,
    private readonly logger: LoggerService,
    @InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
    @InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
    @InjectMetric(CACHE_DEGRADATION_TOTAL) private readonly cacheDegradationCounter: Counter,
  ) {}

  onModuleInit(): void {
    this.logger.log('CacheService initialized', 'CacheService');
  }

  /**
   * Cache-aside: get from cache, or execute loader and store result.
   *
   * When Redis is down the loader is called directly (graceful degradation).
   * Degradation events are counted via cache_degradation_total for alerting.
   *
   * Cache entries are stored as { __v, cachedAt, ttlSeconds } envelopes so
   * that CacheMetaInterceptor can surface freshness metadata to the frontend.
   * Legacy plain-JSON entries (written before this version) are served
   * transparently; they receive cacheMeta: { cachedAt: null, ... }.
   */
  async getOrSet<T>(
    key: string,
    loader: () => Promise<T>,
    ttlSeconds: number,
    resource: string,
  ): Promise<T> {
    const store = cacheMetaStorage.getStore();

    // Fast-path: skip Redis entirely when it is known to be disconnected.
    if (!this.redis.isAvailable()) {
      this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' });
      this.cacheMissCounter.inc({ resource });
      if (store) {
        store.meta = { cachedAt: null, nextRefreshAt: null, source: 'fresh' };
      }
      return loader();
    }

    try {
      const cached = await this.redis.get(key);
      if (cached !== null) {
        this.cacheHitCounter.inc({ resource });
        const parsed = JSON.parse(cached) as unknown;
        // Detect enveloped entries written by this method.
        if (
          parsed !== null &&
          typeof parsed === 'object' &&
          '__v' in (parsed as object) &&
          'cachedAt' in (parsed as object)
        ) {
          const envelope = parsed as { __v: T; cachedAt: string; ttlSeconds: number };
          if (store) {
            const nextRefreshAt = new Date(
              new Date(envelope.cachedAt).getTime() + envelope.ttlSeconds * 1000,
            ).toISOString();
            store.meta = { cachedAt: envelope.cachedAt, nextRefreshAt, source: 'cache' };
          }
          return envelope.__v;
        }
        // Legacy plain value — serve without timestamp meta.
        if (store) {
          store.meta = { cachedAt: null, nextRefreshAt: null, source: 'cache' };
        }
        return parsed as T;
      }
    } catch (err) {
      this.cacheDegradationCounter.inc({ resource, operation: 'read_error' });
      this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
    }

    this.cacheMissCounter.inc({ resource });
    const result = await loader();

    const cachedAt = new Date().toISOString();
    if (store) {
      const nextRefreshAt = new Date(new Date(cachedAt).getTime() + ttlSeconds * 1000).toISOString();
      store.meta = { cachedAt, nextRefreshAt, source: 'fresh' };
    }

    try {
      const envelope = { __v: result, cachedAt, ttlSeconds };
      await this.redis.set(key, JSON.stringify(envelope), ttlSeconds);
    } catch (err) {
      this.cacheDegradationCounter.inc({ resource, operation: 'write_error' });
      this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
    }

    return result;
  }

  /** Invalidate a single cache key. */
  async invalidate(key: string): Promise<void> {
    try {
      await this.redis.del(key);
    } catch (err) {
      this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'invalidate_error' });
      this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
    }
  }

  /** Invalidate all keys matching a prefix using SCAN (non-blocking). */
  async invalidateByPrefix(prefix: string): Promise<void> {
    try {
      const client = this.redis.getClient();
      let cursor = '0';
      do {
        const [nextCursor, keys] = await client.scan(cursor, 'MATCH', `${prefix}:*`, 'COUNT', 100);
        cursor = nextCursor;
        if (keys.length > 0) {
          await client.del(...keys);
        }
      } while (cursor !== '0');
    } catch (err) {
      this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'prefix_invalidate_error' });
      this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
    }
  }

  /** Build a deterministic cache key from prefix + parts. */
  static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string {
    const sanitized = parts
      .filter((p) => p !== undefined)
      .map((p) => String(p).toLowerCase().replace(/\s+/g, '_'));
    return `${prefix}:${sanitized.join(':')}`;
  }
}
