export interface CacheEntry<V> {
  value: V;
  createdAt: number;
  expiresAt: number;
  staleUntil: number;
  tags: string[];
}

export type CacheLookup<V> = { state: "miss" } | { state: "fresh" | "stale"; entry: CacheEntry<V> };

export interface CacheSetOptions {
  ttlMs?: number;
  staleWhileRevalidateMs?: number;
  tags?: string[];
}

export interface TagCacheOptions {
  ttlMs?: number;
  staleWhileRevalidateMs?: number;
  maxEntries?: number;
  clock?: () => number;
  onEvent?: (event: CacheEvent) => void;
}

export interface CacheEvent {
  operation: "hit" | "stale" | "miss" | "set" | "delete" | "invalidate" | "clear" | "load";
  key?: string;
  tags?: string[];
  at: number;
}

export interface CacheSnapshotEntry {
  key: string;
  state: "fresh" | "stale";
  createdAt: number;
  expiresAt: number;
  staleUntil: number;
  tags: string[];
}

export class TagCache<V = unknown> {
  private entries = new Map<string, CacheEntry<V>>();
  private tagIndex = new Map<string, Set<string>>();
  private pending = new Map<string, Promise<V>>();
  private locks = new Map<string, Promise<void>>();
  private revisions = new Map<string, number>();
  private readonly ttlMs: number;
  private readonly staleMs: number;
  private readonly maxEntries: number;
  private readonly clock: () => number;
  private readonly onEvent?: (event: CacheEvent) => void;

  constructor(options: TagCacheOptions = {}) {
    this.ttlMs = options.ttlMs ?? 60_000;
    this.staleMs = options.staleWhileRevalidateMs ?? 0;
    this.maxEntries = Math.max(1, options.maxEntries ?? 10_000);
    this.clock = options.clock ?? Date.now;
    this.onEvent = options.onEvent;
  }

  private emit(event: Omit<CacheEvent, "at">): void {
    this.onEvent?.({ ...event, at: this.clock() });
  }

  lookup(key: string): CacheLookup<V> {
    const entry = this.entries.get(key);
    if (!entry) {
      this.emit({ operation: "miss", key });
      return { state: "miss" };
    }
    const now = this.clock();
    if (entry.staleUntil <= now) {
      this.delete(key);
      this.emit({ operation: "miss", key });
      return { state: "miss" };
    }
    this.entries.delete(key);
    this.entries.set(key, entry);
    const state = entry.expiresAt > now ? "fresh" : "stale";
    this.emit({ operation: state === "fresh" ? "hit" : "stale", key, tags: entry.tags });
    return { state, entry };
  }

  get(key: string): V | undefined {
    const hit = this.lookup(key);
    return hit.state === "miss" ? undefined : hit.entry.value;
  }

  set(key: string, value: V, options: CacheSetOptions = {}): void {
    this.bump(key);
    this.store(key, value, options);
  }

  private store(key: string, value: V, options: CacheSetOptions): void {
    this.removeEntry(key);
    const now = this.clock();
    const ttlMs = Math.max(0, options.ttlMs ?? this.ttlMs);
    const staleMs = Math.max(0, options.staleWhileRevalidateMs ?? this.staleMs);
    const tags = [...new Set(options.tags ?? [])];
    const entry: CacheEntry<V> = {
      value,
      createdAt: now,
      expiresAt: now + ttlMs,
      staleUntil: now + ttlMs + staleMs,
      tags,
    };
    this.entries.set(key, entry);
    this.emit({ operation: "set", key, tags });
    for (const tag of tags) {
      const keys = this.tagIndex.get(tag) ?? new Set<string>();
      keys.add(key);
      this.tagIndex.set(tag, keys);
    }
    while (this.entries.size > this.maxEntries) {
      const oldest = this.entries.keys().next().value as string | undefined;
      if (oldest === undefined) break;
      this.delete(oldest);
    }
  }

  async getOrLoad(
    key: string,
    loader: () => V | Promise<V>,
    options: CacheSetOptions = {},
  ): Promise<V> {
    const hit = this.lookup(key);
    if (hit.state === "fresh") return hit.entry.value;
    if (hit.state === "stale") {
      if (!this.pending.has(key)) {
        const revision = this.revision(key);
        this.emit({ operation: "load", key, tags: options.tags });
        const refresh = Promise.resolve()
          .then(loader)
          .then((value) => {
            if (this.revision(key) === revision) this.store(key, value, options);
            return value;
          })
          .finally(() => this.pending.delete(key));
        this.pending.set(key, refresh);
      }
      return hit.entry.value;
    }
    const existing = this.pending.get(key);
    if (existing) return existing;
    const revision = this.revision(key);
    this.emit({ operation: "load", key, tags: options.tags });
    const pending = Promise.resolve()
      .then(loader)
      .then((value) => {
        if (this.revision(key) === revision) this.store(key, value, options);
        return value;
      })
      .finally(() => this.pending.delete(key));
    this.pending.set(key, pending);
    return pending;
  }

  /** Serialize arbitrary cache-adjacent work for a key without storing its result. */
  async withLock<T>(key: string, task: () => T | Promise<T>): Promise<T> {
    const previous = this.locks.get(key) ?? Promise.resolve();
    let release!: () => void;
    const current = new Promise<void>((resolve) => (release = resolve));
    const queued = previous.then(() => current);
    this.locks.set(key, queued);
    await previous;
    try {
      return await task();
    } finally {
      release();
      if (this.locks.get(key) === queued) this.locks.delete(key);
    }
  }

  delete(key: string): boolean {
    const removed = this.removeEntry(key);
    if (removed || this.pending.has(key)) this.bump(key);
    if (removed) this.emit({ operation: "delete", key });
    return removed;
  }

  private removeEntry(key: string): boolean {
    const entry = this.entries.get(key);
    if (!entry) return false;
    this.entries.delete(key);
    for (const tag of entry.tags) {
      const keys = this.tagIndex.get(tag);
      keys?.delete(key);
      if (keys?.size === 0) this.tagIndex.delete(tag);
    }
    return true;
  }

  invalidateTag(tag: string): number {
    const keys = [...(this.tagIndex.get(tag) ?? [])];
    for (const key of keys) this.delete(key);
    this.emit({ operation: "invalidate", tags: [tag] });
    return keys.length;
  }

  invalidateTags(tags: Iterable<string>): number {
    const requested = [...tags];
    const keys = new Set<string>();
    for (const tag of requested) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
    for (const key of keys) this.delete(key);
    this.emit({ operation: "invalidate", tags: requested });
    return keys.size;
  }

  clear(): void {
    for (const key of new Set([...this.entries.keys(), ...this.pending.keys()])) this.bump(key);
    this.entries.clear();
    this.tagIndex.clear();
    this.emit({ operation: "clear" });
  }

  get size(): number {
    return this.entries.size;
  }

  snapshot(): CacheSnapshotEntry[] {
    const now = this.clock();
    return [...this.entries.entries()].map(([key, entry]) => ({
      key,
      state: entry.expiresAt > now ? "fresh" : "stale",
      createdAt: entry.createdAt,
      expiresAt: entry.expiresAt,
      staleUntil: entry.staleUntil,
      tags: [...entry.tags],
    }));
  }

  private revision(key: string): number {
    return this.revisions.get(key) ?? 0;
  }

  private bump(key: string): void {
    this.revisions.set(key, this.revision(key) + 1);
  }
}
