import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { FileCache, parseMaxSize } from '../src/proxymodel/file-cache.js';

function makeTmpDir(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'mcpctl-cache-test-'));
}

function rmrf(dir: string): void {
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
}

describe('FileCache', () => {
  let tmpDir: string;

  beforeEach(() => {
    tmpDir = makeTmpDir();
  });
  afterEach(() => {
    rmrf(tmpDir);
  });

  // -- Basic get/set --

  it('returns null on cache miss', async () => {
    const cache = new FileCache('test-ns', { dir: tmpDir });
    expect(await cache.get('missing')).toBeNull();
  });

  it('stores and retrieves values', async () => {
    const cache = new FileCache('test-ns', { dir: tmpDir });
    await cache.set('key1', 'hello world');
    expect(await cache.get('key1')).toBe('hello world');
  });

  it('getOrCompute computes on miss, returns cached on hit', async () => {
    const cache = new FileCache('test-ns', { dir: tmpDir });
    let calls = 0;
    const compute = async () => { calls++; return 'computed'; };

    const v1 = await cache.getOrCompute('k', compute);
    const v2 = await cache.getOrCompute('k', compute);

    expect(v1).toBe('computed');
    expect(v2).toBe('computed');
    expect(calls).toBe(1);
  });

  it('hash produces consistent 16-char hex strings', () => {
    const cache = new FileCache('test-ns', { dir: tmpDir });
    const h1 = cache.hash('hello');
    const h2 = cache.hash('hello');
    const h3 = cache.hash('world');

    expect(h1).toBe(h2);
    expect(h1).not.toBe(h3);
    expect(h1).toHaveLength(16);
    expect(/^[0-9a-f]+$/.test(h1)).toBe(true);
  });

  // -- Persistence (L2 disk) --

  it('persists values to disk across instances', async () => {
    const cache1 = new FileCache('persist-ns', { dir: tmpDir });
    await cache1.set('pk', 'persistent-value');

    // New instance, same namespace — should find it on disk
    const cache2 = new FileCache('persist-ns', { dir: tmpDir });
    expect(await cache2.get('pk')).toBe('persistent-value');
  });

  it('creates .dat files on disk', async () => {
    const cache = new FileCache('disk-ns', { dir: tmpDir });
    await cache.set('mykey', 'data');

    const nsDir = path.join(tmpDir, 'disk-ns');
    const files = fs.readdirSync(nsDir).filter((f) => f.endsWith('.dat'));
    expect(files.length).toBe(1);
  });

  // -- Namespace isolation --

  it('different namespaces are isolated', async () => {
    const cacheA = new FileCache('ns-alpha', { dir: tmpDir });
    const cacheB = new FileCache('ns-beta', { dir: tmpDir });

    await cacheA.set('shared-key', 'alpha-value');
    await cacheB.set('shared-key', 'beta-value');

    expect(await cacheA.get('shared-key')).toBe('alpha-value');
    expect(await cacheB.get('shared-key')).toBe('beta-value');
  });

  it('provider--model--proxymodel namespaces are separate', async () => {
    const ns1 = 'openai--gpt-4o--content-pipeline';
    const ns2 = 'anthropic--claude-sonnet-4-20250514--content-pipeline';
    const ns3 = 'openai--gpt-4o--default';

    const c1 = new FileCache(ns1, { dir: tmpDir });
    const c2 = new FileCache(ns2, { dir: tmpDir });
    const c3 = new FileCache(ns3, { dir: tmpDir });

    await c1.set('k', 'from-gpt4o-pipeline');
    await c2.set('k', 'from-claude-pipeline');
    await c3.set('k', 'from-gpt4o-default');

    expect(await c1.get('k')).toBe('from-gpt4o-pipeline');
    expect(await c2.get('k')).toBe('from-claude-pipeline');
    expect(await c3.get('k')).toBe('from-gpt4o-default');

    // Verify separate directories on disk
    const dirs = fs.readdirSync(tmpDir);
    expect(dirs.length).toBe(3);
  });

  // -- L1 memory cache --

  it('L1 memory cache has LRU eviction', async () => {
    const cache = new FileCache('lru-ns', { dir: tmpDir, maxMemoryEntries: 3 });

    await cache.set('a', '1');
    await cache.set('b', '2');
    await cache.set('c', '3');
    expect(cache.memorySize).toBe(3);

    await cache.set('d', '4');
    expect(cache.memorySize).toBe(3);

    // 'a' evicted from memory but still on disk
    cache.clearMemory();
    expect(await cache.get('a')).toBe('1'); // restored from disk
  });

  it('get refreshes LRU position in memory', async () => {
    const cache = new FileCache('lru2-ns', { dir: tmpDir, maxMemoryEntries: 3 });

    await cache.set('a', '1');
    await cache.set('b', '2');
    await cache.set('c', '3');

    // Access 'a' to refresh it
    await cache.get('a');

    // Adding 'd' should evict 'b' (now oldest), not 'a'
    await cache.set('d', '4');

    // 'a' should still be in memory
    cache.clearMemory();
    // All values still on disk regardless
    expect(await cache.get('b')).toBe('2');
  });

  it('clearMemory only clears L1, not disk', async () => {
    const cache = new FileCache('clear-ns', { dir: tmpDir });
    await cache.set('k', 'val');
    expect(cache.memorySize).toBe(1);

    cache.clearMemory();
    expect(cache.memorySize).toBe(0);

    // Still on disk
    expect(await cache.get('k')).toBe('val');
    expect(cache.memorySize).toBe(1); // re-loaded into L1
  });

  // -- Static: stats --

  it('stats returns empty for non-existent dir', () => {
    const stats = FileCache.stats(path.join(tmpDir, 'nonexistent'));
    expect(stats.totalEntries).toBe(0);
    expect(stats.totalSize).toBe(0);
    expect(stats.namespaces).toHaveLength(0);
  });

  it('stats reports per-namespace breakdown', async () => {
    const c1 = new FileCache('ns-one', { dir: tmpDir });
    const c2 = new FileCache('ns-two', { dir: tmpDir });

    await c1.set('a', 'hello');
    await c1.set('b', 'world');
    await c2.set('x', 'data');

    const stats = FileCache.stats(tmpDir);
    expect(stats.totalEntries).toBe(3);
    expect(stats.namespaces).toHaveLength(2);

    const one = stats.namespaces.find((ns) => ns.name === 'ns-one');
    const two = stats.namespaces.find((ns) => ns.name === 'ns-two');
    expect(one?.entries).toBe(2);
    expect(two?.entries).toBe(1);
    expect(stats.totalSize).toBeGreaterThan(0);
  });

  // -- Static: clear --

  it('clear removes all entries', async () => {
    const c = new FileCache('clear-all', { dir: tmpDir });
    await c.set('a', '1');
    await c.set('b', '2');

    const result = FileCache.clear({ rootDir: tmpDir });
    expect(result.removed).toBe(2);
    expect(result.freedBytes).toBeGreaterThan(0);

    const stats = FileCache.stats(tmpDir);
    expect(stats.totalEntries).toBe(0);
  });

  it('clear with namespace only removes that namespace', async () => {
    const c1 = new FileCache('keep-me', { dir: tmpDir });
    const c2 = new FileCache('delete-me', { dir: tmpDir });

    await c1.set('a', '1');
    await c2.set('b', '2');

    const result = FileCache.clear({ rootDir: tmpDir, namespace: 'delete-me' });
    expect(result.removed).toBe(1);

    const stats = FileCache.stats(tmpDir);
    expect(stats.totalEntries).toBe(1);
    const withEntries = stats.namespaces.filter((ns) => ns.entries > 0);
    expect(withEntries).toHaveLength(1);
    expect(withEntries[0].name).toBe('keep-me');
  });

  // -- Static: cleanup (TTL + size limit) --

  it('cleanup evicts entries exceeding maxSizeBytes', async () => {
    // Create entries that exceed a 50-byte limit
    const c = new FileCache('big-ns', { dir: tmpDir });
    await c.set('a', 'x'.repeat(30));
    await c.set('b', 'y'.repeat(30));
    await c.set('c', 'z'.repeat(30));

    const before = FileCache.stats(tmpDir);
    expect(before.totalEntries).toBe(3);

    // Cleanup with 50-byte limit (well below 90 bytes of content)
    const result = FileCache.cleanup(tmpDir, 50, 365 * 24 * 60 * 60 * 1000);
    expect(result.removed).toBeGreaterThan(0);

    const after = FileCache.stats(tmpDir);
    expect(after.totalSize).toBeLessThanOrEqual(50);
  });

  // -- Keys with special characters --

  it('handles keys with colons and special chars', async () => {
    const cache = new FileCache('special-ns', { dir: tmpDir });
    await cache.set('summary:abc123:200', 'summarized content');
    expect(await cache.get('summary:abc123:200')).toBe('summarized content');
  });

  it('handles very long keys', async () => {
    const cache = new FileCache('long-ns', { dir: tmpDir });
    const longKey = 'a'.repeat(500);
    await cache.set(longKey, 'value');
    expect(await cache.get(longKey)).toBe('value');
  });
});

