import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fleetAgentMutationExitCode } from './fleet-agent-crud-command.js';
import { registerFleetCommand, type FleetCommandDeps } from './fleet.js';

const roster = `
version: 2
generation: 7
transport: tmux
tmux:
  socket_name: mosaic-fleet
  holder_session: _holder
defaults:
  working_directory: /srv/mosaic
  runtime: pi
runtimes:
  pi:
    reset_command: /new
agents:
  - name: orchestrator
    alias: Orchestrator
    class: orchestrator
    runtime: pi
    provider: openai
    model: gpt-5.6-sol
    reasoning: high
    tool_policy: orchestrator
    working_directory: /srv/mosaic
    persistent_persona: true
    reset_between_tasks: false
    lifecycle:
      enabled: true
      desired_state: stopped
    launch:
      yolo: true
`;

const agent = {
  name: 'coder0',
  alias: 'Coder 0',
  className: 'code',
  runtime: 'pi',
  provider: 'openai',
  model: 'gpt-5.6-sol',
  reasoning: 'high',
  toolPolicy: 'code',
  workingDirectory: '/srv/mosaic',
  persistentPersona: false,
  resetBetweenTasks: true,
  launch: { yolo: true },
};

let cleanup: string | undefined;

afterEach(async (): Promise<void> => {
  vi.restoreAllMocks();
  process.exitCode = undefined;
  if (cleanup) await rm(cleanup, { recursive: true, force: true });
  cleanup = undefined;
});

async function fleetHome(): Promise<string> {
  cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-command-'));
  const fleetDir = join(cleanup, 'fleet');
  const agentsDir = join(fleetDir, 'agents');
  const rolesDir = join(fleetDir, 'roles');
  await mkdir(agentsDir, { recursive: true, mode: 0o700 });
  await mkdir(rolesDir, { recursive: true, mode: 0o700 });
  await chmod(cleanup, 0o700);
  await chmod(fleetDir, 0o700);
  await chmod(agentsDir, 0o700);
  await chmod(rolesDir, 0o700);
  await writeFile(join(fleetDir, 'roster.yaml'), roster, { mode: 0o600 });
  for (const className of ['orchestrator', 'code']) {
    await writeFile(
      join(rolesDir, `${className}.md`),
      `# ${className}\n\n(\`class: ${className}\`)\n`,
      {
        mode: 0o600,
      },
    );
  }
  return cleanup;
}

function program(mosaicHome: string, deps: FleetCommandDeps = {}): Command {
  const result = new Command();
  result.exitOverride();
  registerFleetCommand(result, { ...deps, mosaicHome });
  return result;
}

function output(): { lines: string[] } {
  const lines: string[] = [];
  vi.spyOn(console, 'log').mockImplementation((value: string): void => {
    lines.push(value);
  });
  return { lines };
}

