import {
  lstatSync,
  mkdirSync,
  mkdtempSync,
  readFileSync,
  rmSync,
  symlinkSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  applyFleetLaunchComposition,
  deepMergeSettings,
  FleetLaunchError,
  formatFleetLaunchDryRun,
  parseFleetAgentProfile,
  registerFleetLaunchCommand,
  resolveFleetLaunchComposition,
} from './fleet-launch-command.js';

const roots: string[] = [];

afterEach(() => {
  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claude' }): {
  root: string;
  systemHome: string;
  userHome: string;
  agentDir: string;
  namedBundleDir: string;
} {
  const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
  roots.push(root);
  const systemHome = join(root, 'system');
  const userHome = join(root, 'user');
  const agentDir = join(userHome, 'fleet', 'agents', 'fred');
  const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
  mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
  mkdirSync(agentDir, { recursive: true });
  mkdirSync(namedBundleDir, { recursive: true });
  writeFileSync(join(systemHome, 'runtime', 'claude', 'settings.json'), '{}\n');
  writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
  writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
  writeFileSync(
    join(namedBundleDir, 'account.json'),
    '{"oauthAccount":{"emailAddress":"fred@example.com"}}\n',
  );
  symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
  return { root, systemHome, userHome, agentDir, namedBundleDir };
}

describe('fleet launch profile schema 1', () => {
  it('rejects an unknown key and names it', () => {
    expect(() =>
      parseFleetAgentProfile('{"schema":1,"harness":"claude","pluigns":[]}'),
    ).toThrowError(/unknown profile key "pluigns"/);
  });

  it('uses a dedicated SCHEMA_TOO_NEW error with an upgrade hint', () => {
    try {
      parseFleetAgentProfile('{"schema":2,"harness":"claude"}');
      throw new Error('expected parse to fail');
    } catch (error) {
      expect(error).toBeInstanceOf(FleetLaunchError);
      expect((error as FleetLaunchError).code).toBe('SCHEMA_TOO_NEW');
      expect((error as Error).message).toMatch(/upgrade Mosaic/i);
    }
  });
});

describe('three-layer settings merge', () => {
  it('keeps base-only settings', () => {
    expect(deepMergeSettings({ base: { enabled: true } })).toEqual({ base: { enabled: true } });
  });

  it('uses the last layer for scalar conflicts', () => {
    expect(deepMergeSettings({ model: 'base' }, { model: 'user' })).toEqual({ model: 'user' });
  });

  it('replaces arrays instead of appending', () => {
    expect(deepMergeSettings({ hooks: ['base'] }, { hooks: ['user'] })).toEqual({
      hooks: ['user'],
    });
  });

  it('uses null as a key-deleting tombstone', () => {
    expect(
      deepMergeSettings({ nested: { keep: true, remove: true } }, { nested: { remove: null } }),
    ).toEqual({ nested: { keep: true } });
  });

  it('concatenates hook event arrays so an overlay adds gating without erasing base hooks', () => {
    const qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] };
    const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] };
    const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] };
    expect(
      deepMergeSettings(
        { hooks: { Stop: [qaStop], PreToolUse: [qaPre] } },
        { hooks: { Stop: [leaseStop] } },
      ),
    ).toEqual({ hooks: { Stop: [qaStop, leaseStop], PreToolUse: [qaPre] } });
  });

  it('still deletes a whole hook event via the null tombstone', () => {
    expect(
      deepMergeSettings(
        { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'qa-stop.sh' }] }] } },
        { hooks: { Stop: null } },
      ),
    ).toEqual({ hooks: {} });
  });

  it('keeps replace semantics for arrays outside the top-level hooks object', () => {
    expect(
      deepMergeSettings(
        { plugins: ['base'], nested: { hooks: { Stop: ['base'] } } },
        { plugins: ['user'], nested: { hooks: { Stop: ['user'] } } },
      ),
    ).toEqual({ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } });
  });

  it('deep-merges all three layers in precedence order', () => {
    expect(
      deepMergeSettings(
        { nested: { system: true, shared: 'system' }, list: [1] },
        { nested: { user: true, shared: 'user' }, list: [2] },
        { nested: { agent: true, shared: 'agent' }, list: [3] },
      ),
    ).toEqual({
      nested: { system: true, user: true, agent: true, shared: 'agent' },
      list: [3],
    });
  });
});

describe('profile-selected overlay', () => {
  it('defaults to no overlay when the optional profile field is omitted', () => {
    const fx = fixture();
    writeFileSync(join(fx.agentDir, 'overlay.json'), '{"mustNotLoad":true}\n');

    const plan = resolveFleetLaunchComposition('fred', {
      systemHome: fx.systemHome,
      userHome: fx.userHome,
    });
    expect(plan.settings.merged).toEqual({});
    expect(plan.settings.layers[2]?.present).toBe(false);
  });
});

