/**
 * ADR-121 Phase 21 — Content-addressed embed cache (BEYOND SOTA).
 *
 * The Phase 20 lazy router cuts feature-extraction cost by 24% on a
 * mixed workload — but it still pays the question-embed cost on
 * EVERY query, even when the same question was asked moments ago.
 * Production RAG has heavy repeated-query patterns (FAQs, common
 * queries hit thousands of times). The right fix is a
 * content-addressed cache: hash the (normalized) input text, look
 * up the cached vector, skip the embed entirely on a hit.
 *
 * This module ships:
 *   - `EmbedCache` — in-memory LRU cache with hit/miss stats
 *   - `wrapWithCache(embedFn, cache)` — transparent adapter that
 *     turns any embed function into a cache-checking one
 *
 * Cache key = sha256-hex of (normalized text + dim). The dim is
 * mixed in so two providers with different output sizes don't
 * collide. Normalization: lowercase + trim + collapse internal
 * whitespace — matches what production cache lookups would do.
 *
 * Composes with the Phase 20 lazy router by passing
 * `wrapWithCache(embedFn, cache)` as the `embed` adapter.
 */

import { createHash } from 'node:crypto';

export interface EmbedCacheOptions {
  /** Max entries before LRU eviction kicks in. Default 1000. */
  readonly maxEntries?: number;
  /** Vector dimension — mixed into the cache key. Default auto-detected on first set. */
  readonly dimension?: number;
}

export interface EmbedCacheStats {
  readonly entries: number;
  readonly maxEntries: number;
  readonly hits: number;
  readonly misses: number;
  /** Total lookups (hits + misses). */
  readonly lookups: number;
  /** Hit rate in [0, 1]. */
  readonly hitRate: number;
  /** Total bytes occupied by cached vectors (best-effort estimate). */
  readonly approxByteSize: number;
}

/**
 * In-memory LRU cache for embeddings. The cache key is the sha256
 * hex of the canonicalized text + dim — so two distinct text bodies
 * never collide and the same text always lands on the same key.
 *
 * Cache state is process-local. For cross-process sharing use
 * `PersistentEmbeddingCache` from `persistent-cache.ts`.
 */
export class EmbedCache {
  private readonly maxEntries: number;
  private dim: number | undefined;
  // LRU implementation via a Map (which preserves insertion order
  // in JS) — re-insert on hit to bump to most-recent.
  private readonly store: Map<string, Float32Array> = new Map();
  private hits = 0;
  private misses = 0;

  constructor(options: EmbedCacheOptions = {}) {
    this.maxEntries = options.maxEntries ?? 1000;
    if (this.maxEntries < 1) throw new Error('maxEntries must be >= 1');
    this.dim = options.dimension;
  }

  /**
   * Compute the cache key for a text + dim pair. Exposed for
   * testing + so the lazy router can pre-key lookups.
   */
  keyFor(text: string, dimension: number): string {
    const normalized = canonicalizeText(text);
    return createHash('sha256').update(`${dimension}|${normalized}`).digest('hex');
  }

  get(text: string): Float32Array | undefined {
    if (this.dim === undefined) {
      this.misses++;
      return undefined;
    }
    const key = this.keyFor(text, this.dim);
    const existing = this.store.get(key);
    if (existing) {
      this.hits++;
      // LRU bump — delete + re-insert moves to MRU position.
      this.store.delete(key);
      this.store.set(key, existing);
      return existing;
    }
    this.misses++;
    return undefined;
  }

  set(text: string, vector: Float32Array | number[]): void {
    const v = vector instanceof Float32Array ? vector : new Float32Array(vector);
    if (this.dim === undefined) this.dim = v.length;
    if (v.length !== this.dim) {
      throw new Error(`EmbedCache: vector dim ${v.length} != cache dim ${this.dim}`);
    }
    const key = this.keyFor(text, this.dim);
    // Replace existing key OR add new — both bump to MRU.
    if (this.store.has(key)) this.store.delete(key);
    this.store.set(key, v);
    // LRU evict if over capacity.
    while (this.store.size > this.maxEntries) {
      const oldestKey = this.store.keys().next().value;
      if (oldestKey === undefined) break;
      this.store.delete(oldestKey);
    }
  }

  has(text: string): boolean {
    if (this.dim === undefined) return false;
    return this.store.has(this.keyFor(text, this.dim));
  }

  /** Clear all entries; reset stats. */
  reset(): void {
    this.store.clear();
    this.hits = 0;
    this.misses = 0;
  }

  stats(): EmbedCacheStats {
    const entries = this.store.size;
    const lookups = this.hits + this.misses;
    const bytesPerVec = (this.dim ?? 0) * 4; // Float32 = 4 bytes
    return {
      entries,
      maxEntries: this.maxEntries,
      hits: this.hits,
      misses: this.misses,
      lookups,
      hitRate: lookups === 0 ? 0 : this.hits / lookups,
      approxByteSize: entries * bytesPerVec,
    };
  }
}

/**
 * Canonicalize text for stable cache key derivation: trim, collapse
 * internal whitespace, lowercase. Identical to what production cache
 * lookups should do — "how does AUTH work?" and "how does auth work?"
 * should hit the same cache entry.
 */
export function canonicalizeText(text: string): string {
  return text.trim().replace(/\s+/g, ' ').toLowerCase();
}

/**
 * Wrap an async embed function with a cache-checking adapter. On
 * cache hit, returns the cached vector without calling the wrapped
 * embedder. On miss, calls the wrapped embedder and stores the
 * result.
 *
 * The returned function has the same signature as the input function
 * so it can be passed to any router as a drop-in replacement.
 */
export function wrapWithCache(
  embedder: (text: string) => Promise<Float32Array | number[]>,
  cache: EmbedCache,
): (text: string) => Promise<Float32Array> {
  return async (text: string) => {
    const hit = cache.get(text);
    if (hit) return hit;
    const v = await embedder(text);
    const f32 = v instanceof Float32Array ? v : new Float32Array(v);
    cache.set(text, f32);
    return f32;
  };
}