describe('mosaic fleet local roster mutations', (): void => {
  it('returns non-zero when a projection failure leaves recovery work after roster persistence', (): void => {
    expect(
      fleetAgentMutationExitCode({
        recovery: {
          code: 'projection-apply-failed',
          rosterPath: '/private/fleet/roster.yaml',
          action: 'regenerate-projections-from-roster',
        },
      }),
    ).toBe(1);
  });

  it('returns zero for a mutation result without recovery work', (): void => {
    expect(fleetAgentMutationExitCode({})).toBe(0);
  });

  it('registers programmatic local create, get, update, delete, and plan commands', async (): Promise<void> => {
    const home = await fleetHome();
    const root = program(home);
    const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
    expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
      expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
    );
  });

  it('runs the JSON mutation commands through the direct mosaic fleet control plane', async (): Promise<void> => {
    const home = await fleetHome();
    const root = new Command();
    root.exitOverride();
    registerFleetCommand(root, { mosaicHome: home });
    const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
    expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
      expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
    );
  });

  it('gets one v2 roster agent as stable JSON without mutating the roster', async (): Promise<void> => {
    const home = await fleetHome();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const captured = output();

    const root = new Command();
    root.exitOverride();
    registerFleetCommand(root, { mosaicHome: home });
    await root.parseAsync(['node', 'mosaic', 'fleet', 'get', 'orchestrator']);

    expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
      generation: 7,
      agent: { name: 'orchestrator', lifecycle: { desiredState: 'stopped' } },
    });
    expect(await readFile(rosterPath, 'utf8')).toBe(roster);
  });

  it('returns a JSON error and non-zero exit for a missing local agent', async (): Promise<void> => {
    const home = await fleetHome();
    const captured = output();

    await program(home).parseAsync(['node', 'mosaic', 'fleet', 'get', 'missing']);

    expect(JSON.parse(captured.lines.join('\n'))).toEqual({ error: { code: 'agent-not-found' } });
    expect(process.exitCode).toBe(1);
  });

  it('prints a deterministic create plan without changing roster or generated projections', async (): Promise<void> => {
    const home = await fleetHome();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const captured = output();

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'plan',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);

    expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
      applied: false,
      plan: {
        operation: 'create',
        currentGeneration: 7,
        nextGeneration: 8,
        agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
      },
    });
    expect(await readFile(rosterPath, 'utf8')).toBe(roster);
    await expect(
      readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
    ).rejects.toThrow();
  });

  it('plans create, update, and delete with operation-specific target names without mutation', async (): Promise<void> => {
    const home = await fleetHome();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const captured = output();
    const updatedCoder = { ...agent, alias: 'Coder Updated' };

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'plan',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);
    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);
    const beforePlans = await readFile(rosterPath, 'utf8');
    const beforeOrchestratorProjection = await readFile(
      join(home, 'fleet', 'agents', 'orchestrator.env.generated'),
      'utf8',
    );
    captured.lines.length = 0;

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'plan',
      'update',
      'coder0',
      '--expected-generation',
      '8',
      '--agent',
      JSON.stringify(updatedCoder),
    ]);
    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'plan',
      'delete',
      'coder0',
      '--expected-generation',
      '8',
    ]);

    const plans = captured.lines.map((line: string): unknown => JSON.parse(line));
    expect(plans).toMatchObject([
      { plan: { operation: 'update', agent: { name: 'coder0' } } },
      { plan: { operation: 'delete', agent: { name: 'coder0' } } },
    ]);
    expect(await readFile(rosterPath, 'utf8')).toBe(beforePlans);
    expect(
      await readFile(join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8'),
    ).toBe(beforeOrchestratorProjection);
  });

  it.each([
    ['plan create', ['fleet', 'plan', 'create'], 'top-level'],
    ['create', ['fleet', 'create'], 'top-level'],
    ['update', ['fleet', 'update', 'orchestrator'], 'top-level'],
    ['plan create', ['fleet', 'plan', 'create'], 'launch'],
    ['create', ['fleet', 'create'], 'launch'],
    ['update', ['fleet', 'update', 'orchestrator'], 'launch'],
  ])(
    'rejects unknown and prototype-sensitive agent fields without mutation',
    async (_operation: string, command: string[], shape: string): Promise<void> => {
      const home = await fleetHome();
      const rosterPath = join(home, 'fleet', 'roster.yaml');
      const agentsDir = join(home, 'fleet', 'agents');
      const captured = output();
      const artifactContents = new Map([
        ['orchestrator.env.generated', 'generated-before\n'],
        ['orchestrator.env.local', 'local-before\n'],
        ['orchestrator.env', 'legacy-before\n'],
        ['orchestrator.env.quarantine', 'quarantine-before\n'],
      ]);
      for (const [filename, content] of artifactContents) {
        await writeFile(join(agentsDir, filename), content, { mode: 0o600 });
      }
      const rejectedValue = 'must-not-appear-in-diagnostics';
      const unsafeAgent = {
        ...agent,
        name: 'orchestrator',
        launch: shape === 'launch' ? { yolo: true, command: rejectedValue } : { yolo: true },
        ...(shape === 'top-level'
          ? {
              command: rejectedValue,
              channel: rejectedValue,
              secretRef: rejectedValue,
              constructor: rejectedValue,
              prototype: rejectedValue,
            }
          : {}),
      };
      const payload =
        shape === 'top-level'
          ? JSON.stringify(unsafeAgent).replace(/}$/, ',"__proto__":{"unsupported":true}}')
          : JSON.stringify(unsafeAgent);

      await program(home).parseAsync([
        'node',
        'mosaic',
        ...command,
        '--expected-generation',
        '7',
        '--agent',
        payload,
      ]);

      const diagnostic = captured.lines.pop() ?? '';
      expect(JSON.parse(diagnostic)).toEqual({ error: { code: 'invalid-request' } });
      expect(diagnostic).not.toContain(rejectedValue);
      expect(process.exitCode).toBe(1);
      expect(await readFile(rosterPath, 'utf8')).toBe(roster);
      for (const [filename, content] of artifactContents) {
        expect(await readFile(join(agentsDir, filename), 'utf8')).toBe(content);
      }
    },
  );

  it('rejects missing update/delete plan names and an extra create plan name', async (): Promise<void> => {
    const home = await fleetHome();
    const captured = output();

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'plan',
      'delete',
      '--expected-generation',
      '7',
    ]);
    expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'plan',
      'create',
      'coder0',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);
    expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
  });

  it('plans persisted start as desired state without starting a runtime', async (): Promise<void> => {
    const home = await fleetHome();
    const captured = output();

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'plan',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
      '--persisted-start',
    ]);

    expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
      applied: false,
      plan: { agent: { lifecycle: { desiredState: 'running' } } },
    });
  });

  it('returns non-zero JSON when a filesystem projection fails after roster persistence', async (): Promise<void> => {
    const home = await fleetHome();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const agentsDir = join(home, 'fleet', 'agents');
    const captured = output();

    await program(home, {
      projectionApplier: async (): Promise<never> => {
        throw new Error('injected projection failure');
      },
    }).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);

    expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
      applied: false,
      authoritativeRoster: 'committed',
      projections: 'incomplete',
      recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
    });
    expect(process.exitCode).toBe(1);
    expect(await readFile(rosterPath, 'utf8')).toContain('generation: 8');
    expect(await readFile(rosterPath, 'utf8')).toContain('name: coder0');
    await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
  });

  it('deletes only the target generated projection while retaining legacy and quarantine artifacts', async (): Promise<void> => {
    const home = await fleetHome();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const agentsDir = join(home, 'fleet', 'agents');
    const captured = output();

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);
    await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
      mode: 0o600,
    });
    await writeFile(join(agentsDir, 'coder0.env'), 'MOSAIC_AGENT_COMMAND=legacy-command\n', {
      mode: 0o600,
    });
    await writeFile(join(agentsDir, 'coder0.env.quarantine'), 'quarantine-before\n', {
      mode: 0o600,
    });
    await writeFile(join(agentsDir, 'unrelated.env.generated'), 'unrelated-before\n', {
      mode: 0o600,
    });
    const beforeDryRun = await Promise.all([
      readFile(rosterPath, 'utf8'),
      readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
      readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
      readFile(join(agentsDir, 'coder0.env'), 'utf8'),
      readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
      readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
    ]);

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'delete',
      'coder0',
      '--expected-generation',
      '8',
      '--dry-run',
    ]);
    expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
      applied: false,
      authoritativeRoster: 'unchanged',
      projections: 'not-applied',
    });
    expect(
      await Promise.all([
        readFile(rosterPath, 'utf8'),
        readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
        readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
        readFile(join(agentsDir, 'coder0.env'), 'utf8'),
        readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
        readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
      ]),
    ).toEqual(beforeDryRun);

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'delete',
      'coder0',
      '--expected-generation',
      '8',
    ]);

    expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
      applied: true,
      authoritativeRoster: 'committed',
      projections: 'complete',
    });
    await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
    expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(beforeDryRun[2]);
    expect(await readFile(join(agentsDir, 'coder0.env'), 'utf8')).toBe(beforeDryRun[3]);
    expect(await readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8')).toBe(beforeDryRun[4]);
    expect(await readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8')).toBe(
      beforeDryRun[5],
    );
    expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
  });

  it('reports truthful partial recovery when a delete projection fails after roster persistence', async (): Promise<void> => {
    const home = await fleetHome();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const agentsDir = join(home, 'fleet', 'agents');
    const captured = output();
    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);

    captured.lines.length = 0;
    await program(home, {
      projectionApplier: async (): Promise<never> => {
        throw new Error('injected projection failure');
      },
    }).parseAsync(['node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8']);

    expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
      applied: false,
      authoritativeRoster: 'committed',
      projections: 'incomplete',
      recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
    });
    expect(process.exitCode).toBe(1);
    expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
    expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
    expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
      'MOSAIC_AGENT_NAME=coder0',
    );
  });

  it.each(['unsafe directory permissions', 'symlinked directory'] as const)(
    'rejects delete before roster mutation for %s',
    async (hazard: 'unsafe directory permissions' | 'symlinked directory'): Promise<void> => {
      const home = await fleetHome();
      const rosterPath = join(home, 'fleet', 'roster.yaml');
      const agentsDir = join(home, 'fleet', 'agents');
      const captured = output();
      await program(home).parseAsync([
        'node',
        'mosaic',
        'fleet',
        'create',
        '--expected-generation',
        '7',
        '--agent',
        JSON.stringify(agent),
      ]);
      const beforeRoster = await readFile(rosterPath, 'utf8');
      let generatedPath = join(agentsDir, 'coder0.env.generated');

      if (hazard === 'unsafe directory permissions') {
        await chmod(agentsDir, 0o777);
      } else {
        const targetDir = join(home, 'fleet', 'agents-target');
        await rename(agentsDir, targetDir);
        await symlink(targetDir, agentsDir, 'dir');
        generatedPath = join(targetDir, 'coder0.env.generated');
      }

      try {
        captured.lines.length = 0;
        await program(home).parseAsync([
          'node',
          'mosaic',
          'fleet',
          'delete',
          'coder0',
          '--expected-generation',
          '8',
        ]);
      } finally {
        if (hazard === 'unsafe directory permissions') await chmod(agentsDir, 0o700);
      }

      expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({
        error: { code: 'mutation-failed' },
      });
      expect(await readFile(rosterPath, 'utf8')).toBe(beforeRoster);
      expect(await readFile(generatedPath, 'utf8')).toContain('MOSAIC_AGENT_NAME=coder0');
    },
  );

  it('creates, updates, and deletes through the v2 roster without runtime actions', async (): Promise<void> => {
    const home = await fleetHome();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const agentsDir = join(home, 'fleet', 'agents');
    const captured = output();
    const updated = { ...agent, alias: 'Coder Zero' };

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'create',
      '--expected-generation',
      '7',
      '--agent',
      JSON.stringify(agent),
    ]);
    expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true });
    expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
      'MOSAIC_AGENT_NAME=coder0',
    );

    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'update',
      'coder0',
      '--expected-generation',
      '8',
      '--agent',
      JSON.stringify(updated),
    ]);
    expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
      applied: true,
      plan: { nextGeneration: 9, agent: { alias: 'Coder Zero' } },
    });

    await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', {
      mode: 0o600,
    });
    await program(home).parseAsync([
      'node',
      'mosaic',
      'fleet',
      'delete',
      'coder0',
      '--expected-generation',
      '9',
    ]);

    expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
      applied: true,
      plan: { operation: 'delete', nextGeneration: 10, agent: { name: 'coder0' } },
    });
    await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
    expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(
      'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n',
    );
    expect(await readFile(rosterPath, 'utf8')).toContain('generation: 10');
    expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
  });
});
