import { describe, test, expect } from 'bun:test';
import { join, sep } from 'path';
import type { OperationContext } from '../src/core/operations.ts';
import {
  buildSkillCatalog,
  getSkillDetail,
  crossReferenceTools,
  oneLineDescription,
  resolveSkillMdPath,
} from '../src/core/skill-catalog.ts';

const FIXTURE = join(import.meta.dir, 'fixtures', 'skill-catalog', 'skills');

/** Minimal ctx stub — the pure catalog functions only read remote/auth. */
function ctx(remote: boolean, scopes?: string[]): OperationContext {
  return {
    remote,
    auth: scopes ? { token: 't', clientId: 'c', scopes } : undefined,
    config: {} as OperationContext['config'],
    engine: null as unknown as OperationContext['engine'],
    logger: { info() {}, warn() {}, error() {} },
    dryRun: false,
    sourceId: 'default',
  } as OperationContext;
}

describe('buildSkillCatalog', () => {
  test('lists real skills, excludes _conventions, derives broken', () => {
    const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
    const names = res.skills.map(s => s.name).sort();
    expect(names).toContain('brain-ops');
    expect(names).toContain('query-helper');
    expect(names).toContain('broken'); // malformed frontmatter → derived name, listed
    expect(names).not.toContain('_conventions');
    expect(res.count).toBe(res.skills.length);
    expect(res.schema_version).toBe(1);
    expect(res.skills_dir_source).toBe('config');
  });

  test('malformed-frontmatter skill is listed with empty triggers, no throw', () => {
    const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
    const broken = res.skills.find(s => s.name === 'broken')!;
    expect(broken).toBeDefined();
    expect(broken.triggers).toEqual([]);
    expect(broken.tools).toEqual([]);
    expect(broken.writes_pages).toBe(false);
  });

  test('triggers union frontmatter + RESOLVER.md, deduped', () => {
    const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
    const qh = res.skills.find(s => s.name === 'query-helper')!;
    // frontmatter trigger + the RESOLVER.md row both present
    expect(qh.triggers).toContain('find a page');
    expect(qh.triggers).toContain('where is my note');
  });

  test('D7 usable/unavailable split honors caller scope', () => {
    const read = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config')
      .skills.find(s => s.name === 'brain-ops')!;
    expect(read.usable_tools.sort()).toEqual(['query', 'search']);
    // put_page is write-scope (blocked for read), web_search isn't an op at all
    expect(read.unavailable_tools.sort()).toEqual(['put_page', 'web_search']);

    const admin = buildSkillCatalog(ctx(true, ['admin']), FIXTURE, 'config')
      .skills.find(s => s.name === 'brain-ops')!;
    expect(admin.usable_tools.sort()).toEqual(['put_page', 'query', 'search']);
    expect(admin.unavailable_tools).toEqual(['web_search']);
  });

  test('local caller (remote=false) can use any real op', () => {
    const local = buildSkillCatalog(ctx(false), FIXTURE, 'config')
      .skills.find(s => s.name === 'brain-ops')!;
    expect(local.usable_tools.sort()).toEqual(['put_page', 'query', 'search']);
    expect(local.unavailable_tools).toEqual(['web_search']); // still not an op
  });

  test('section filter narrows the result set', () => {
    const all = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
    const someSection = all.skills[0].section;
    const filtered = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config', {
      section: someSection,
    });
    expect(filtered.skills.every(s => s.section === someSection)).toBe(true);
    const nomatch = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config', {
      section: 'no-such-section-xyz',
    });
    expect(nomatch.skills).toEqual([]);
  });

  test('instructions envelope present and shaped', () => {
    const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
    expect(res.instructions.fetch_op).toBe('get_skill');
    expect(res.instructions.how_to_use.length).toBeGreaterThan(0);
    expect(res.instructions.available_brain_tools).toContain('search');
    // read scope must NOT advertise a write op as available
    expect(res.instructions.available_brain_tools).not.toContain('put_page');
  });

  test('empty dir → count 0 but instructions still present', () => {
    const empty = join(import.meta.dir, 'fixtures', 'skill-catalog', 'does-not-exist');
    const res = buildSkillCatalog(ctx(true, ['read']), empty, 'config');
    expect(res.count).toBe(0);
    expect(res.skills).toEqual([]);
    expect(res.instructions.fetch_op).toBe('get_skill');
  });
});

describe('getSkillDetail', () => {
  test('returns prose body + allowlisted frontmatter (drops writes_to/sources)', () => {
    const res = getSkillDetail(ctx(true, ['read']), FIXTURE, 'brain-ops');
    expect(res.name).toBe('brain-ops');
    expect(res.body).toContain('Brain-first lookup');
    expect(res.body).not.toContain('---'); // fence stripped
    // allowlist: name/description/triggers/tools/writes_pages/mutating only
    const fmKeys = Object.keys(res.frontmatter).sort();
    expect(fmKeys).not.toContain('writes_to');
    expect(fmKeys).not.toContain('sources');
    expect(fmKeys).not.toContain('raw');
    expect(res.frontmatter.writes_pages).toBe(true);
    // the dropped fields must not leak anywhere in the response
    const blob = JSON.stringify(res);
    expect(blob).not.toContain('/abs/path/should/be/dropped.ts');
    expect(blob).not.toContain('people/');
  });

  test('mirrors the D7 tool split + client_guidance', () => {
    const res = getSkillDetail(ctx(true, ['read']), FIXTURE, 'brain-ops');
    expect(res.usable_tools.sort()).toEqual(['query', 'search']);
    expect(res.unavailable_tools.sort()).toEqual(['put_page', 'web_search']);
    expect(res.client_guidance.mutating).toBe(true);
    expect(res.client_guidance.protocol.length).toBeGreaterThan(0);
  });
});

describe('oneLineDescription', () => {
  test('prefers frontmatter description', () => {
    expect(oneLineDescription('description: hello there', '# h\nbody')).toBe('hello there');
  });
  test('falls back to first prose line, skipping headings', () => {
    expect(oneLineDescription('', '# Title\n\nFirst real line.')).toBe('First real line.');
  });
  test('empty when nothing usable', () => {
    expect(oneLineDescription('', '# only a heading')).toBe('');
  });
});

describe('crossReferenceTools', () => {
  test('unknown tool is unavailable; read op usable for read scope', () => {
    const { usable_tools, unavailable_tools } = crossReferenceTools(
      ['search', 'put_page', 'totally_not_an_op'],
      ctx(true, ['read']),
    );
    expect(usable_tools).toEqual(['search']);
    expect(unavailable_tools.sort()).toEqual(['put_page', 'totally_not_an_op']);
  });
});

describe('resolveSkillMdPath (happy path)', () => {
  test('resolves a real skill to its SKILL.md', () => {
    const p = resolveSkillMdPath(FIXTURE, 'brain-ops');
    expect(p.endsWith(sep + join('brain-ops', 'SKILL.md'))).toBe(true);
  });
});
