/**
 * Caching primitives:
 *  - `TTLCache` — a small in-memory time-to-live cache with `getOrLoad`, for
 *    memoising expensive data (query results, computed pages).
 *  - HTTP helpers — `cacheControl` to build a directive, `withCacheControl` to
 *    apply it, and `etag` / `notModified` for conditional requests (304s).
 */

// --- In-memory TTL cache ---------------------------------------------------

interface Entry<V> {
  value: V;
  expiresAt: number;
}

export class TTLCache<V = unknown> {
  private store = new Map<string, Entry<V>>();
  private loading = new Map<string, Promise<V>>();
  private revisions = new Map<string, number>();
  private generation = 0;
  constructor(private readonly ttlMs = 60_000) {}

  get(key: string): V | undefined {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    if (entry.expiresAt <= Date.now()) {
      this.store.delete(key);
      return undefined;
    }
    return entry.value;
  }

  set(key: string, value: V, ttlMs = this.ttlMs): void {
    this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
    this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
  }

  /** Return the cached value or compute, cache, and return it. */
  async getOrLoad(key: string, loader: () => Promise<V> | V, ttlMs = this.ttlMs): Promise<V> {
    const hit = this.get(key);
    if (hit !== undefined) return hit;
    const pending = this.loading.get(key);
    if (pending) return pending;
    const revision = this.revisions.get(key) ?? 0;
    const generation = this.generation;
    const promise = Promise.resolve().then(loader);
    this.loading.set(key, promise);
    try {
      const value = await promise;
      if (this.generation === generation && (this.revisions.get(key) ?? 0) === revision) {
        this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
      }
      return value;
    } finally {
      if (this.loading.get(key) === promise) this.loading.delete(key);
    }
  }

  delete(key: string): void {
    this.store.delete(key);
    this.loading.delete(key);
    this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
  }

  clear(): void {
    this.store.clear();
    this.loading.clear();
    this.revisions.clear();
    this.generation++;
  }

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

// --- HTTP caching ----------------------------------------------------------

export interface CacheControlOptions {
  /** max-age in seconds. */
  maxAge?: number;
  /** s-maxage (shared/CDN cache) in seconds. */
  sMaxAge?: number;
  /** Mark private (per-user) rather than public. */
  private?: boolean;
  /** no-store: never cache. Overrides other directives. */
  noStore?: boolean;
  /** no-cache: revalidate before use. */
  noCache?: boolean;
  /** stale-while-revalidate window in seconds. */
  staleWhileRevalidate?: number;
  /** stale-if-error window in seconds. */
  staleIfError?: number;
  immutable?: boolean;
}

/** Build a Cache-Control header value from options. */
export function cacheControl(options: CacheControlOptions): string {
  if (options.noStore) return "no-store";
  const parts: string[] = [options.private ? "private" : "public"];
  if (options.noCache) parts.push("no-cache");
  if (options.maxAge !== undefined)
    parts.push(`max-age=${Math.max(0, Math.floor(options.maxAge))}`);
  if (options.sMaxAge !== undefined)
    parts.push(`s-maxage=${Math.max(0, Math.floor(options.sMaxAge))}`);
  if (options.staleWhileRevalidate !== undefined) {
    parts.push(`stale-while-revalidate=${Math.max(0, Math.floor(options.staleWhileRevalidate))}`);
  }
  if (options.staleIfError !== undefined) {
    parts.push(`stale-if-error=${Math.max(0, Math.floor(options.staleIfError))}`);
  }
  if (options.immutable) parts.push("immutable");
  return parts.join(", ");
}

/** Apply a Cache-Control header to a response (returns the same response). */
export function withCacheControl(res: Response, options: CacheControlOptions): Response {
  try {
    res.headers.set("Cache-Control", cacheControl(options));
  } catch {
    /* immutable response — skip */
  }
  return res;
}

/** A stable, quoted ETag for a string/bytes body (FNV-1a, weak by default). */
export function etag(body: string | ArrayBuffer | Uint8Array, weak = true): string {
  const bytes =
    typeof body === "string"
      ? new TextEncoder().encode(body)
      : body instanceof Uint8Array
        ? body
        : new Uint8Array(body);
  let hash = 0x811c9dc5;
  for (let i = 0; i < bytes.length; i++) {
    hash ^= bytes[i]!;
    hash = Math.imul(hash, 0x01000193);
  }
  const tag = `"${(hash >>> 0).toString(16)}-${bytes.length.toString(16)}"`;
  return weak ? `W/${tag}` : tag;
}

/** True when the request's If-None-Match matches the given ETag (send a 304). */
export function notModified(req: Request, tag: string): boolean {
  const inm = req.headers.get("if-none-match");
  if (!inm) return false;
  const normalize = (t: string) => t.trim().replace(/^W\//, "");
  const target = normalize(tag);
  return inm.split(",").some((candidate) => normalize(candidate) === target);
}
