/**
 * Tests for the codebase-knowledge cache (Alt D from the design doc):
 * salient index in system prompt + transparent serve-from-cache when the
 * agent re-issues the same code-map call on an unchanged file.
 *
 * Production pattern these tests guard against (Monopoly mission,
 * May 2026): 22 skeleton calls on the same file, 80% of redundant
 * re-calls happening while the original tool result was still in the
 * agent's active context. The service must:
 *
 *   1. record fetched skeletons / ranges so they can be served on the
 *      next identical call
 *   2. invalidate on writes — never serve stale content after a mutation
 *   3. detect mutations the writers don't know about (terminal commands,
 *      external editors, build outputs) via mtime-on-lookup
 *   4. produce a system-prompt index block that lists what's been
 *      mapped and flags STALE entries
 *   5. enforce per-agent isolation
 *   6. bound growth (LRU under MAX_FILES_PER_AGENT)
 *   7. canonicalize paths workingDir-anchored, monorepo-safe
 */
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import {
  getCodebaseKnowledgeService,
  _resetCodebaseKnowledgeServiceForTests,
} from '../codebaseKnowledgeService.js';

beforeEach(() => _resetCodebaseKnowledgeServiceForTests());

const SILENT_LOGGER = { info(){}, warn(){}, error(){}, debug(){} };