// -- parseMaxSize --

describe('parseMaxSize', () => {
  it('passes through numbers directly', () => {
    expect(parseMaxSize(1024)).toBe(1024);
    expect(parseMaxSize(0)).toBe(0);
  });

  it('parses byte units', () => {
    expect(parseMaxSize('100B')).toBe(100);
    expect(parseMaxSize('1KB')).toBe(1024);
    expect(parseMaxSize('256MB')).toBe(256 * 1024 * 1024);
    expect(parseMaxSize('1GB')).toBe(1024 * 1024 * 1024);
    expect(parseMaxSize('2TB')).toBe(2 * 1024 * 1024 * 1024 * 1024);
  });

  it('handles fractional values', () => {
    expect(parseMaxSize('1.5GB')).toBe(Math.floor(1.5 * 1024 * 1024 * 1024));
    expect(parseMaxSize('0.5MB')).toBe(Math.floor(0.5 * 1024 * 1024));
  });

  it('is case-insensitive', () => {
    expect(parseMaxSize('256mb')).toBe(256 * 1024 * 1024);
    expect(parseMaxSize('1gb')).toBe(1024 * 1024 * 1024);
    expect(parseMaxSize('1Gb')).toBe(1024 * 1024 * 1024);
  });

  it('trims whitespace', () => {
    expect(parseMaxSize('  256MB  ')).toBe(256 * 1024 * 1024);
    expect(parseMaxSize(' 1 GB ')).toBe(1024 * 1024 * 1024);
  });

  it('parses plain number strings', () => {
    expect(parseMaxSize('1048576')).toBe(1048576);
  });

  it('parses percentage (resolves against filesystem)', () => {
    // We can't predict the exact value, but it should be a positive number
    const result = parseMaxSize('10%', '/tmp');
    expect(result).toBeGreaterThan(0);
    expect(typeof result).toBe('number');
  });

  it('percentage of 100% equals full partition', () => {
    const full = parseMaxSize('100%', '/tmp');
    const half = parseMaxSize('50%', '/tmp');
    // 50% should be roughly half of 100% (within rounding)
    expect(Math.abs(half - full / 2)).toBeLessThan(1024);
  });

  it('throws on invalid specs', () => {
    expect(() => parseMaxSize('abc')).toThrow();
    expect(() => parseMaxSize('')).toThrow();
    expect(() => parseMaxSize('0%')).toThrow();
    expect(() => parseMaxSize('101%')).toThrow();
    expect(() => parseMaxSize('-5MB')).toThrow();
  });
});