describe('unscaffolded agent names', () => {
  it('points an unscaffolded name at mosaic fleet agent new', () => {
    const fx = fixture();
    try {
      resolveFleetLaunchComposition('ghost', {
        systemHome: fx.systemHome,
        userHome: fx.userHome,
      });
      throw new Error('expected resolution to fail');
    } catch (error: unknown) {
      const launchError = error as FleetLaunchError;
      expect(launchError.code).toBe('AGENT_NOT_SCAFFOLDED');
      expect(launchError.message).toContain("no such fleet agent 'ghost'");
      expect(launchError.message).toContain('mosaic fleet agent new ghost');
    }
  });
});

describe('A3 credential validation', () => {
  it('refuses a symlinked bundle credential file', () => {
    const fx = fixture();
    rmSync(join(fx.namedBundleDir, '.credentials.json'));
    const outside = join(fx.root, 'outside-credentials.json');
    writeFileSync(outside, '{}\n');
    symlinkSync(outside, join(fx.namedBundleDir, '.credentials.json'));

    expect(() =>
      resolveFleetLaunchComposition('fred', {
        systemHome: fx.systemHome,
        userHome: fx.userHome,
      }),
    ).toThrowError(/real, non-symlink credential file/);
  });

  it('accepts a real credential file contained in the harness auth root', () => {
    const fx = fixture();
    const plan = resolveFleetLaunchComposition('fred', {
      systemHome: fx.systemHome,
      userHome: fx.userHome,
    });

    expect(plan.credential.target).toBe(join(fx.namedBundleDir, '.credentials.json'));
    expect(plan.bundle.display).toBe('primary -> fred_example.com (fred@example.com)');
  });

  it('refuses first-auth state when a real file occupies the seat link', () => {
    const fx = fixture();
    const seatHome = join(fx.agentDir, '.claude');
    mkdirSync(seatHome, { recursive: true });
    writeFileSync(join(seatHome, '.credentials.json'), '{"private":true}\n');

    expect(() =>
      resolveFleetLaunchComposition('fred', {
        systemHome: fx.systemHome,
        userHome: fx.userHome,
      }),
    ).toThrowError(/first-auth.*refusing to delete or overwrite/i);
    expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
  });
});

describe('managed plugin and skill links', () => {
  it('installs listed entries and prunes only stale managed symlinks', () => {
    const fx = fixture({
      schema: 1,
      harness: 'claude',
      plugins: ['keep'],
      skills: ['mosaic-tools'],
    });
    mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
    mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
    mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
    const pluginHome = join(fx.agentDir, '.claude', 'plugins');
    mkdirSync(pluginHome, { recursive: true });
    symlinkSync(join(fx.userHome, 'plugins', 'old'), join(pluginHome, 'old'), 'dir');

    const plan = resolveFleetLaunchComposition('fred', {
      systemHome: fx.systemHome,
      userHome: fx.userHome,
    });
    expect(plan.prune).toEqual([join(pluginHome, 'old')]);
    applyFleetLaunchComposition(plan);

    expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
    expect(lstatSync(join(pluginHome, 'keep')).isSymbolicLink()).toBe(true);
    expect(lstatSync(join(fx.agentDir, '.claude', 'skills', 'mosaic-tools')).isSymbolicLink()).toBe(
      true,
    );
  });

  it('tolerates harness metadata files in the install root and still refuses real directories', () => {
    const fx = fixture({ schema: 1, harness: 'claude', plugins: [] });
    const pluginHome = join(fx.agentDir, '.claude', 'plugins');
    mkdirSync(pluginHome, { recursive: true });
    writeFileSync(join(pluginHome, 'installed_plugins.json'), '{}\n');

    const plan = resolveFleetLaunchComposition('fred', {
      systemHome: fx.systemHome,
      userHome: fx.userHome,
    });
    expect(plan.prune).toEqual([]);
    expect(readFileSync(join(pluginHome, 'installed_plugins.json'), 'utf8')).toBe('{}\n');

    mkdirSync(join(pluginHome, 'stray-plugin'), { recursive: true });
    expect(() =>
      resolveFleetLaunchComposition('fred', {
        systemHome: fx.systemHome,
        userHome: fx.userHome,
      }),
    ).toThrowError(/real plugin directory occupies managed install root.*refusing to prune/i);
  });

  it('surfaces a real directory at a managed link path without deleting it', () => {
    const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
    mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
    const occupied = join(fx.agentDir, '.claude', 'plugins', 'keep');
    mkdirSync(occupied, { recursive: true });

    expect(() =>
      resolveFleetLaunchComposition('fred', {
        systemHome: fx.systemHome,
        userHome: fx.userHome,
      }),
    ).toThrowError(/real plugin directory.*refusing to delete/i);
    expect(lstatSync(occupied).isDirectory()).toBe(true);
  });
});

