import { describe, expect, it } from 'vitest';
import { ToolResultCache } from './tool-result-cache.js';
import {
  buildReadCacheKey,
  buildGrepCacheKey,
  buildGlobCacheKey,
  buildWebFetchCacheKey,
  buildOfficeCacheKey,
} from './cache-key.js';
import { extractInvalidationTrigger } from './invalidation.js';
import type { ToolCall } from '../../llm/openai-compat.js';

function entry(overrides: Partial<Parameters<ToolResultCache['set']>[0]> = {}): Parameters<ToolResultCache['set']>[0] {
  return {
    key: 'k1',
    toolName: 'Read',
    resultText: 'hello',
    createdAt: '2026-05-01T00:00:00.000Z',
    sourceMovement: 'investigate',
    touchedPaths: ['foo.ts'],
    volatility: 'file',
    ...overrides,
  };
}

function toolCall(name: string, args: Record<string, unknown>): ToolCall {
  return { id: 'tc-1', type: 'function', function: { name, arguments: JSON.stringify(args) } };
}

describe('buildReadCacheKey', () => {
  it('produces a deterministic v1-prefixed key for identical args', () => {
    const a = buildReadCacheKey({ workspacePath: '/ws', filePath: 'foo.ts' });
    const b = buildReadCacheKey({ workspacePath: '/ws', filePath: 'foo.ts' });
    expect(a).toBe(b);
    expect(a.startsWith('read:v1:')).toBe(true);
  });

  it('treats different workspaces as different keys', () => {
    const a = buildReadCacheKey({ workspacePath: '/wsA', filePath: 'foo.ts' });
    const b = buildReadCacheKey({ workspacePath: '/wsB', filePath: 'foo.ts' });
    expect(a).not.toBe(b);
  });

  it('treats different file paths as different keys', () => {
    const a = buildReadCacheKey({ workspacePath: '/ws', filePath: 'foo.ts' });
    const b = buildReadCacheKey({ workspacePath: '/ws', filePath: 'bar.ts' });
    expect(a).not.toBe(b);
  });

  it('treats different offset/limit ranges as different keys', () => {
    const all = buildReadCacheKey({ workspacePath: '/ws', filePath: 'foo.ts' });
    const ranged = buildReadCacheKey({ workspacePath: '/ws', filePath: 'foo.ts', offset: 0, limit: 100 });
    expect(all).not.toBe(ranged);
  });

  it('treats byte ranges separately from line ranges', () => {
    const lines = buildReadCacheKey({ workspacePath: '/ws', filePath: 'foo.bin', offset: 0, limit: 10 });
    const bytes = buildReadCacheKey({ workspacePath: '/ws', filePath: 'foo.bin', byteOffset: 0, byteLength: 10 });
    expect(lines).not.toBe(bytes);
  });
});

describe('buildGrepCacheKey', () => {
  it('is deterministic for identical args', () => {
    const a = buildGrepCacheKey({ workspacePath: '/ws', pattern: 'foo' });
    const b = buildGrepCacheKey({ workspacePath: '/ws', pattern: 'foo' });
    expect(a).toBe(b);
    expect(a.startsWith('grep:v1:')).toBe(true);
  });
  it('varies by pattern, path, and glob independently', () => {
    const base = buildGrepCacheKey({ workspacePath: '/ws', pattern: 'foo' });
    expect(base).not.toBe(buildGrepCacheKey({ workspacePath: '/ws', pattern: 'bar' }));
    expect(base).not.toBe(buildGrepCacheKey({ workspacePath: '/ws', pattern: 'foo', path: 'src/' }));
    expect(base).not.toBe(buildGrepCacheKey({ workspacePath: '/ws', pattern: 'foo', glob: '*.ts' }));
  });
});

describe('buildGlobCacheKey', () => {
  it('is deterministic and distinguishes pattern/path', () => {
    const a = buildGlobCacheKey({ workspacePath: '/ws', pattern: '**/*.ts' });
    const b = buildGlobCacheKey({ workspacePath: '/ws', pattern: '**/*.ts' });
    expect(a).toBe(b);
    expect(a.startsWith('glob:v1:')).toBe(true);
    expect(a).not.toBe(buildGlobCacheKey({ workspacePath: '/ws', pattern: '**/*.ts', path: 'src/' }));
  });
});

