/**
 * Regression test for [CDX-4] cross-mode cache contamination.
 *
 * Before v0.32.3 (PR #897 as merged), the query_cache primary key was
 * sha256(source_id::query_text) — a tokenmax search (expansion=on, limit=50)
 * would populate a row that a subsequent conservative call (no expansion,
 * limit=10) read back, serving the wrong-shape results.
 *
 * After v0.32.3:
 *   - cacheRowId(query, source, knobsHash) — knobsHash is part of the PK
 *   - SemanticQueryCache.lookup({knobsHash}) filters WHERE knobs_hash = $
 *   - SemanticQueryCache.store({knobsHash}) writes the resolved hash
 *
 * This test exercises the cache class directly on a fresh PGLite brain
 * to verify cross-mode writes don't collide.
 */
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { SemanticQueryCache, cacheRowId } from '../src/core/search/query-cache.ts';
import type { SearchResult } from '../src/core/types.ts';
import { knobsHash, resolveSearchMode } from '../src/core/search/mode.ts';
import { resolveHardExcludes } from '../src/core/search/source-boost.ts';
import { resetFtsLanguageCache } from '../src/core/fts-language.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';

let engine: PGLiteEngine;

/**
 * Run `fn` with GBRAIN_FTS_LANGUAGE pinned, then restore this process's
 * original value. getFtsLanguage() memoizes, so the cache is reset on both
 * edges — otherwise the pin would leak into the mode hashes computed below
 * (and, for an operator who runs the suite with the env set, flip them).
 * Serial file: direct process.env mutation is the sanctioned pattern here
 * (isolation guard R1).
 */
function withFtsLanguage<T>(language: string | undefined, fn: () => T): T {
  const saved = process.env.GBRAIN_FTS_LANGUAGE;
  if (language === undefined) delete process.env.GBRAIN_FTS_LANGUAGE;
  else process.env.GBRAIN_FTS_LANGUAGE = language;
  resetFtsLanguageCache();
  try {
    return fn();
  } finally {
    if (saved === undefined) delete process.env.GBRAIN_FTS_LANGUAGE;
    else process.env.GBRAIN_FTS_LANGUAGE = saved;
    resetFtsLanguageCache();
  }
}

const conservativeHash = knobsHash(resolveSearchMode({ mode: 'conservative' }));
const balancedHash = knobsHash(resolveSearchMode({ mode: 'balanced' }));
const tokenmaxHash = knobsHash(resolveSearchMode({ mode: 'tokenmax' }));

beforeAll(async () => {
  // v0.36.2.0: DEFAULT_EMBEDDING_DIMENSIONS flipped to 1280 (ZE Matryoshka).
  // The makeEmbedding fixture below emits 1536-dim unit vectors. If we let
  // initSchema() inherit the default, query_cache.embedding gets sized at
  // halfvec(1280) and the inserts throw "expected 1280 dimensions, not 1536".
  // Pin the gateway to 1536d so this file is hermetic regardless of
  // gateway state from other tests in the shard. Pattern matches
  // test/consolidate-valid-until.test.ts.
  resetGateway();
  configureGateway({
    embedding_model: 'openai:text-embedding-3-large',
    embedding_dimensions: 1536,
    env: { OPENAI_API_KEY: 'sk-fake' },
  });
  engine = new PGLiteEngine();
  await engine.connect({});
  await engine.initSchema();
});

afterAll(async () => {
  await engine.disconnect();
  resetGateway();
});

beforeEach(async () => {
  // Clear cache before each test so writes start from empty state.
  await engine.executeRaw('DELETE FROM query_cache');
});

const makeEmbedding = (seed: number): Float32Array => {
  const arr = new Float32Array(1536);
  for (let i = 0; i < 1536; i++) {
    arr[i] = Math.sin(seed + i * 0.001);
  }
  // Normalize to unit length so cosine similarity is well-defined.
  let norm = 0;
  for (let i = 0; i < 1536; i++) norm += arr[i] * arr[i];
  norm = Math.sqrt(norm);
  if (norm > 0) for (let i = 0; i < 1536; i++) arr[i] /= norm;
  return arr;
};

const makeResults = (label: string, n: number): SearchResult[] =>
  Array.from({ length: n }, (_, i) => ({
    slug: `${label}/result-${i}`,
    title: `${label} result ${i}`,
    chunk_text: `chunk-${label}-${i}`,
    chunk_id: (i + 1) * 1000,
    score: 1 / (i + 1),
    chunk_index: i,
    type: 'note' as const,
    chunk_source: 'compiled_truth' as const,
    page_id: i + 1,
    stale: false,
  }));

