import { CacheService } from "./cache.service.js";
import type { CacheBucketMetrics } from "./cache.types.js";

/**
 * Configuration for a cache bucket.
 *
 * @property ttlSeconds - TTL in seconds. `null` means no TTL (CDC-driven invalidation only).
 * @property allowNull  - Whether to cache null/undefined values from the fetcher.
 */
export interface CacheBucketConfig {
  ttlSeconds: number | null;
  allowNull?: boolean;
}

/**
 * Options for the getOrSet operation.
 */
export interface GetOrSetOptions {
  /** Whether to cache null/undefined values (overrides bucket default) */
  allowNull?: boolean;
}

/**
 * Base class for domain-specific cache services.
 *
 * Extracts the common patterns shared across OrdersCacheService,
 * SupportCacheService, and others:
 *
 * - **Request coalescing**: Deduplicates in-flight requests to the same key,
 *   preventing thundering herd after cache miss or invalidation.
 *
 * - **Cache metrics tracking**: Tracks hits, misses, and invalidations
 *   per named bucket.
 *
 * - **getOrSet pattern**: Check cache, coalesce, fetch, store -- with
 *   configurable TTL (including null for CDC-driven caches) and null handling.
 *
 * Subclasses define their own bucket names, key builders, and invalidation methods.
 * The base handles the mechanical caching logic so subclasses focus on domain semantics.
 *
 * @example
 * ```typescript
 * class OrdersCacheService extends CacheStrategyBase<'summaries' | 'details'> {
 *   constructor(cache: CacheService) {
 *     super(cache, {
 *       summaries: { ttlSeconds: null },     // CDC-driven, no TTL
 *       details:   { ttlSeconds: null, allowNull: true },
 *     });
 *   }
 * }
 * ```
 */
export abstract class CacheStrategyBase<TBucket extends string> {
  private readonly metrics: Record<TBucket, CacheBucketMetrics>;
  private readonly inflightRequests = new Map<string, Promise<unknown>>();
  private readonly bucketConfigs: Record<TBucket, CacheBucketConfig>;
  private invalidationCount = 0;

  protected constructor(
    protected readonly cache: CacheService,
    bucketConfigs: Record<TBucket, CacheBucketConfig>
  ) {
    this.bucketConfigs = bucketConfigs;

    // Initialize metrics for each bucket
    const metrics = {} as Record<TBucket, CacheBucketMetrics>;
    for (const bucket of Object.keys(bucketConfigs) as TBucket[]) {
      metrics[bucket] = { hits: 0, misses: 0 };
    }
    this.metrics = metrics;
  }

  /**
   * Get or fetch a cached value with request coalescing.
   *
   * 1. Check Redis cache
   * 2. If cache miss, check for an in-flight request for the same key
   * 3. If no in-flight request, call the fetcher, store in cache, and return
   *
   * @param bucket   - The metrics bucket to track hits/misses against
   * @param key      - The Redis cache key
   * @param fetcher  - Async function to fetch fresh data on cache miss
   * @param options  - Optional overrides (e.g., allowNull)
   * @returns The cached or freshly fetched value
   */
  protected async getOrSet<T>(
    bucket: TBucket,
    key: string,
    fetcher: () => Promise<T>,
    options?: GetOrSetOptions
  ): Promise<T> {
    const config = this.bucketConfigs[bucket];
    const allowNull = options?.allowNull ?? config.allowNull ?? false;
    const ttlSeconds = config.ttlSeconds;

    // Check Redis cache first
    const cached = await this.cache.get<T>(key);

    if (cached !== null) {
      this.metrics[bucket].hits++;
      return cached;
    }

    // Check for in-flight request (prevents thundering herd)
    const existingRequest = this.inflightRequests.get(key);
    if (existingRequest) {
      return existingRequest as Promise<T>;
    }

    // Fetch fresh data
    this.metrics[bucket].misses++;

    const fetchPromise = (async () => {
      try {
        const fresh = await fetcher();
        const valueToStore = allowNull ? (fresh ?? null) : fresh;

        if (ttlSeconds === null) {
          await this.cache.set(key, valueToStore);
        } else {
          await this.cache.set(key, valueToStore, ttlSeconds);
        }

        return fresh;
      } finally {
        this.inflightRequests.delete(key);
      }
    })();

    this.inflightRequests.set(key, fetchPromise);
    return fetchPromise;
  }

  /**
   * Invalidate a single cache key and track the invalidation.
   */
  protected async invalidate(key: string): Promise<void> {
    this.invalidationCount++;
    await this.cache.del(key);
  }

  /**
   * Invalidate all keys matching a pattern and track the invalidation.
   */
  protected async invalidatePattern(pattern: string): Promise<void> {
    this.invalidationCount++;
    await this.cache.delPattern(pattern);
  }

  /**
   * Get the current invalidation count.
   */
  protected getInvalidationCount(): number {
    return this.invalidationCount;
  }

  /**
   * Get a snapshot of metrics for a specific bucket.
   */
  protected getBucketMetrics(bucket: TBucket): CacheBucketMetrics {
    return { ...this.metrics[bucket] };
  }
}