// -- Namespace isolation for LLM provider/model/proxymodel combos --

describe('FileCache namespace isolation', () => {
  let tmpDir: string;

  beforeEach(() => {
    tmpDir = makeTmpDir();
  });
  afterEach(() => {
    rmrf(tmpDir);
  });

  const combos = [
    { provider: 'openai', model: 'gpt-4o', proxyModel: 'content-pipeline' },
    { provider: 'openai', model: 'gpt-4o-mini', proxyModel: 'content-pipeline' },
    { provider: 'anthropic', model: 'claude-sonnet-4-20250514', proxyModel: 'content-pipeline' },
    { provider: 'openai', model: 'gpt-4o', proxyModel: 'default' },
    { provider: 'vllm', model: 'qwen-72b', proxyModel: 'content-pipeline' },
  ];

  it('each provider--model--proxymodel combo gets its own cache', async () => {
    const caches = combos.map(
      (c) => new FileCache(`${c.provider}--${c.model}--${c.proxyModel}`, { dir: tmpDir }),
    );

    // Write same key with different values to each cache
    for (let i = 0; i < caches.length; i++) {
      await caches[i].set('summary-key', `value-from-combo-${i}`);
    }

    // Each reads back its own value
    for (let i = 0; i < caches.length; i++) {
      expect(await caches[i].get('summary-key')).toBe(`value-from-combo-${i}`);
    }

    // Verify stats show correct number of namespaces
    const stats = FileCache.stats(tmpDir);
    expect(stats.namespaces).toHaveLength(combos.length);
    expect(stats.totalEntries).toBe(combos.length);
  });

  it('changing only the model creates a separate cache', async () => {
    const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
    const c2 = new FileCache('openai--gpt-4o-mini--content-pipeline', { dir: tmpDir });

    await c1.set('k', 'gpt4o-result');
    await c2.set('k', 'mini-result');

    expect(await c1.get('k')).toBe('gpt4o-result');
    expect(await c2.get('k')).toBe('mini-result');
  });

  it('changing only the provider creates a separate cache', async () => {
    const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
    const c2 = new FileCache('anthropic--gpt-4o--content-pipeline', { dir: tmpDir });

    await c1.set('k', 'openai-result');
    await c2.set('k', 'anthropic-result');

    expect(await c1.get('k')).toBe('openai-result');
    expect(await c2.get('k')).toBe('anthropic-result');
  });

  it('changing only the proxyModel creates a separate cache', async () => {
    const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
    const c2 = new FileCache('openai--gpt-4o--default', { dir: tmpDir });

    await c1.set('k', 'pipeline-result');
    await c2.set('k', 'default-result');

    expect(await c1.get('k')).toBe('pipeline-result');
    expect(await c2.get('k')).toBe('default-result');
  });

  it('clearing one namespace leaves others intact', async () => {
    const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
    const c2 = new FileCache('anthropic--claude-sonnet-4-20250514--content-pipeline', { dir: tmpDir });

    await c1.set('k', 'v1');
    await c2.set('k', 'v2');

    FileCache.clear({ rootDir: tmpDir, namespace: 'openai--gpt-4o--content-pipeline' });

    // c1's namespace cleared, c2 intact
    const fresh1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
    const fresh2 = new FileCache('anthropic--claude-sonnet-4-20250514--content-pipeline', { dir: tmpDir });

    expect(await fresh1.get('k')).toBeNull();
    expect(await fresh2.get('k')).toBe('v2');
  });
});
