import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
  existsSync,
  lstatSync,
  mkdirSync,
  mkdtempSync,
  readlinkSync,
  readFileSync,
  rmSync,
  symlinkSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  listSkills,
  registerSkill,
  registerSkillCommand,
  syncClaudeSkills,
  unregisterSkill,
  type SkillPaths,
} from './skill.js';

const LEGACY_SYNC_SCRIPT = fileURLToPath(
  new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
);
const CONTEXT_REFRESH_SOURCE_SKILL = fileURLToPath(
  new URL('../../framework/skills/mosaic-context-refresh/SKILL.md', import.meta.url),
);

describe('Claude skill bridge', () => {
  let root: string;
  let paths: SkillPaths;

  beforeEach(() => {
    root = mkdtempSync(join(tmpdir(), 'mosaic-skill-cli-'));
    paths = {
      mosaicSkillsDir: join(root, '.config', 'mosaic', 'skills'),
      claudeSkillsDir: join(root, '.claude', 'skills'),
    };
    mkdirSync(paths.mosaicSkillsDir, { recursive: true });
  });

  afterEach(() => {
    rmSync(root, { recursive: true, force: true });
  });

  function createSkill(name: string): string {
    const skillDir = join(paths.mosaicSkillsDir, name);
    mkdirSync(skillDir, { recursive: true });
    writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n`);
    return skillDir;
  }

  function expectCorrectLink(name: string): void {
    const linkPath = join(paths.claudeSkillsDir, name);
    expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
    expect(readlinkSync(linkPath)).toBe(join(paths.mosaicSkillsDir, name));
  }

  describe('name validation', () => {
    const invalidNames = [
      '../../etc',
      '/abs/path',
      'a/b',
      String.raw`a\b`,
      '-rf',
      '..',
      'safe.',
      'space name',
      'line\nbreak',
      'escape\u001B[31m',
    ];

    for (const name of invalidNames) {
      it(`rejects ${JSON.stringify(name)} before register can escape its roots`, () => {
        expect(() => registerSkill(name, paths)).toThrow(/invalid skill name/i);
        expect(existsSync(paths.claudeSkillsDir)).toBe(false);
      });

      it(`rejects ${JSON.stringify(name)} before unregister can escape its roots`, () => {
        expect(() => unregisterSkill(name, paths)).toThrow(/invalid skill name/i);
        expect(existsSync(paths.claudeSkillsDir)).toBe(false);
      });
    }
  });

  describe('CLI validation errors', () => {
    let previousExitCode: number | string | null | undefined;

    beforeEach(() => {
      previousExitCode = process.exitCode;
      process.exitCode = undefined;
    });

    afterEach(() => {
      process.exitCode = previousExitCode;
    });

    it.each(['register', 'unregister'])(
      'reports invalid %s names on stderr and sets a nonzero exit status',
      async (subcommand) => {
        const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
        const program = new Command().exitOverride();
        registerSkillCommand(program, paths);

        await program.parseAsync(['node', 'mosaic', 'skill', subcommand, '../../etc']);

        expect(error).toHaveBeenCalledWith(expect.stringMatching(/invalid skill name/i));
        expect(process.exitCode).toBe(1);
        expect(existsSync(paths.claudeSkillsDir)).toBe(false);
        error.mockRestore();
      },
    );
  });

  describe('CLI status output', () => {
    let previousExitCode: number | string | null | undefined;

    beforeEach(() => {
      previousExitCode = process.exitCode;
      process.exitCode = undefined;
    });

    afterEach(() => {
      process.exitCode = previousExitCode;
    });

    async function run(...args: string[]): Promise<void> {
      const program = new Command().exitOverride();
      registerSkillCommand(program, paths);
      await program.parseAsync(['node', 'mosaic', 'skill', ...args]);
    }

    it('reports register repair/idempotency and unregister idempotency statuses', async () => {
      const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
      createSkill('status-skill');

      await run('register', 'status-skill');
      await run('register', 'status-skill');
      rmSync(join(paths.claudeSkillsDir, 'status-skill'));
      symlinkSync(
        join(paths.mosaicSkillsDir, 'retired'),
        join(paths.claudeSkillsDir, 'status-skill'),
      );
      await run('register', 'status-skill');
      await run('unregister', 'status-skill');
      await run('unregister', 'status-skill');

      expect(log.mock.calls.flat()).toEqual([
        'status-skill: registered',
        'status-skill: already registered',
        'status-skill: repaired dangling registration',
        'status-skill: unregistered',
        'status-skill: already unregistered',
      ]);
      log.mockRestore();
    });

    it('reports empty and populated skill lists', async () => {
      const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);

      await run('list');
      createSkill('listed');
      await run('list');

      expect(log).toHaveBeenCalledWith('No Mosaic or Claude Code skills found.');
      expect(log).toHaveBeenCalledWith(expect.stringMatching(/^unregistered\s+listed$/));
      log.mockRestore();
    });
  });

  describe('registerSkill', () => {
    it('creates the exact canonical symlink and is idempotent', () => {
      createSkill('new-skill');

      expect(registerSkill('new-skill', paths).status).toBe('registered');
      expectCorrectLink('new-skill');

      expect(registerSkill('new-skill', paths).status).toBe('already-registered');
      expectCorrectLink('new-skill');
    });

    it('repairs a dangling Mosaic-owned symlink', () => {
      createSkill('new-skill');
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      symlinkSync(
        join(paths.mosaicSkillsDir, 'retired-skill'),
        join(paths.claudeSkillsDir, 'new-skill'),
      );

      expect(registerSkill('new-skill', paths).status).toBe('repaired');
      expectCorrectLink('new-skill');
    });

    it.each(['file', 'directory', 'symlink'] as const)(
      'refuses to clobber a foreign %s at the target',
      (kind) => {
        createSkill('protected');
        mkdirSync(paths.claudeSkillsDir, { recursive: true });
        const target = join(paths.claudeSkillsDir, 'protected');
        const foreign = join(root, 'foreign');

        if (kind === 'file') writeFileSync(target, 'keep me\n');
        if (kind === 'directory') mkdirSync(target);
        if (kind === 'symlink') {
          writeFileSync(foreign, 'keep me\n');
          symlinkSync(foreign, target);
        }

        expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
        if (kind === 'file') expect(lstatSync(target).isFile()).toBe(true);
        if (kind === 'directory') expect(lstatSync(target).isDirectory()).toBe(true);
        if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
      },
    );

    it('refuses a symlinked Claude skills ancestor instead of writing outside the bridge root', () => {
      createSkill('protected');
      const externalClaude = join(root, 'external-claude');
      mkdirSync(externalClaude);
      symlinkSync(externalClaude, join(root, '.claude'));

      expect(() => registerSkill('protected', paths)).toThrow(
        /symlink.*ancestor|ancestor.*symlink/i,
      );
      expect(existsSync(join(externalClaude, 'skills', 'protected'))).toBe(false);
    });

    it('refuses a symlinked canonical skills root instead of registering an external source', () => {
      rmSync(paths.mosaicSkillsDir, { recursive: true });
      const externalSkills = join(root, 'external-skills');
      mkdirSync(join(externalSkills, 'protected'), { recursive: true });
      symlinkSync(externalSkills, paths.mosaicSkillsDir);

      expect(() => registerSkill('protected', paths)).toThrow(
        /symlink.*ancestor|ancestor.*symlink/i,
      );
      expect(existsSync(paths.claudeSkillsDir)).toBe(false);
    });

    it('refuses a dangling foreign symlink rather than treating it as repairable', () => {
      createSkill('protected');
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      const foreignMissing = join(root, 'foreign-missing');
      const target = join(paths.claudeSkillsDir, 'protected');
      symlinkSync(foreignMissing, target);

      expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
      expect(readlinkSync(target)).toBe(foreignMissing);
    });
  });

  describe('unregisterSkill', () => {
    it('removes a Mosaic-owned symlink and is idempotent when absent', () => {
      createSkill('removable');
      registerSkill('removable', paths);

      expect(unregisterSkill('removable', paths).status).toBe('unregistered');
      expect(existsSync(join(paths.claudeSkillsDir, 'removable'))).toBe(false);

      expect(unregisterSkill('removable', paths).status).toBe('already-unregistered');
    });

    it('refuses to remove a misdirected Mosaic-root symlink', () => {
      createSkill('other');
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      const requested = join(paths.claudeSkillsDir, 'requested');
      symlinkSync(join(paths.mosaicSkillsDir, 'other'), requested);

      expect(() => unregisterSkill('requested', paths)).toThrow(/misdirected/i);
      expect(readlinkSync(requested)).toBe(join(paths.mosaicSkillsDir, 'other'));
    });

    it.each(['file', 'directory', 'symlink'] as const)('refuses to remove a foreign %s', (kind) => {
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      const target = join(paths.claudeSkillsDir, 'protected');
      const foreign = join(root, 'foreign');

      if (kind === 'file') writeFileSync(target, 'keep me\n');
      if (kind === 'directory') mkdirSync(target);
      if (kind === 'symlink') {
        writeFileSync(foreign, 'keep me\n');
        symlinkSync(foreign, target);
      }

      expect(() => unregisterSkill('protected', paths)).toThrow(/foreign|refus/i);
      expect(lstatSync(target)).toBeDefined();
      if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
    });
  });

  describe('listSkills', () => {
    it('flags registered, unregistered, Mosaic-owned dangling, and foreign entries', () => {
      createSkill('registered');
      createSkill('unregistered');
      registerSkill('registered', paths);
      symlinkSync(join(paths.mosaicSkillsDir, 'retired'), join(paths.claudeSkillsDir, 'dangling'));
      writeFileSync(join(paths.claudeSkillsDir, 'foreign-file'), 'keep me\n');
      symlinkSync(join(root, 'missing-foreign'), join(paths.claudeSkillsDir, 'foreign-link'));

      expect(listSkills(paths)).toEqual([
        expect.objectContaining({ name: 'dangling', status: 'dangling' }),
        expect.objectContaining({ name: 'foreign-file', status: 'foreign' }),
        expect.objectContaining({ name: 'foreign-link', status: 'foreign-dangling' }),
        expect.objectContaining({ name: 'registered', status: 'registered' }),
        expect.objectContaining({ name: 'unregistered', status: 'unregistered' }),
      ]);
    });
  });

  describe('install linker compatibility', () => {
    it('preserves foreign-name links into Mosaic home but outside canonical skills', () => {
      createSkill('missing');
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      const mosaicHome = join(root, '.config', 'mosaic');
      const liveForeignTarget = join(mosaicHome, 'foreign-non-skill-target');
      mkdirSync(liveForeignTarget);
      const liveForeignLink = join(paths.claudeSkillsDir, 'foreign-tool');
      const danglingForeignLink = join(paths.claudeSkillsDir, 'unresolvable-foreign');
      symlinkSync(liveForeignTarget, liveForeignLink);
      symlinkSync(join(mosaicHome, 'foreign-missing'), danglingForeignLink);

      const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
        encoding: 'utf8',
        env: { ...process.env, HOME: root, MOSAIC_HOME: mosaicHome },
      });

      expect(result.status, result.stderr).toBe(0);
      expect(readlinkSync(liveForeignLink)).toBe(liveForeignTarget);
      expect(readlinkSync(danglingForeignLink)).toBe(join(mosaicHome, 'foreign-missing'));
      // Skills now link into the mosaic-owned harness home, never the base install.
      expect(readlinkSync(join(mosaicHome, '.claude', 'skills', 'missing'))).toBe(
        join(paths.mosaicSkillsDir, 'missing'),
      );
      expect(existsSync(join(paths.claudeSkillsDir, 'missing'))).toBe(false);
    });

    it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => {
      createSkill('dangling-foreign');
      createSkill('live-foreign');
      createSkill('missing');
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      const external = join(root, 'external');
      mkdirSync(external);
      const liveLink = join(paths.claudeSkillsDir, 'live-foreign');
      const danglingLink = join(paths.claudeSkillsDir, 'dangling-foreign');
      symlinkSync(external, liveLink);
      symlinkSync(join(root, 'external-missing'), danglingLink);

      const mosaicHome = join(root, '.config', 'mosaic');
      const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
        encoding: 'utf8',
        env: { ...process.env, HOME: root, MOSAIC_HOME: mosaicHome },
      });

      expect(result.status, result.stderr).toBe(0);
      expect(readlinkSync(liveLink)).toBe(external);
      expect(readlinkSync(danglingLink)).toBe(join(root, 'external-missing'));
      // Skills now link into the mosaic-owned harness home, never the base install.
      expect(readlinkSync(join(mosaicHome, '.claude', 'skills', 'missing'))).toBe(
        join(paths.mosaicSkillsDir, 'missing'),
      );
      expect(existsSync(join(paths.claudeSkillsDir, 'missing'))).toBe(false);
    });
  });

  describe('syncClaudeSkills', () => {
    it('projects the durable context-refresh skill through the #824 bridge in a tmp root', () => {
      expect(existsSync(CONTEXT_REFRESH_SOURCE_SKILL)).toBe(true);
      const source = readFileSync(CONTEXT_REFRESH_SOURCE_SKILL, 'utf8');
      expect(source).toContain('recover-context.py');
      expect(source).toContain('middle drop is not represented as receipt-detectable');
      const skillDir = createSkill('mosaic-context-refresh');
      writeFileSync(join(skillDir, 'SKILL.md'), source);

      const result = syncClaudeSkills(paths);

      expect(result).toEqual({
        registered: ['mosaic-context-refresh'],
        repaired: [],
        unchanged: [],
        conflicts: [],
      });
      expectCorrectLink('mosaic-context-refresh');
    });

    it('generically creates every missing canonical link and repairs managed broken links', () => {
      createSkill('added-after-setup');
      createSkill('another-new-skill');
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      symlinkSync(
        join(paths.mosaicSkillsDir, 'retired'),
        join(paths.claudeSkillsDir, 'added-after-setup'),
      );

      const result = syncClaudeSkills(paths);

      expect(result).toEqual({
        registered: ['another-new-skill'],
        repaired: ['added-after-setup'],
        unchanged: [],
        conflicts: [],
      });
      expectCorrectLink('added-after-setup');
      expectCorrectLink('another-new-skill');
    });

    it('escapes an invalid filesystem-derived name in conflict output', () => {
      createSkill('line\nbreak');

      const result = syncClaudeSkills(paths);

      expect(result.registered).toEqual([]);
      expect(result.conflicts).toEqual([
        expect.objectContaining({
          name: '"line\\nbreak"',
          reason: expect.stringMatching(/invalid/i),
        }),
      ]);
      expect(existsSync(paths.claudeSkillsDir)).toBe(false);
    });

    it('continues syncing other skills without clobbering foreign entries', () => {
      createSkill('blocked');
      createSkill('link-me');
      mkdirSync(paths.claudeSkillsDir, { recursive: true });
      const blocked = join(paths.claudeSkillsDir, 'blocked');
      writeFileSync(blocked, 'keep me\n');

      const result = syncClaudeSkills(paths);

      expect(result.registered).toEqual(['link-me']);
      expect(result.conflicts).toEqual([
        expect.objectContaining({
          name: 'blocked',
          reason: expect.stringMatching(/foreign|refus/i),
        }),
      ]);
      expect(readlinkSync(join(paths.claudeSkillsDir, 'link-me'))).toBe(
        join(paths.mosaicSkillsDir, 'link-me'),
      );
      expect(lstatSync(blocked).isFile()).toBe(true);
    });
  });
});