describe('buildWebFetchCacheKey', () => {
  it('normalizes scheme/host case and strips fragments', () => {
    const a = buildWebFetchCacheKey({ url: 'HTTPS://Example.COM/foo?x=1#hash' });
    const b = buildWebFetchCacheKey({ url: 'https://example.com/foo?x=1' });
    expect(a).toBe(b);
    expect(a.startsWith('webfetch:v1:')).toBe(true);
  });
  it('treats different paths as different keys', () => {
    expect(buildWebFetchCacheKey({ url: 'https://x.com/a' })).not.toBe(buildWebFetchCacheKey({ url: 'https://x.com/b' }));
  });
  it('falls back to raw string for malformed URLs', () => {
    const key = buildWebFetchCacheKey({ url: 'not a url' });
    expect(key).toContain('not a url');
  });
});

describe('buildOfficeCacheKey', () => {
  it('separates by tool name', () => {
    const pdf = buildOfficeCacheKey({ workspacePath: '/ws', toolName: 'ReadPdf', filePath: 'a.pdf' });
    const xls = buildOfficeCacheKey({ workspacePath: '/ws', toolName: 'ReadExcel', filePath: 'a.pdf' });
    expect(pdf).not.toBe(xls);
    expect(pdf.startsWith('office:v1:')).toBe(true);
  });
  it('separates by file path and range', () => {
    const a = buildOfficeCacheKey({ workspacePath: '/ws', toolName: 'ReadPdf', filePath: 'a.pdf' });
    const b = buildOfficeCacheKey({ workspacePath: '/ws', toolName: 'ReadPdf', filePath: 'a.pdf', range: 'page=1-5' });
    expect(a).not.toBe(b);
  });
});

describe('ToolResultCache', () => {
  it('returns undefined for an unknown key', () => {
    const cache = new ToolResultCache();
    expect(cache.get('nope')).toBeUndefined();
    expect(cache.has('nope')).toBe(false);
    expect(cache.size()).toBe(0);
  });

  it('stores and retrieves an entry', () => {
    const cache = new ToolResultCache();
    cache.set(entry());
    expect(cache.has('k1')).toBe(true);
    expect(cache.get('k1')?.resultText).toBe('hello');
    expect(cache.size()).toBe(1);
  });

  it('keeps the first entry on duplicate key (preserves original sourceMovement)', () => {
    const cache = new ToolResultCache();
    cache.set(entry({ resultText: 'first', sourceMovement: 'investigate' }));
    cache.set(entry({ resultText: 'second', sourceMovement: 'plan', createdAt: '2026-05-01T01:00:00.000Z' }));
    expect(cache.get('k1')?.resultText).toBe('first');
    expect(cache.get('k1')?.sourceMovement).toBe('investigate');
  });

  it('formatHit prefixes the original result with cache header', () => {
    const formatted = ToolResultCache.formatHit(
      entry({ resultText: 'const answer = 42;\n' }),
      'Read foo.ts',
    );
    expect(formatted.startsWith('[cached: Read foo.ts from movement investigate at 2026-05-01T00:00:00.000Z]\n')).toBe(true);
    expect(formatted.endsWith('const answer = 42;\n')).toBe(true);
  });
});

