import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');

let tmpDir: string;
let slugDir: string;
let learningsFile: string;

function runLog(input: string, opts: { expectFail?: boolean } = {}): { stdout: string; exitCode: number } {
  const execOpts: ExecSyncOptionsWithStringEncoding = {
    cwd: ROOT,
    env: { ...process.env, GSTACK_HOME: tmpDir },
    encoding: 'utf-8',
    timeout: 15000,
  };
  try {
    const stdout = execSync(`${BIN}/gstack-learnings-log '${input.replace(/'/g, "'\\''")}'`, execOpts).trim();
    return { stdout, exitCode: 0 };
  } catch (e: any) {
    if (opts.expectFail) {
      return { stdout: e.stderr?.toString() || '', exitCode: e.status || 1 };
    }
    throw e;
  }
}

function runSearch(args: string = ''): string {
  const execOpts: ExecSyncOptionsWithStringEncoding = {
    cwd: ROOT,
    env: { ...process.env, GSTACK_HOME: tmpDir },
    encoding: 'utf-8',
    timeout: 15000,
  };
  try {
    return execSync(`${BIN}/gstack-learnings-search ${args}`, execOpts).trim();
  } catch {
    return '';
  }
}

beforeEach(() => {
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-learn-'));
  slugDir = path.join(tmpDir, 'projects');
  fs.mkdirSync(slugDir, { recursive: true });
});

afterEach(() => {
  fs.rmSync(tmpDir, { recursive: true, force: true });
});

function findLearningsFile(): string | null {
  const projectDirs = fs.readdirSync(slugDir);
  if (projectDirs.length === 0) return null;
  const f = path.join(slugDir, projectDirs[0], 'learnings.jsonl');
  return fs.existsSync(f) ? f : null;
}

describe('gstack-learnings-log', () => {
  test('appends valid JSON to learnings.jsonl', () => {
    const input = '{"skill":"review","type":"pattern","key":"test-key","insight":"test insight","confidence":8,"source":"observed"}';
    const result = runLog(input);
    expect(result.exitCode).toBe(0);

    const f = findLearningsFile();
    expect(f).not.toBeNull();
    const content = fs.readFileSync(f!, 'utf-8').trim();
    const parsed = JSON.parse(content);
    expect(parsed.skill).toBe('review');
    expect(parsed.key).toBe('test-key');
    expect(parsed.confidence).toBe(8);
  });

  test('auto-injects timestamp when ts is missing', () => {
    const input = '{"skill":"review","type":"pattern","key":"ts-test","insight":"test","confidence":5,"source":"observed"}';
    runLog(input);

    const f = findLearningsFile();
    expect(f).not.toBeNull();
    const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
    expect(parsed.ts).toBeDefined();
    expect(new Date(parsed.ts).getTime()).toBeGreaterThan(0);
  });

  test('rejects non-JSON input with non-zero exit code', () => {
    const result = runLog('not json at all', { expectFail: true });
    expect(result.exitCode).not.toBe(0);
  });

  test('append-only: duplicate keys create multiple entries', () => {
    const input1 = '{"skill":"review","type":"pattern","key":"dup-key","insight":"first version","confidence":6,"source":"observed"}';
    const input2 = '{"skill":"review","type":"pattern","key":"dup-key","insight":"second version","confidence":8,"source":"observed"}';
    runLog(input1);
    runLog(input2);

    const f = findLearningsFile();
    expect(f).not.toBeNull();
    const lines = fs.readFileSync(f!, 'utf-8').trim().split('\n');
    expect(lines.length).toBe(2);
  });

  // Regression test for #1423: investigate skill emits type:"investigation"
  // but ALLOWED_TYPES previously rejected it. Now accepted.
  test('accepts type:"investigation" (regression: #1423)', () => {
    const input = '{"skill":"investigate","type":"investigation","key":"root-cause","insight":"verified","confidence":9,"source":"observed"}';
    const result = runLog(input);
    expect(result.exitCode).toBe(0);
    const f = findLearningsFile();
    expect(f).not.toBeNull();
    const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
    expect(parsed.type).toBe('investigation');
  });

  // Caller contract: investigate/SKILL.md.tmpl must emit type:"investigation"
  // verbatim. Guards against the template drifting to an invalid type and
  // silently breaking the log path. See codex review finding for #1423.
  test('investigate template emits type:"investigation" verbatim (caller contract)', () => {
    const tmpl = fs.readFileSync(path.join(ROOT, 'investigate/SKILL.md.tmpl'), 'utf-8');
    // The invocation line must include "type":"investigation" exactly.
    expect(tmpl).toContain('"type":"investigation"');
  });
});

