import { describe, test, expect, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
  composeResolvers,
  composeManifests,
  renderResolverMarkdown,
  renderManifestJson,
  writeMountsCache,
  clearMountsCache,
  __testing,
} from '../src/core/mounts-cache.ts';
import { HOST_BRAIN_ID, type MountEntry } from '../src/core/brain-registry.ts';

const toCleanup: string[] = [];
function mktmp(prefix = 'mounts-cache-'): string {
  const dir = mkdtempSync(join(tmpdir(), prefix));
  toCleanup.push(dir);
  return dir;
}
afterEach(() => {
  while (toCleanup.length > 0) {
    const p = toCleanup.pop();
    if (!p) continue;
    try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
  }
});

/** Build a tmp skills dir with a minimal RESOLVER.md + manifest.json + SKILL.md stubs. */
function makeSkillsDir(skills: Array<{ name: string; trigger: string; section?: string }>): string {
  const dir = mktmp();
  mkdirSync(join(dir, 'skills'), { recursive: true });
  for (const s of skills) {
    mkdirSync(join(dir, 'skills', s.name), { recursive: true });
    writeFileSync(join(dir, 'skills', s.name, 'SKILL.md'), `---\nname: ${s.name}\n---\n# ${s.name}\n`);
  }
  const resolverLines = ['# RESOLVER', ''];
  const bySection = new Map<string, Array<{ name: string; trigger: string }>>();
  for (const s of skills) {
    const sec = s.section || 'Brain operations';
    const bucket = bySection.get(sec) ?? [];
    bucket.push(s);
    bySection.set(sec, bucket);
  }
  for (const [sec, bucket] of bySection.entries()) {
    resolverLines.push(`## ${sec}`, '', '| Trigger | Skill |', '|---------|-------|');
    for (const s of bucket) {
      resolverLines.push(`| ${s.trigger} | \`skills/${s.name}/SKILL.md\` |`);
    }
    resolverLines.push('');
  }
  writeFileSync(join(dir, 'skills', 'RESOLVER.md'), resolverLines.join('\n'));
  writeFileSync(
    join(dir, 'skills', 'manifest.json'),
    JSON.stringify({ skills: skills.map(s => ({ name: s.name, path: `${s.name}/SKILL.md` })) }, null, 2),
  );
  return join(dir, 'skills');
}

function makeMount(id: string, skillsDir: string, enabled = true): MountEntry {
  // skillsDir is the SKILLS dir; mount.path is the parent (repo root).
  const repoRoot = join(skillsDir, '..');
  return {
    id,
    path: repoRoot,
    engine: 'pglite',
    database_path: join(repoRoot, '.pg'),
    enabled,
  };
}

describe('composeResolvers — host only', () => {
  test('empty world: empty output', () => {
    const hostDir = mktmp();
    const result = composeResolvers(hostDir, []);
    expect(result.entries).toEqual([]);
    expect(result.shadows).toEqual([]);
    expect(result.ambiguities).toEqual([]);
  });

  test('host skills only: no namespace prefix, brainId=host', () => {
    const hostSkills = makeSkillsDir([
      { name: 'query', trigger: 'search' },
      { name: 'enrich', trigger: 'enrich a page' },
    ]);
    const result = composeResolvers(hostSkills, []);
    expect(result.entries).toHaveLength(2);
    expect(result.entries.every(e => e.brainId === HOST_BRAIN_ID)).toBe(true);
    expect(result.entries.map(e => e.qualifiedName).sort()).toEqual(['enrich', 'query']);
  });
});