describe('cacheRowId is bifurcated by knobsHash', () => {
  test('same (query, source) but different knobs → different row IDs', () => {
    const id1 = cacheRowId('what is the meaning of life', 'default', conservativeHash);
    const id2 = cacheRowId('what is the meaning of life', 'default', tokenmaxHash);
    expect(id1).not.toBe(id2);
  });

  test('same (query, source, knobs) → same row ID (idempotent)', () => {
    const id1 = cacheRowId('what is the meaning of life', 'default', balancedHash);
    const id2 = cacheRowId('what is the meaning of life', 'default', balancedHash);
    expect(id1).toBe(id2);
  });

  test('empty knobsHash still produces a valid ID (test-fixture compatibility)', () => {
    const id = cacheRowId('q', 'default', '');
    expect(id).toMatch(/^[0-9a-f]{32}$/);
  });

  test('all three mode hashes are distinct', () => {
    expect(conservativeHash).not.toBe(balancedHash);
    expect(balancedHash).not.toBe(tokenmaxHash);
    expect(conservativeHash).not.toBe(tokenmaxHash);
  });
});

describe('SemanticQueryCache cross-mode isolation (CDX-4 hotfix)', () => {
  test('tokenmax write does NOT contaminate conservative lookup', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(1);
    const tokenmaxResults = makeResults('tokenmax', 50);

    // Write under tokenmax knobs.
    await cache.store('what is the meaning of life', emb, tokenmaxResults, {
      vector_enabled: true,
      detail_resolved: null,
      expansion_applied: true,
    }, { knobsHash: tokenmaxHash });

    // Lookup under conservative knobs with the same embedding → MISS.
    const conservativeHit = await cache.lookup(emb, { knobsHash: conservativeHash });
    expect(conservativeHit.hit).toBe(false);

    // Lookup under tokenmax knobs with the same embedding → HIT.
    const tokenmaxHit = await cache.lookup(emb, { knobsHash: tokenmaxHash });
    expect(tokenmaxHit.hit).toBe(true);
    expect(tokenmaxHit.results?.length).toBe(50);
    expect(tokenmaxHit.results?.[0].slug).toBe('tokenmax/result-0');
  });

  test('three modes coexist as distinct rows for the same query', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(2);

    await cache.store('q', emb, makeResults('conservative', 10), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: conservativeHash });
    await cache.store('q', emb, makeResults('balanced', 25), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: balancedHash });
    await cache.store('q', emb, makeResults('tokenmax', 50), {
      vector_enabled: true, detail_resolved: null, expansion_applied: true,
    }, { knobsHash: tokenmaxHash });

    const rows = await engine.executeRaw<{ n: number }>(
      `SELECT COUNT(*)::int AS n FROM query_cache WHERE query_text = 'q'`,
    );
    expect(rows[0].n).toBe(3);

    const cHit = await cache.lookup(emb, { knobsHash: conservativeHash });
    expect(cHit.hit).toBe(true);
    expect(cHit.results?.length).toBe(10);
    expect(cHit.results?.[0].slug).toBe('conservative/result-0');

    const bHit = await cache.lookup(emb, { knobsHash: balancedHash });
    expect(bHit.hit).toBe(true);
    expect(bHit.results?.length).toBe(25);

    const tHit = await cache.lookup(emb, { knobsHash: tokenmaxHash });
    expect(tHit.hit).toBe(true);
    expect(tHit.results?.length).toBe(50);
  });

  test('legacy rows (NULL knobs_hash) are excluded from lookup', async () => {
    // Manually insert a row with NULL knobs_hash (simulating pre-v0.32.3 state).
    const emb = makeEmbedding(3);
    const vecStr = `[${Array.from(emb).map(v => v.toFixed(6)).join(',')}]`;
    await engine.executeRaw(
      `INSERT INTO query_cache (id, query_text, source_id, knobs_hash, embedding, results, meta, ttl_seconds, created_at)
       VALUES ($1, $2, $3, NULL, $4::vector, $5::jsonb, $6::jsonb, 3600, now())`,
      [
        'legacy-row-id',
        'legacy-query',
        'default',
        vecStr,
        JSON.stringify(makeResults('legacy', 5)),
        JSON.stringify({ vector_enabled: true, detail_resolved: null, expansion_applied: false }),
      ],
    );

    const cache = new SemanticQueryCache(engine);
    // Any mode's lookup → MISS (NULL row is excluded by the knobs_hash filter).
    expect((await cache.lookup(emb, { knobsHash: conservativeHash })).hit).toBe(false);
    expect((await cache.lookup(emb, { knobsHash: balancedHash })).hit).toBe(false);
    expect((await cache.lookup(emb, { knobsHash: tokenmaxHash })).hit).toBe(false);
  });

  test('same mode written twice updates in place (no duplicate rows)', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(4);

    await cache.store('q', emb, makeResults('first', 5), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: balancedHash });

    await cache.store('q', emb, makeResults('second', 7), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: balancedHash });

    const rows = await engine.executeRaw<{ n: number }>(
      `SELECT COUNT(*)::int AS n FROM query_cache WHERE query_text = 'q'`,
    );
    expect(rows[0].n).toBe(1);

    const hit = await cache.lookup(emb, { knobsHash: balancedHash });
    expect(hit.hit).toBe(true);
    expect(hit.results?.length).toBe(7);
    expect(hit.results?.[0].slug).toBe('second/result-0');
  });

  test('empty knobsHash arg writes a row but does not collide with mode-hash rows', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(5);

    await cache.store('q', emb, makeResults('no-mode', 3), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    });

    // No-mode lookup hits its own row.
    const noModeHit = await cache.lookup(emb);
    expect(noModeHit.hit).toBe(true);
    expect(noModeHit.results?.length).toBe(3);

    // Conservative-hash lookup misses (the no-mode row had empty hash).
    const conservativeHit = await cache.lookup(emb, { knobsHash: conservativeHash });
    expect(conservativeHit.hit).toBe(false);
  });
});

