/**
 * Tests for the skill installer rework (IUV-02-03).
 *
 * We mock `node:child_process` to verify that:
 *   1. syncSkills passes MOSAIC_INSTALL_SKILLS with the exact selected subset
 *   2. When the script exits non-zero, the failure is surfaced to the user
 *   3. When the script is missing, a clear error is shown (not a silent no-op)
 *   4. An empty selection is a no-op (script never called)
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, readlinkSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { WizardState } from '../types.js';
import type { ConfigService } from '../config/config-service.js';

// ── spawnSync mock ─────────────────────────────────────────────────────────────

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spawnSyncMock = vi.fn<any>();

vi.mock('node:child_process', () => ({
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  spawnSync: (...args: any[]) => spawnSyncMock(...args),
}));

// ── platform stub ──────────────────────────────────────────────────────────────

vi.mock('../platform/detect.js', () => ({
  getShellProfilePath: () => null,
}));

import { finalizeStage } from './finalize.js';

// ── Helpers ────────────────────────────────────────────────────────────────────

function makeState(mosaicHome: string, selectedSkills: string[] = []): WizardState {
  return {
    mosaicHome,
    sourceDir: mosaicHome,
    mode: 'quick',
    installAction: 'fresh',
    soul: { agentName: 'TestBot', communicationStyle: 'direct' },
    user: {},
    tools: {},
    runtimes: { detected: [], mcpConfigured: false },
    selectedSkills,
  };
}

function buildPrompter() {
  return {
    intro: vi.fn(),
    outro: vi.fn(),
    note: vi.fn(),
    log: vi.fn(),
    warn: vi.fn(),
    text: vi.fn(),
    confirm: vi.fn(),
    select: vi.fn(),
    multiselect: vi.fn(),
    groupMultiselect: vi.fn(),
    spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
    separator: vi.fn(),
  };
}

function makeConfigService(): ConfigService {
  return {
    readSoul: vi.fn().mockResolvedValue({}),
    readUser: vi.fn().mockResolvedValue({}),
    readTools: vi.fn().mockResolvedValue({}),
    writeSoul: vi.fn().mockResolvedValue(undefined),
    writeUser: vi.fn().mockResolvedValue(undefined),
    writeTools: vi.fn().mockResolvedValue(undefined),
    syncFramework: vi.fn().mockResolvedValue(undefined),
    get: vi.fn(),
    set: vi.fn(),
    getSection: vi.fn(),
  } as unknown as ConfigService;
}

// ── Tests ──────────────────────────────────────────────────────────────────────

describe('finalizeStage — skill installer', () => {
  let tmp: string;
  let scriptsDir: string;
  let syncScript: string;

  beforeEach(() => {
    tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
    scriptsDir = join(tmp, 'tools', '_scripts');
    mkdirSync(scriptsDir, { recursive: true });
    syncScript = join(scriptsDir, 'mosaic-sync-skills');

    // Default: current framework layout has tools/_scripts and succeeds.
    writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
    spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
  });

  afterEach(() => {
    rmSync(tmp, { recursive: true, force: true });
    vi.clearAllMocks();
  });

  function findSkillsSyncCall() {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return (spawnSyncMock.mock.calls as any[][]).find(
      (args) =>
        Array.isArray(args[1]) &&
        (args[1] as string[]).some((a) => a.includes('mosaic-sync-skills')),
    );
  }

  it('auto-registers every canonical skill even when it was added after initial setup', async () => {
    const claudeHome = join(tmp, '.claude');
    const previousClaudeHome = process.env['CLAUDE_HOME'];
    process.env['CLAUDE_HOME'] = claudeHome;
    mkdirSync(join(tmp, 'skills', 'added-after-setup'), { recursive: true });
    mkdirSync(join(tmp, 'skills', 'another-new-skill'), { recursive: true });

    try {
      await finalizeStage(buildPrompter(), makeState(tmp, []), makeConfigService());

      expect(readlinkSync(join(claudeHome, 'skills', 'added-after-setup'))).toBe(
        join(tmp, 'skills', 'added-after-setup'),
      );
      expect(readlinkSync(join(claudeHome, 'skills', 'another-new-skill'))).toBe(
        join(tmp, 'skills', 'another-new-skill'),
      );
    } finally {
      if (previousClaudeHome === undefined) delete process.env['CLAUDE_HOME'];
      else process.env['CLAUDE_HOME'] = previousClaudeHome;
    }
  });

  it('warns and completes finalization when bridge-wide reconciliation fails', async () => {
    writeFileSync(join(tmp, 'skills'), 'invalid canonical root\n');
    const p = buildPrompter();

    await finalizeStage(p, makeState(tmp, []), makeConfigService());

    expect(p.warn).toHaveBeenCalledWith(
      expect.stringMatching(/Claude skill reconciliation skipped.*not a directory/i),
    );
    expect(p.outro).toHaveBeenCalledWith('Mosaic is ready.');
  });

  it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
    const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
    const p = buildPrompter();
    const config = makeConfigService();

    await finalizeStage(p, state, config);

    const call = findSkillsSyncCall();
    expect(call).toBeDefined();
    expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]);
    const opts = call![2] as { env?: Record<string, string> };
    expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
  });

  it('falls back to legacy bin path for pre-migration installs', async () => {
    rmSync(syncScript);
    const legacyBinDir = join(tmp, 'bin');
    mkdirSync(legacyBinDir, { recursive: true });
    const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills');
    writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });

    const state = makeState(tmp, ['brainstorming']);
    const p = buildPrompter();
    const config = makeConfigService();

    await finalizeStage(p, state, config);

    const call = findSkillsSyncCall();
    expect(call).toBeDefined();
    expect(call![1]).toEqual([legacySyncScript]);
  });

  it('skips the sync script entirely when no skills are selected', async () => {
    const state = makeState(tmp, []);
    const p = buildPrompter();
    const config = makeConfigService();

    await finalizeStage(p, state, config);

    expect(findSkillsSyncCall()).toBeUndefined();
  });

  it('warns the user when the sync script exits non-zero', async () => {
    spawnSyncMock.mockReturnValue({
      status: 1,
      stdout: '',
      stderr: 'git clone failed: connection refused',
    });

    const state = makeState(tmp, ['brainstorming']);
    const p = buildPrompter();
    const config = makeConfigService();

    await finalizeStage(p, state, config);

    expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('git clone failed'));
    expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('mosaic sync'));
  });

  it('warns the user when the sync script is missing', async () => {
    // Remove the script to simulate a missing installation
    rmSync(syncScript);

    const state = makeState(tmp, ['brainstorming']);
    const p = buildPrompter();
    const config = makeConfigService();

    await finalizeStage(p, state, config);

    // spawnSync should NOT have been called for the skills script
    expect(findSkillsSyncCall()).toBeUndefined();
    expect(p.warn).toHaveBeenCalledWith(
      expect.stringContaining('tools/_scripts/mosaic-sync-skills'),
    );
  });

  it('includes skills count in the summary when install succeeds', async () => {
    const state = makeState(tmp, ['brainstorming', 'lint']);
    const p = buildPrompter();
    const config = makeConfigService();

    await finalizeStage(p, state, config);

    const noteMock = p.note as ReturnType<typeof vi.fn>;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const summaryCall = (noteMock.mock.calls as any[][]).find(
      ([, title]) => title === 'Installation Summary',
    );
    expect(summaryCall).toBeDefined();
    expect(summaryCall![0] as string).toContain('2 installed');
  });
});