describe('composeResolvers — mount skills', () => {
  test('single mount: namespace prefix applied', () => {
    const hostSkills = makeSkillsDir([]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'yc-media ingest' }]);
    const mount = makeMount('yc-media', mountSkills);
    const result = composeResolvers(hostSkills, [mount]);
    expect(result.entries).toHaveLength(1);
    expect(result.entries[0].qualifiedName).toBe('yc-media::ingest');
    expect(result.entries[0].brainId).toBe('yc-media');
    // absolutePath is join()-built, so it carries '\' on win32 — build the
    // expected tail with join() rather than a POSIX literal.
    expect(result.entries[0].absolutePath).toContain(join('/skills', 'ingest', 'SKILL.md'));
  });

  test('disabled mount is excluded', () => {
    const hostSkills = makeSkillsDir([]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
    const mount = makeMount('disabled', mountSkills, false);
    const result = composeResolvers(hostSkills, [mount]);
    expect(result.entries).toEqual([]);
  });

  test('two mounts same skill → ambiguity (host does not define it)', () => {
    const hostSkills = makeSkillsDir([]);
    const m1Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm1 ingest' }]);
    const m2Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm2 ingest' }]);
    const m1 = makeMount('alpha', m1Skills);
    const m2 = makeMount('beta', m2Skills);
    const result = composeResolvers(hostSkills, [m1, m2]);
    expect(result.ambiguities).toHaveLength(1);
    expect(result.ambiguities[0].skillName).toBe('ingest');
    expect(result.ambiguities[0].mountIds).toEqual(['alpha', 'beta']);
    // Both entries still surface (via namespace form).
    const names = result.entries.map(e => e.qualifiedName).sort();
    expect(names).toEqual(['alpha::ingest', 'beta::ingest']);
  });

  test('host + mount same name → host wins BARE name, mount reachable via namespace', () => {
    const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host ingest' }]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'mount ingest' }]);
    const mount = makeMount('yc-media', mountSkills);
    const result = composeResolvers(hostSkills, [mount]);
    // Both entries survive: host wins bare 'ingest', but 'yc-media::ingest'
    // must remain routable (the whole point of namespace-qualified form).
    expect(result.entries).toHaveLength(2);
    const host = result.entries.find(e => e.brainId === HOST_BRAIN_ID);
    const mnt = result.entries.find(e => e.brainId === 'yc-media');
    expect(host?.qualifiedName).toBe('ingest');
    expect(mnt?.qualifiedName).toBe('yc-media::ingest');
    // Shadow recorded so doctor can warn about local-customizing a remote skill
    expect(result.shadows).toHaveLength(1);
    expect(result.shadows[0].skillName).toBe('ingest');
    expect(result.shadows[0].shadowedMounts).toHaveLength(1);
    expect(result.shadows[0].shadowedMounts[0].mountId).toBe('yc-media');
    // Not flagged as ambiguity — host wins the bare name cleanly
    expect(result.ambiguities).toEqual([]);
  });

  test('host shadows two mounts → all three entries survive, shadow tracks both mounts', () => {
    const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host' }]);
    const m1Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm1' }]);
    const m2Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm2' }]);
    const result = composeResolvers(hostSkills, [makeMount('a', m1Skills), makeMount('b', m2Skills)]);
    // Host entry + both namespaced mount entries
    expect(result.entries).toHaveLength(3);
    expect(result.entries.map(e => e.qualifiedName).sort()).toEqual(['a::ingest', 'b::ingest', 'ingest']);
    // No ambiguity: host wins bare name
    expect(result.ambiguities).toEqual([]);
    expect(result.shadows).toHaveLength(1);
    expect(result.shadows[0].shadowedMounts.map(m => m.mountId).sort()).toEqual(['a', 'b']);
  });

  test('mount without RESOLVER.md contributes no entries (not an error)', () => {
    const hostSkills = makeSkillsDir([]);
    // Create a mount path with no skills/RESOLVER.md inside
    const emptyRoot = mktmp();
    const mount: MountEntry = {
      id: 'empty', path: emptyRoot, engine: 'pglite', database_path: `${emptyRoot}/.pg`, enabled: true,
    };
    const result = composeResolvers(hostSkills, [mount]);
    expect(result.entries).toEqual([]);
  });
});