describe('hard-exclude cache isolation (#2825)', () => {
  // Hashes computed the way hybridSearchCached does: same resolved mode, ctx
  // carrying the resolved hard-exclude list. A row written by a process
  // WITHOUT GBRAIN_SEARCH_EXCLUDE (defaults only) must not be served to a
  // process WITH it, and vice versa.
  const noEnvHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), {
    hardExcludes: resolveHardExcludes(undefined, undefined, undefined),
  });
  const envExcludeHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), {
    hardExcludes: resolveHardExcludes(undefined, undefined, 'private/'),
  });

  test('row written without excludes is NOT served to a lookup with excludes', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(6);

    // Simulate a no-exclude process writing results that include a slug the
    // excluding process must never see.
    const leaky = makeResults('private', 5);
    await cache.store('who is alice', emb, leaky, {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: noEnvHash });

    // Excluding process → MISS (falls through to a fresh, filtered query).
    const excluded = await cache.lookup(emb, { knobsHash: envExcludeHash });
    expect(excluded.hit).toBe(false);

    // Original no-exclude process still hits its own row.
    const original = await cache.lookup(emb, { knobsHash: noEnvHash });
    expect(original.hit).toBe(true);
    expect(original.results?.length).toBe(5);
  });

  test('row written WITH excludes is not served back once excludes are lifted', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(7);

    await cache.store('who is alice', emb, makeResults('filtered', 3), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: envExcludeHash });

    expect((await cache.lookup(emb, { knobsHash: noEnvHash })).hit).toBe(false);
    expect((await cache.lookup(emb, { knobsHash: envExcludeHash })).hit).toBe(true);
  });
});

describe('FTS language cache isolation', () => {
  // GBRAIN_FTS_LANGUAGE retokenizes BOTH sides of the keyword arm (the
  // trigger-built search_vector and the query-side websearch_to_tsquery), so
  // rows written under one language describe a different index than the one a
  // post-`reindex-search-vector` process queries. knobsHash folds the resolved
  // language in (`fts=`) so those rows can never be served across the switch.
  const englishHash = withFtsLanguage(undefined, () =>
    knobsHash(resolveSearchMode({ mode: 'balanced' })));
  const portugueseHash = withFtsLanguage('portuguese', () =>
    knobsHash(resolveSearchMode({ mode: 'balanced' })));

  test('the resolved language changes the hash', () => {
    expect(englishHash).not.toBe(portugueseHash);
    // An invalid value falls back to english inside getFtsLanguage(), so it
    // must land on the english row rather than minting an unreachable one.
    expect(withFtsLanguage('NOT A CONFIG', () =>
      knobsHash(resolveSearchMode({ mode: 'balanced' })))).toBe(englishHash);
  });

  test('a row written under english is NOT served after switching to portuguese', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(8);

    // English-tokenized run: 'running' stems to 'run', so these rows reflect
    // an index the portuguese-configured process no longer has.
    await cache.store('running', emb, makeResults('english-stemmed', 4), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: englishHash });

    // Post-reindex process → MISS (falls through to a fresh keyword query
    // against the retokenized index).
    expect((await cache.lookup(emb, { knobsHash: portugueseHash })).hit).toBe(false);

    // Same-language process still hits its own row.
    const same = await cache.lookup(emb, { knobsHash: englishHash });
    expect(same.hit).toBe(true);
    expect(same.results?.length).toBe(4);
  });

  test('switching back does not resurrect the other language rows', async () => {
    const cache = new SemanticQueryCache(engine);
    const emb = makeEmbedding(9);

    await cache.store('running', emb, makeResults('portuguese-stemmed', 2), {
      vector_enabled: true, detail_resolved: null, expansion_applied: false,
    }, { knobsHash: portugueseHash });

    expect((await cache.lookup(emb, { knobsHash: englishHash })).hit).toBe(false);
    expect((await cache.lookup(emb, { knobsHash: portugueseHash })).hit).toBe(true);
  });
});