describe('gstack-learnings-search', () => {
  test('returns empty and exits 0 when no learnings file exists', () => {
    const output = runSearch();
    expect(output).toBe('');
  });

  test('returns formatted output when learnings exist', () => {
    runLog('{"skill":"review","type":"pattern","key":"test-search","insight":"search test insight","confidence":7,"source":"observed"}');
    const output = runSearch();
    expect(output).toContain('LEARNINGS:');
    expect(output).toContain('test-search');
    expect(output).toContain('search test insight');
  });

  test('deduplicates entries by key+type (latest wins)', () => {
    const old = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'old version', confidence: 5, source: 'observed', ts: '2026-01-01T00:00:00Z' });
    const newer = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'new version', confidence: 8, source: 'observed', ts: '2026-03-28T00:00:00Z' });
    runLog(old);
    runLog(newer);

    const output = runSearch();
    expect(output).toContain('new version');
    expect(output).not.toContain('old version');
    expect(output).toContain('1 loaded');
  });

  test('filters by --type', () => {
    runLog('{"skill":"review","type":"pattern","key":"p1","insight":"a pattern","confidence":7,"source":"observed"}');
    runLog('{"skill":"review","type":"pitfall","key":"p2","insight":"a pitfall","confidence":7,"source":"observed"}');

    const patternOnly = runSearch('--type pattern');
    expect(patternOnly).toContain('p1');
    expect(patternOnly).not.toContain('p2');
  });

  test('filters by --query', () => {
    runLog('{"skill":"review","type":"pattern","key":"auth-bypass","insight":"check session tokens","confidence":7,"source":"observed"}');
    runLog('{"skill":"review","type":"pattern","key":"n-plus-one","insight":"use includes for associations","confidence":7,"source":"observed"}');

    const authOnly = runSearch('--query auth');
    expect(authOnly).toContain('auth-bypass');
    expect(authOnly).not.toContain('n-plus-one');
  });

  test('respects --limit', () => {
    for (let i = 0; i < 5; i++) {
      runLog(`{"skill":"review","type":"pattern","key":"limit-${i}","insight":"insight ${i}","confidence":7,"source":"observed"}`);
    }

    const limited = runSearch('--limit 2');
    // Should show 2, not 5
    expect(limited).toContain('2 loaded');
  });

  test('applies confidence decay for observed/inferred sources', () => {
    // Entry from 90 days ago with source=observed, confidence=8
    // Should decay to 8 - floor(90/30) = 8 - 3 = 5
    const ts = new Date(Date.now() - 90 * 86400000).toISOString();
    runLog(`{"skill":"review","type":"pattern","key":"decay-test","insight":"old observation","confidence":8,"source":"observed","ts":"${ts}"}`);

    const output = runSearch();
    // Should show confidence 5 (decayed from 8)
    expect(output).toContain('confidence: 5/10');
  });

  test('does NOT decay user-stated learnings', () => {
    const ts = new Date(Date.now() - 90 * 86400000).toISOString();
    runLog(`{"skill":"review","type":"preference","key":"no-decay-test","insight":"user preference","confidence":9,"source":"user-stated","ts":"${ts}"}`);

    const output = runSearch();
    // Should still show confidence 9 (no decay for user-stated)
    expect(output).toContain('confidence: 9/10');
  });

  test('skips malformed JSONL lines gracefully', () => {
    // Write a valid entry, then manually append a bad line
    runLog('{"skill":"review","type":"pattern","key":"valid-entry","insight":"valid","confidence":7,"source":"observed"}');
    const f = findLearningsFile();
    expect(f).not.toBeNull();
    fs.appendFileSync(f!, '\nthis is not json\n');
    fs.appendFileSync(f!, '{"skill":"review","type":"pattern","key":"also-valid","insight":"also valid","confidence":6,"source":"observed","ts":"2026-03-28T00:00:00Z"}\n');

    const output = runSearch();
    expect(output).toContain('valid-entry');
    expect(output).toContain('also-valid');
  });
});