describe('ToolResultCache.invalidatePath', () => {
  it('removes file entries that touched the path and leaves others alone', () => {
    const cache = new ToolResultCache();
    cache.set(entry({ key: 'foo:0', touchedPaths: ['foo.ts'], volatility: 'file' }));
    cache.set(entry({ key: 'foo:50', touchedPaths: ['foo.ts'], volatility: 'file' }));
    cache.set(entry({ key: 'bar:0', touchedPaths: ['bar.ts'], volatility: 'file' }));

    const evicted = cache.invalidatePath('foo.ts');
    expect(evicted).toBe(2);
    expect(cache.has('foo:0')).toBe(false);
    expect(cache.has('foo:50')).toBe(false);
    expect(cache.has('bar:0')).toBe(true);
    expect(cache.size()).toBe(1);
  });

  it('returns 0 when no file entry touched the path', () => {
    const cache = new ToolResultCache();
    cache.set(entry({ key: 'bar:0', touchedPaths: ['bar.ts'], volatility: 'file' }));
    expect(cache.invalidatePath('foo.ts')).toBe(0);
    expect(cache.size()).toBe(1);
  });

  it('evicts ALL search entries unconditionally (Phase 4 conservative rule)', () => {
    const cache = new ToolResultCache();
    cache.set(entry({ key: 'grep:src', touchedPaths: ['src/'], volatility: 'search' }));
    cache.set(entry({ key: 'grep:tests', touchedPaths: ['tests/'], volatility: 'search' }));
    cache.set(entry({ key: 'read:foo', touchedPaths: ['foo.ts'], volatility: 'file' }));

    const evicted = cache.invalidatePath('unrelated.ts');
    // search entries dropped even though 'unrelated.ts' is in neither scope
    expect(evicted).toBe(2);
    expect(cache.has('grep:src')).toBe(false);
    expect(cache.has('grep:tests')).toBe(false);
    expect(cache.has('read:foo')).toBe(true);
  });

  it('keeps url entries on path invalidation', () => {
    const cache = new ToolResultCache();
    cache.set(entry({ key: 'web', touchedPaths: [], volatility: 'url' }));
    cache.set(entry({ key: 'read', touchedPaths: ['foo.ts'], volatility: 'file' }));

    const evicted = cache.invalidatePath('foo.ts');
    expect(evicted).toBe(1);
    expect(cache.has('web')).toBe(true);
  });
});

describe('ToolResultCache.invalidateAllFiles', () => {
  it('drops file and search entries; spares url entries', () => {
    const cache = new ToolResultCache();
    cache.set(entry({ key: 'foo', touchedPaths: ['foo.ts'], volatility: 'file' }));
    cache.set(entry({ key: 'grep', touchedPaths: ['src/'], volatility: 'search' }));
    cache.set(entry({ key: 'web', touchedPaths: [], volatility: 'url' }));

    const evicted = cache.invalidateAllFiles();
    expect(evicted).toBe(2);
    expect(cache.has('foo')).toBe(false);
    expect(cache.has('grep')).toBe(false);
    expect(cache.has('web')).toBe(true);
  });
});

describe('extractInvalidationTrigger', () => {
  it('returns null for read-only tools', () => {
    expect(extractInvalidationTrigger(toolCall('Read', { file_path: 'foo.ts' }))).toBeNull();
    expect(extractInvalidationTrigger(toolCall('Grep', { pattern: 'x' }))).toBeNull();
    expect(extractInvalidationTrigger(toolCall('Glob', { pattern: '*' }))).toBeNull();
  });

  it('returns path trigger for Edit', () => {
    const trig = extractInvalidationTrigger(toolCall('Edit', { file_path: 'foo.ts', old_string: 'a', new_string: 'b' }));
    expect(trig).toEqual({ kind: 'path', path: 'foo.ts' });
  });

  it('returns path trigger for Write', () => {
    const trig = extractInvalidationTrigger(toolCall('Write', { file_path: 'out.txt', content: 'hi' }));
    expect(trig).toEqual({ kind: 'path', path: 'out.txt' });
  });

  it('returns all_files for Edit/Write with malformed args (conservative)', () => {
    const bad: ToolCall = { id: 'x', type: 'function', function: { name: 'Edit', arguments: '{not json' } };
    expect(extractInvalidationTrigger(bad)).toEqual({ kind: 'all_files' });
  });

  it('returns all_files for Edit/Write missing file_path', () => {
    expect(extractInvalidationTrigger(toolCall('Write', { content: 'no path' }))).toEqual({ kind: 'all_files' });
  });

  it('returns all_files for Bash regardless of command', () => {
    expect(extractInvalidationTrigger(toolCall('Bash', { command: 'echo hi' }))).toEqual({ kind: 'all_files' });
    expect(extractInvalidationTrigger(toolCall('Bash', { command: 'ls' }))).toEqual({ kind: 'all_files' });
  });
});