describe('fleet launch command outcomes', () => {
  it('--dry-run prints without writing or invoking the launcher', () => {
    const fx = fixture();
    const program = new Command().exitOverride();
    const fleet = program.command('fleet');
    const launcher = vi.fn();
    const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
    registerFleetLaunchCommand(fleet, () => fx.systemHome, {
      userHome: fx.userHome,
      launcher,
    });

    try {
      program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dry-run']);
      expect(stdout).toHaveBeenCalledWith(
        expect.stringContaining('mosaic fleet launch fred --dry-run'),
      );
      expect(launcher).not.toHaveBeenCalled();
      expect(() => lstatSync(join(fx.agentDir, '.claude'))).toThrow();
    } finally {
      stdout.mockRestore();
    }
  });

  it('applies the plan and invokes the existing launch seam with declared values', () => {
    const fx = fixture({
      schema: 1,
      harness: 'claude',
      model: 'opus',
      env: { SEAT_FLAG: 'yes' },
    });
    const program = new Command().exitOverride();
    const fleet = program.command('fleet');
    const launcher = vi.fn();
    registerFleetLaunchCommand(fleet, () => fx.systemHome, {
      userHome: fx.userHome,
      launcher,
    });

    program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);

    expect(launcher).toHaveBeenCalledWith(
      'claude',
      ['--model', 'opus'],
      {
        CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
        MOSAIC_AGENT_NAME: 'fred',
        SEAT_FLAG: 'yes',
      },
      { agentDir: fx.agentDir },
    );
    expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
      true,
    );
  });

  it('sets a non-zero exit code and never invokes the launcher', () => {
    const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
    const program = new Command().exitOverride();
    const fleet = program.command('fleet');
    const launcher = vi.fn();
    const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
    const priorExitCode = process.exitCode;
    process.exitCode = 0;
    registerFleetLaunchCommand(fleet, () => fx.systemHome, {
      userHome: fx.userHome,
      launcher,
    });

    try {
      program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
      expect(process.exitCode).toBe(1);
      expect(launcher).not.toHaveBeenCalled();
      expect(stderr).toHaveBeenCalledWith(expect.stringContaining('unknown profile key "unknown"'));
    } finally {
      process.exitCode = priorExitCode;
      stderr.mockRestore();
    }
  });
});

describe('dry-run composition', () => {
  it('renders a deterministic full composition and writes nothing', () => {
    const fx = fixture({
      schema: 1,
      harness: 'claude',
      bundle: 'primary',
      model: 'opus',
      overlay: 'overlay.json',
      plugins: ['code-review'],
      skills: ['mosaic-tools'],
      env: { SEAT_FLAG: 'yes' },
    });
    writeFileSync(
      join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
      '{"theme":"dark","hooks":["system"],"nested":{"system":true}}\n',
    );
    mkdirSync(join(fx.userHome, 'config', 'claude'), { recursive: true });
    writeFileSync(
      join(fx.userHome, 'config', 'claude', 'settings.json'),
      '{"hooks":["user"],"nested":{"user":true}}\n',
    );
    writeFileSync(join(fx.agentDir, 'overlay.json'), '{"theme":null,"nested":{"agent":true}}\n');
    mkdirSync(join(fx.userHome, 'plugins', 'code-review'), { recursive: true });
    mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });

    const plan = resolveFleetLaunchComposition('fred', {
      systemHome: fx.systemHome,
      userHome: fx.userHome,
    });
    const output = formatFleetLaunchDryRun(plan).replaceAll(fx.root, '<ROOT>');

    expect(output).toMatchInlineSnapshot(`
      "mosaic fleet launch fred --dry-run
      profile: <ROOT>/user/fleet/agents/fred/profile.json (schema 1)
      harness: claude
      seat-home: <ROOT>/user/fleet/agents/fred/.claude
      settings sources:
        system: <ROOT>/system/runtime/claude/settings.json
        user: <ROOT>/user/config/claude/settings.json
        agent: <ROOT>/user/fleet/agents/fred/overlay.json
        output: <ROOT>/user/fleet/agents/fred/.claude/settings.json
        snapshot: <ROOT>/user/fleet/agents/fred/settings.generated.json
      merged settings:
      {
        "hooks": [
          "user"
        ],
        "nested": {
          "agent": true,
          "system": true,
          "user": true
        }
      }
      bundle: primary -> fred_example.com (fred@example.com)
      symlinks:
        credentials: <ROOT>/user/fleet/agents/fred/.claude/.credentials.json -> <ROOT>/user/auth/claude/fred_example.com/.credentials.json
        plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
        skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
      declared env:
        CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
        MOSAIC_AGENT_NAME=fred
        SEAT_FLAG=yes
      argv: ["claude","--model","opus"]"
    `);
    expect(() => readFileSync(join(fx.agentDir, '.claude', 'settings.json'), 'utf8')).toThrow();

    applyFleetLaunchComposition(plan);
    expect(JSON.parse(readFileSync(plan.settings.output, 'utf8'))).toEqual({
      hooks: ['user'],
      nested: { agent: true, system: true, user: true },
    });
    expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
      readFileSync(plan.settings.output, 'utf8'),
    );
    expect(lstatSync(plan.credential.link).isSymbolicLink()).toBe(true);
  });
});