describe('gstack-learnings-log edge cases', () => {
  test('preserves existing timestamp when ts is present', () => {
    const input = '{"skill":"review","type":"pattern","key":"ts-preserve","insight":"test","confidence":5,"source":"observed","ts":"2025-06-15T10:00:00Z"}';
    runLog(input);

    const f = findLearningsFile();
    expect(f).not.toBeNull();
    const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
    expect(parsed.ts).toBe('2025-06-15T10:00:00Z');
  });

  test('handles JSON with special characters in insight', () => {
    const input = JSON.stringify({ skill: 'review', type: 'pattern', key: 'special-chars', insight: 'Use "quotes" and \\backslashes', confidence: 7, source: 'observed' });
    runLog(input);

    const f = findLearningsFile();
    expect(f).not.toBeNull();
    const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
    expect(parsed.insight).toContain('quotes');
    expect(parsed.insight).toContain('backslashes');
  });

  test('handles JSON with files array field', () => {
    const input = JSON.stringify({ skill: 'review', type: 'architecture', key: 'with-files', insight: 'test', confidence: 8, source: 'observed', files: ['src/auth.ts', 'src/db.ts'] });
    runLog(input);

    const f = findLearningsFile();
    expect(f).not.toBeNull();
    const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
    expect(parsed.files).toEqual(['src/auth.ts', 'src/db.ts']);
  });
});

describe('gstack-learnings-search edge cases', () => {
  test('sorts by confidence then recency', () => {
    // Two entries: one high confidence old, one lower confidence recent
    runLog(JSON.stringify({ skill: 'review', type: 'pattern', key: 'high-conf', insight: 'high confidence entry', confidence: 9, source: 'user-stated', ts: '2026-01-01T00:00:00Z' }));
    runLog(JSON.stringify({ skill: 'review', type: 'pattern', key: 'recent', insight: 'recent entry', confidence: 5, source: 'observed', ts: '2026-03-28T00:00:00Z' }));

    const output = runSearch();
    const highIdx = output.indexOf('high-conf');
    const recentIdx = output.indexOf('recent');
    // High confidence should appear first
    expect(highIdx).toBeLessThan(recentIdx);
  });

  test('groups output by type', () => {
    runLog(JSON.stringify({ skill: 'review', type: 'pattern', key: 'p1', insight: 'a pattern', confidence: 7, source: 'observed' }));
    runLog(JSON.stringify({ skill: 'review', type: 'pitfall', key: 'pit1', insight: 'a pitfall', confidence: 7, source: 'observed' }));

    const output = runSearch();
    expect(output).toContain('## Patterns');
    expect(output).toContain('## Pitfalls');
  });

  test('combined --type and --query filtering', () => {
    runLog(JSON.stringify({ skill: 'review', type: 'pattern', key: 'auth-token', insight: 'check token expiry', confidence: 7, source: 'observed' }));
    runLog(JSON.stringify({ skill: 'review', type: 'pitfall', key: 'auth-leak', insight: 'auth token in logs', confidence: 7, source: 'observed' }));
    runLog(JSON.stringify({ skill: 'review', type: 'pattern', key: 'cache-key', insight: 'cache invalidation', confidence: 7, source: 'observed' }));

    const output = runSearch('--type pattern --query auth');
    expect(output).toContain('auth-token');
    expect(output).not.toContain('auth-leak');  // wrong type
    expect(output).not.toContain('cache-key');  // wrong query
  });

  test('entries with missing key or type are skipped', () => {
    runLog(JSON.stringify({ skill: 'review', type: 'pattern', key: 'valid', insight: 'valid entry', confidence: 7, source: 'observed' }));
    const f = findLearningsFile();
    expect(f).not.toBeNull();
    // Append entries missing key and type
    fs.appendFileSync(f!, JSON.stringify({ skill: 'review', type: 'pattern', insight: 'no key', confidence: 7, source: 'observed' }) + '\n');
    fs.appendFileSync(f!, JSON.stringify({ skill: 'review', key: 'no-type', insight: 'no type', confidence: 7, source: 'observed' }) + '\n');

    const output = runSearch();
    expect(output).toContain('valid');
    expect(output).not.toContain('no key');
    expect(output).not.toContain('no-type');
  });

  test('confidence decay floors at 0 (never negative)', () => {
    // Entry from 1 year ago with confidence 3 — decay would be 12, clamped to 0
    const ts = new Date(Date.now() - 365 * 86400000).toISOString();
    runLog(JSON.stringify({ skill: 'review', type: 'pattern', key: 'ancient', insight: 'very old', confidence: 3, source: 'observed', ts }));

    const output = runSearch();
    expect(output).toContain('confidence: 0/10');
  });
});