describe('composeManifests', () => {
  test('host only: entries preserved without namespace', () => {
    const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
    const result = composeManifests(hostSkills, []);
    expect(result.entries).toHaveLength(1);
    expect(result.entries[0].name).toBe('query');
    expect(result.entries[0].brainId).toBe(HOST_BRAIN_ID);
  });

  test('mount entries get namespace prefix (Codex finding #8)', () => {
    const hostSkills = makeSkillsDir([]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
    const result = composeManifests(hostSkills, [makeMount('yc-media', mountSkills)]);
    expect(result.entries).toHaveLength(1);
    expect(result.entries[0].name).toBe('yc-media::ingest');
    expect(result.entries[0].brainId).toBe('yc-media');
  });

  test('manifest keeps namespace-qualified mount entry even when host shadows', () => {
    // Bare-name resolution is composeResolvers' job. The manifest lists every
    // addressable skill by its canonical name, so the namespace-qualified
    // form must survive regardless of host shadow. This matches the
    // corresponding composeResolvers shadow test.
    const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host' }]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'mount' }]);
    const result = composeManifests(hostSkills, [makeMount('yc-media', mountSkills)]);
    expect(result.entries).toHaveLength(2);
    expect(result.entries.map(e => e.name).sort()).toEqual(['ingest', 'yc-media::ingest']);
    const host = result.entries.find(e => e.name === 'ingest');
    const mnt = result.entries.find(e => e.name === 'yc-media::ingest');
    expect(host?.brainId).toBe(HOST_BRAIN_ID);
    expect(mnt?.brainId).toBe('yc-media');
  });

  test('disabled mount excluded', () => {
    const hostSkills = makeSkillsDir([]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
    const result = composeManifests(hostSkills, [makeMount('off', mountSkills, false)]);
    expect(result.entries).toEqual([]);
  });

  test('missing manifest.json in mount → mount contributes nothing (no crash)', () => {
    const hostSkills = makeSkillsDir([]);
    const emptyRoot = mktmp();
    mkdirSync(join(emptyRoot, 'skills'), { recursive: true });
    // No manifest.json written
    const mount: MountEntry = {
      id: 'bare', path: emptyRoot, engine: 'pglite', database_path: `${emptyRoot}/.pg`, enabled: true,
    };
    expect(() => composeManifests(hostSkills, [mount])).not.toThrow();
  });
});

describe('renderResolverMarkdown', () => {
  test('produces stable markdown with brain column', () => {
    const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'search' }]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'media ingest' }]);
    const composed = composeResolvers(hostSkills, [makeMount('yc-media', mountSkills)]);
    const md = renderResolverMarkdown(composed);
    expect(md).toContain('# GBrain Skill Resolver (aggregated)');
    expect(md).toContain('Auto-generated');
    expect(md).toContain('| Trigger | Skill | Brain |');
    expect(md).toContain('| search');
    expect(md).toContain('| host |');
    expect(md).toContain('| media ingest');
    expect(md).toContain('| yc-media |');
  });

  test('shadows section appears only when shadows exist', () => {
    const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
    const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
    const composed = composeResolvers(hostSkills, [makeMount('m', mountSkills)]);
    const md = renderResolverMarkdown(composed);
    expect(md).toContain('## Shadows');
    expect(md).toContain('`ingest` (host) shadows');
  });

  test('ambiguities section appears only when ambiguities exist', () => {
    const hostSkills = makeSkillsDir([]);
    const m1 = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
    const m2 = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
    const composed = composeResolvers(hostSkills, [makeMount('a', m1), makeMount('b', m2)]);
    const md = renderResolverMarkdown(composed);
    expect(md).toContain('## Ambiguous bare names');
    expect(md).toContain('`ingest`');
  });
});

describe('renderManifestJson', () => {
  test('produces parseable JSON with expected shape', () => {
    const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
    const composed = composeManifests(hostSkills, []);
    const json = renderManifestJson(composed);
    const parsed = JSON.parse(json);
    expect(parsed.generated_by).toBe('gbrain mounts');
    expect(parsed.skills).toHaveLength(1);
    expect(parsed.skills[0].name).toBe('query');
    expect(parsed.skills[0].brain).toBe(HOST_BRAIN_ID);
  });
});

describe('writeMountsCache + clearMountsCache', () => {
  test('writes both RESOLVER.md and manifest.json atomically', () => {
    const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
    const cacheDir = mktmp();
    const { resolverPath, manifestPath } = writeMountsCache(hostSkills, [], { cacheDir });
    expect(existsSync(resolverPath)).toBe(true);
    expect(existsSync(manifestPath)).toBe(true);
    const md = readFileSync(resolverPath, 'utf-8');
    expect(md).toContain('# GBrain Skill Resolver (aggregated)');
    const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
    expect(manifest.skills).toHaveLength(1);
  });

  test('rewriting overwrites cleanly', () => {
    const hostSkills1 = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
    const cacheDir = mktmp();
    writeMountsCache(hostSkills1, [], { cacheDir });
    const hostSkills2 = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
    writeMountsCache(hostSkills2, [], { cacheDir });
    const manifest = JSON.parse(readFileSync(join(cacheDir, 'manifest.json'), 'utf-8'));
    expect(manifest.skills.map((s: { name: string }) => s.name)).toEqual(['ingest']);
  });

  test('clearMountsCache removes the directory', () => {
    const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
    const cacheDir = mktmp();
    writeMountsCache(hostSkills, [], { cacheDir });
    expect(existsSync(cacheDir)).toBe(true);
    clearMountsCache({ cacheDir });
    expect(existsSync(cacheDir)).toBe(false);
  });

  test('clearMountsCache on missing dir is a no-op', () => {
    const fake = join(tmpdir(), `does-not-exist-${Date.now()}`);
    expect(() => clearMountsCache({ cacheDir: fake })).not.toThrow();
  });
});

describe('skillNameFromRelPath', () => {
  test('extracts name from skills/ prefix', () => {
    expect(__testing.skillNameFromRelPath('skills/query/SKILL.md')).toBe('query');
    expect(__testing.skillNameFromRelPath('skills/yc-media/SKILL.md')).toBe('yc-media');
  });
  test('handles name without skills/ prefix', () => {
    expect(__testing.skillNameFromRelPath('query/SKILL.md')).toBe('query');
  });
});