describe('codebaseKnowledgeService', () => {
  describe('recordSkeleton + lookupSkeleton', () => {
    test('records and returns a skeleton for a file', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/engine/gameEngine.ts', 'class GameEngine { ... }');
      const hit = await s.lookupSkeleton('agent-1', 'src/engine/gameEngine.ts');
      expect(hit).not.toBeNull();
      expect(hit.content).toContain('GameEngine');
    });

    test('returns null for a file that was never recorded', async () => {
      const s = getCodebaseKnowledgeService();
      expect(await s.lookupSkeleton('agent-1', 'src/unknown.ts')).toBeNull();
    });

    test('returns null after the file was marked dirty (write occurred)', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/foo.ts', 'class Foo {}');
      s.markDirty('agent-1', 'src/foo.ts');
      expect(await s.lookupSkeleton('agent-1', 'src/foo.ts')).toBeNull();
    });

    test('re-recording after a write clears the dirty flag', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/foo.ts', 'old');
      s.markDirty('agent-1', 'src/foo.ts');
      await s.recordSkeleton('agent-1', 'src/foo.ts', 'new');
      const hit = await s.lookupSkeleton('agent-1', 'src/foo.ts');
      expect(hit.content).toBe('new');
    });

    test('large file content is stored without truncation (200KB cap removed)', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const huge = 'x'.repeat(1_000_000); // 1MB
      await s.recordSkeleton('agent-1', 'src/big.ts', huge);
      const hit = await s.lookupSkeleton('agent-1', 'src/big.ts');
      expect(hit).not.toBeNull();
      expect(hit.content.length).toBe(1_000_000);
    });

    test('ASYMMETRIC-LIE PROTECTION: empty/null content is rejected outright (no metadata-only entry)', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/foo.ts', '');
      await s.recordSkeleton('agent-1', 'src/foo.ts', null);
      await s.recordSkeleton('agent-1', 'src/foo.ts', undefined);
      expect(await s.lookupSkeleton('agent-1', 'src/foo.ts')).toBeNull();
      expect(s.buildIndexBlock('agent-1')).toBe('');
    });
  });

  describe('recordRange + lookupRange', () => {
    test('records and returns an exact range', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordRange('agent-1', 'src/foo.ts', 1, 120, 'lines 1-120');
      const hit = await s.lookupRange('agent-1', 'src/foo.ts', 1, 120);
      expect(hit).not.toBeNull();
      expect(hit.content).toBe('lines 1-120');
    });

    test('different range = miss (we do not slice cached ranges)', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordRange('agent-1', 'src/foo.ts', 1, 120, 'lines 1-120');
      expect(await s.lookupRange('agent-1', 'src/foo.ts', 30, 90)).toBeNull();
    });

    test('range lookup respects dirty flag', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordRange('agent-1', 'src/foo.ts', 1, 120, 'content');
      s.markDirty('agent-1', 'src/foo.ts');
      expect(await s.lookupRange('agent-1', 'src/foo.ts', 1, 120)).toBeNull();
    });

    test('asymmetric-lie protection on ranges too: empty content rejected', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordRange('agent-1', 'src/foo.ts', 1, 50, '');
      expect(await s.lookupRange('agent-1', 'src/foo.ts', 1, 50)).toBeNull();
    });
  });

  describe('path canonicalization — workingDir-anchored, monorepo-safe', () => {
    test('absolute path under workingDir collapses to relative path', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const wd = '/home/me/proj';
      await s.recordSkeleton('agent-1', '/home/me/proj/src/foo.ts', 'cached', wd);
      const hit = await s.lookupSkeleton('agent-1', 'src/foo.ts', wd);
      expect(hit).not.toBeNull();
      expect(hit.content).toBe('cached');
    });

    test('absolute path NOT under workingDir keeps full path', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const wd = '/home/me/proj';
      await s.recordSkeleton('agent-1', '/etc/config.json', 'cached', wd);
      expect(await s.lookupSkeleton('agent-1', 'config.json', wd)).toBeNull();
      expect(await s.lookupSkeleton('agent-1', '/etc/config.json', wd)).not.toBeNull();
    });

    test('Windows backslash paths normalize to forward slashes', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const wd = 'C:\\proj';
      await s.recordSkeleton('agent-1', 'C:\\proj\\src\\foo.ts', 'cached', wd);
      const hit = await s.lookupSkeleton('agent-1', 'src/foo.ts', wd);
      expect(hit).not.toBeNull();
      expect(hit.content).toBe('cached');
    });

    test('Windows drive letter case normalization: C: and c: collide', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'C:/proj/src/foo.ts', 'cached', 'C:/proj');
      const hit = await s.lookupSkeleton('agent-1', 'c:/proj/src/foo.ts', 'c:/proj');
      expect(hit).not.toBeNull();
    });

    test('MONOREPO COLLISION FIX: apps/web/src/Foo.tsx and apps/api/src/Foo.tsx are DISTINCT', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'apps/web/src/Foo.tsx', 'web-version');
      await s.recordSkeleton('agent-1', 'apps/api/src/Foo.tsx', 'api-version');
      const webHit = await s.lookupSkeleton('agent-1', 'apps/web/src/Foo.tsx');
      const apiHit = await s.lookupSkeleton('agent-1', 'apps/api/src/Foo.tsx');
      expect(webHit.content).toBe('web-version');
      expect(apiHit.content).toBe('api-version');
    });

    test('MONOREPO COLLISION FIX: services/auth/src/index.ts vs services/billing/src/index.ts distinct', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'services/auth/src/index.ts', 'auth');
      await s.recordSkeleton('agent-1', 'services/billing/src/index.ts', 'billing');
      expect((await s.lookupSkeleton('agent-1', 'services/auth/src/index.ts')).content).toBe('auth');
      expect((await s.lookupSkeleton('agent-1', 'services/billing/src/index.ts')).content).toBe('billing');
    });

    test('VENDOR COLLISION FIX: node_modules/x/src/index.js vs src/index.js distinct', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'node_modules/lodash/src/index.js', 'vendor');
      await s.recordSkeleton('agent-1', 'src/index.js', 'mine');
      expect((await s.lookupSkeleton('agent-1', 'node_modules/lodash/src/index.js')).content).toBe('vendor');
      expect((await s.lookupSkeleton('agent-1', 'src/index.js')).content).toBe('mine');
    });

    test('trailing slash on workingDir does not affect canonicalization', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', '/home/me/proj/src/foo.ts', 'cached', '/home/me/proj/');
      const hit = await s.lookupSkeleton('agent-1', '/home/me/proj/src/foo.ts', '/home/me/proj');
      expect(hit).not.toBeNull();
    });

    test('leading "./" stripped: ./src/foo === src/foo', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', './src/foo.ts', 'cached');
      expect(await s.lookupSkeleton('agent-1', 'src/foo.ts')).not.toBeNull();
    });

    test('SIBLING-NAME-PREFIX GUARD: workingDir=/foo does NOT slice /foobar/file.ts', async () => {
      // Without the trailing-slash guard, '/foobar/file.ts'.startsWith('/foo')
      // would be true, incorrectly stripping to 'bar/file.ts'.
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', '/foobar/file.ts', 'sibling-content', '/foo');
      expect(await s.lookupSkeleton('agent-1', '/foobar/file.ts', '/foo')).not.toBeNull();
      expect(await s.lookupSkeleton('agent-1', 'bar/file.ts', '/foo')).toBeNull();
    });

    test('non-string and empty paths are gracefully rejected', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await expect(s.recordSkeleton('agent-1', null, 'x')).resolves.toBeUndefined();
      await expect(s.recordSkeleton('agent-1', '', 'x')).resolves.toBeUndefined();
      expect(await s.lookupSkeleton('agent-1', null)).toBeNull();
    });
  });

  describe('per-agent isolation', () => {
    test('one agent\'s cache cannot be read by another agent', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-A', 'src/secret.ts', 'A-private');
      expect(await s.lookupSkeleton('agent-B', 'src/secret.ts')).toBeNull();
    });

    test('clear(agentId) drops one agent\'s cache without touching others', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-A', 'src/a.ts', 'a');
      await s.recordSkeleton('agent-B', 'src/b.ts', 'b');
      s.clear('agent-A');
      expect(await s.lookupSkeleton('agent-A', 'src/a.ts')).toBeNull();
      expect(await s.lookupSkeleton('agent-B', 'src/b.ts')).not.toBeNull();
    });
  });

  describe('buildIndexBlock', () => {
    test('empty cache returns empty string (no block injected)', () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      expect(s.buildIndexBlock('agent-1')).toBe('');
    });

    test('produces a CODEBASE KNOWLEDGE block with one line per file', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/foo.ts', 'class Foo {}');
      await s.recordSkeleton('agent-1', 'src/bar.ts', 'class Bar {}');
      const block = s.buildIndexBlock('agent-1');
      expect(block).toContain('## CODEBASE KNOWLEDGE');
      expect(block).toContain('src/foo.ts');
      expect(block).toContain('src/bar.ts');
    });

    test('flags STALE entries (files written to after last cache fill)', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/foo.ts', 'old');
      s.markDirty('agent-1', 'src/foo.ts');
      const block = s.buildIndexBlock('agent-1');
      expect(block).toContain('STALE');
    });

    test('range labels appear under the file when ranges are cached', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordRange('agent-1', 'src/foo.ts', 1, 100, '...');
      await s.recordRange('agent-1', 'src/foo.ts', 200, 300, '...');
      const block = s.buildIndexBlock('agent-1');
      expect(block).toContain('ranges=[1-100, 200-300]');
    });

    test('PHANTOM-CITATION FIX: no "skeleton@#N" / "msg #N" markers in the index block', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/foo.ts', 'x');
      await s.recordRange('agent-1', 'src/foo.ts', 1, 100, 'x');
      const block = s.buildIndexBlock('agent-1');
      expect(block).not.toContain('@#');
      expect(block).not.toContain('msg #');
      expect(block).not.toContain('#-1');
      expect(block).toContain('skeleton');
      expect(block).toContain('ranges=');
    });

    test('REGRESSION: minimal format — no verbose explanatory paragraph in the block', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-1', 'src/foo.ts', 'x');
      const block = s.buildIndexBlock('agent-1');
      expect(block.toLowerCase()).not.toContain('wasted work');
      expect(block.toLowerCase()).not.toContain('cache will serve');
      expect(block.toLowerCase()).not.toContain('re-issuing');
      expect(block).toContain('## CODEBASE KNOWLEDGE');
    });
  });

  describe('LRU bound (MAX_FILES_PER_AGENT)', () => {
    test('cache evicts oldest file when capacity is exceeded', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      for (let i = 0; i < 60; i++) {
        await s.recordSkeleton('agent-1', `src/file-${i}.ts`, `content-${i}`);
      }
      expect(await s.lookupSkeleton('agent-1', 'src/file-0.ts')).toBeNull();
      expect(await s.lookupSkeleton('agent-1', 'src/file-1.ts')).toBeNull();
      expect(await s.lookupSkeleton('agent-1', 'src/file-59.ts')).not.toBeNull();
    });

    test('re-recording a file moves it to most-recent (LRU semantic)', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      for (let i = 0; i < 50; i++) await s.recordSkeleton('agent-1', `src/f-${i}.ts`, 'x');
      await s.recordSkeleton('agent-1', 'src/f-0.ts', 'x');
      await s.recordSkeleton('agent-1', 'src/f-NEW.ts', 'x');
      expect(await s.lookupSkeleton('agent-1', 'src/f-0.ts')).not.toBeNull();
    });
  });

  // ─── mtime-on-lookup freshness (the catch-all for mutations
  //     `markDirty` doesn't know about) ─────────────────────────────
  describe('mtime-on-lookup freshness check', () => {
    let tmpDir;
    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cbk-mtime-'));
    });
    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    test('serves cache hit when file mtime is unchanged since record', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const file = path.join(tmpDir, 'foo.ts');
      await fs.writeFile(file, 'class Foo {}', 'utf8');
      await s.recordSkeleton('agent-1', file, 'class Foo {}', tmpDir);
      // No mutation — cache hit expected.
      const hit = await s.lookupSkeleton('agent-1', file, tmpDir);
      expect(hit).not.toBeNull();
      expect(hit.content).toBe('class Foo {}');
    });

    test('TERMINAL MUTATION caught: file modified by external write returns null + flips STALE', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const file = path.join(tmpDir, 'foo.ts');
      await fs.writeFile(file, 'v1', 'utf8');
      await s.recordSkeleton('agent-1', file, 'v1', tmpDir);
      // Simulate a `sed -i` / external editor / build output write.
      // markDirty was NOT called — this is the gap mtime-on-lookup
      // exists to plug.
      await new Promise(r => setTimeout(r, 20)); // ensure mtime tick
      await fs.writeFile(file, 'v2', 'utf8');
      // Lookup must return null and flip the entry to dirty.
      const hit = await s.lookupSkeleton('agent-1', file, tmpDir);
      expect(hit).toBeNull();
      // Next index injection should now show STALE.
      const block = s.buildIndexBlock('agent-1');
      expect(block).toContain('STALE');
    });

    test('FILE DELETION caught: lookup returns null', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const file = path.join(tmpDir, 'foo.ts');
      await fs.writeFile(file, 'content', 'utf8');
      await s.recordSkeleton('agent-1', file, 'content', tmpDir);
      await fs.unlink(file);
      expect(await s.lookupSkeleton('agent-1', file, tmpDir)).toBeNull();
    });

    test('RANGE mtime check works the same way', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const file = path.join(tmpDir, 'foo.ts');
      await fs.writeFile(file, 'line1\nline2\nline3', 'utf8');
      await s.recordRange('agent-1', file, 1, 3, 'line1\nline2\nline3', tmpDir);
      // Verify cache hit before mutation.
      expect(await s.lookupRange('agent-1', file, 1, 3, tmpDir)).not.toBeNull();
      await new Promise(r => setTimeout(r, 20));
      await fs.writeFile(file, 'changed', 'utf8');
      expect(await s.lookupRange('agent-1', file, 1, 3, tmpDir)).toBeNull();
    });

    test('IDENTICAL CONTENT REWRITE: mtime still bumps → treated as dirty', async () => {
      // Strict behaviour by design — a write is a write, even if it
      // wrote identical bytes. Better to re-skeleton needlessly than
      // to risk serving stale content.
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const file = path.join(tmpDir, 'foo.ts');
      await fs.writeFile(file, 'same', 'utf8');
      await s.recordSkeleton('agent-1', file, 'same', tmpDir);
      await new Promise(r => setTimeout(r, 20));
      await fs.writeFile(file, 'same', 'utf8');
      expect(await s.lookupSkeleton('agent-1', file, tmpDir)).toBeNull();
    });

    test('markDirty fast-path still works (no stat needed)', async () => {
      // markDirty wins immediately: a known writer doesn't need the
      // stat-on-lookup signal. This is the defense-in-depth — both
      // signals exist, whichever fires first marks dirty.
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      const file = path.join(tmpDir, 'foo.ts');
      await fs.writeFile(file, 'v1', 'utf8');
      await s.recordSkeleton('agent-1', file, 'v1', tmpDir);
      s.markDirty('agent-1', file, tmpDir);
      // No mtime bump, but markDirty fired — lookup returns null.
      expect(await s.lookupSkeleton('agent-1', file, tmpDir)).toBeNull();
    });

    test('STAT FAILURE TOLERANCE: missing file at record time gracefully degrades (no mtime captured, lookup still works on cache)', async () => {
      // If we can't stat at record time (synthetic path in tests, race
      // condition, permission glitch), the entry is created without a
      // mtime. Lookups then skip the mtime check — relying solely on
      // markDirty.
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      // Path that doesn't exist on disk.
      await s.recordSkeleton('agent-1', '/nonexistent/path/foo.ts', 'content');
      const hit = await s.lookupSkeleton('agent-1', '/nonexistent/path/foo.ts');
      expect(hit).not.toBeNull();
      expect(hit.content).toBe('content');
    });
  });

  describe('REGRESSION: the Monopoly failure pattern is now blocked', () => {
    test('simulating 22 skeleton calls on gameEngine.ts: 21 of them hit cache', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      let cacheMisses = 0;
      let cacheHits = 0;
      for (let i = 0; i < 22; i++) {
        const hit = await s.lookupSkeleton('agent-mono', 'src/engine/gameEngine.ts');
        if (hit) cacheHits += 1;
        else {
          cacheMisses += 1;
          await s.recordSkeleton('agent-mono', 'src/engine/gameEngine.ts',
                            'class GameEngine { roll(); buyProperty(); }');
        }
      }
      expect(cacheMisses).toBe(1);
      expect(cacheHits).toBe(21);
    });

    test('cache hits stop after a write, resume after re-record', async () => {
      const s = getCodebaseKnowledgeService(SILENT_LOGGER);
      await s.recordSkeleton('agent-mono', 'src/foo.ts', 'v1');
      expect(await s.lookupSkeleton('agent-mono', 'src/foo.ts')).not.toBeNull();
      s.markDirty('agent-mono', 'src/foo.ts');
      expect(await s.lookupSkeleton('agent-mono', 'src/foo.ts')).toBeNull();
      await s.recordSkeleton('agent-mono', 'src/foo.ts', 'v2');
      const hit = await s.lookupSkeleton('agent-mono', 'src/foo.ts');
      expect(hit).not.toBeNull();
      expect(hit.content).toBe('v2');
    });
  });
});
