import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
  FleetAgentMutationError,
  executeFleetAgentMutation,
  planFleetAgentMutation,
  type FleetAgentMutationRequest,
} from './fleet-agent-crud.js';
import { parseRosterV2, renderRosterV2Yaml } from './roster-v2.js';

const roster = parseRosterV2(`
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
`);

function createRequest(): FleetAgentMutationRequest {
  return {
    operation: 'create',
    expectedGeneration: 7,
    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> => {
  if (cleanup) await rm(cleanup, { recursive: true, force: true });
  cleanup = undefined;
});

async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
  cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-'));
  const rolesDir = join(cleanup, 'roles');
  const overrideDir = join(cleanup, 'roles.local');
  await mkdir(rolesDir, { recursive: true });
  await mkdir(overrideDir, { recursive: true });
  for (const klass of ['orchestrator', 'code']) {
    await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`);
  }
  return { rolesDir, overrideDir };
}

describe('fleet agent CRUD plan', (): void => {
  it('creates stopped-by-default desired state and a deterministic generation plan', (): void => {
    const plan = planFleetAgentMutation(roster, createRequest());

    expect(plan).toMatchObject({
      operation: 'create',
      currentGeneration: 7,
      nextGeneration: 8,
      changed: true,
      agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
    });
  });

  it('rejects a stale generation before proposing effects', (): void => {
    expect((): void => {
      planFleetAgentMutation(roster, { ...createRequest(), expectedGeneration: 6 });
    }).toThrow(FleetAgentMutationError);
  });

  it('treats an equivalent retry as an idempotent no-op', (): void => {
    const created = planFleetAgentMutation(roster, createRequest()).roster;
    const retried = planFleetAgentMutation(created, {
      ...createRequest(),
      expectedGeneration: created.generation,
    });

    expect(retried).toMatchObject({ changed: false, currentGeneration: 8, nextGeneration: 8 });
  });
});

describe('fleet agent CRUD execution', (): void => {
  it('keeps roster and projections unchanged for dry-run', async (): Promise<void> => {
    const dirs = await semanticDirs();
    const home = join(cleanup!, 'mosaic');
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
    await writeFile(rosterPath, 'before\n', { mode: 0o600 });

    const result = await executeFleetAgentMutation({
      roster,
      request: createRequest(),
      mosaicHome: home,
      rosterPath,
      agentEnvDir: join(home, 'fleet', 'agents'),
      rolesDir: dirs.rolesDir,
      overrideDir: dirs.overrideDir,
      dryRun: true,
    });

    expect(result.applied).toBe(false);
    expect(await readFile(rosterPath, 'utf8')).toBe('before\n');
    await expect(
      readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
    ).rejects.toThrow();
  });

  it('fails closed when another writer owns the mutation lock', async (): Promise<void> => {
    const dirs = await semanticDirs();
    const home = join(cleanup!, 'mosaic');
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
    await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
    await writeFile(`${rosterPath}.mutation.lock`, 'owned\n', { mode: 0o600 });

    await expect(
      executeFleetAgentMutation({
        roster,
        request: createRequest(),
        mosaicHome: home,
        rosterPath,
        agentEnvDir: join(home, 'fleet', 'agents'),
        rolesDir: dirs.rolesDir,
        overrideDir: dirs.overrideDir,
      }),
    ).rejects.toMatchObject({ code: 'concurrent-mutation' });
    expect(await readFile(rosterPath, 'utf8')).toBe(renderRosterV2Yaml(roster));
  });

  it('deletes only the removed agent generated projection when it is stale or absent', async (): Promise<void> => {
    const dirs = await semanticDirs();
    const home = join(cleanup!, 'mosaic');
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const agentEnvDir = join(home, 'fleet', 'agents');
    const created = planFleetAgentMutation(roster, createRequest()).roster;
    await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
    await writeFile(rosterPath, renderRosterV2Yaml(created), { mode: 0o600 });
    await writeFile(join(agentEnvDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
      mode: 0o600,
    });

    const result = await executeFleetAgentMutation({
      roster: created,
      request: { operation: 'delete', expectedGeneration: 8, name: 'coder0' },
      mosaicHome: home,
      rosterPath,
      agentEnvDir,
      rolesDir: dirs.rolesDir,
      overrideDir: dirs.overrideDir,
    });

    expect(result).toMatchObject({ applied: true, plan: { nextGeneration: 9 } });
    await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
    expect(await readFile(join(agentEnvDir, 'coder0.env.local'), 'utf8')).toBe(
      'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n',
    );
  });

  it('returns redacted projection recovery after the authoritative roster write', async (): Promise<void> => {
    const dirs = await semanticDirs();
    const home = join(cleanup!, 'mosaic');
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
    await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });

    const result = await executeFleetAgentMutation({
      roster,
      request: createRequest(),
      mosaicHome: home,
      rosterPath,
      agentEnvDir: join(home, 'fleet', 'agents'),
      rolesDir: dirs.rolesDir,
      overrideDir: dirs.overrideDir,
      projectionApplier: async (): Promise<void> => {
        throw new Error('MOSAIC_AGENT_COMMAND=must-not-leak');
      },
    });

    expect(result).toMatchObject({
      applied: false,
      authoritativeRoster: 'committed',
      projections: 'incomplete',
      recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
    });
    expect(JSON.stringify(result)).not.toContain('must-not-leak');
    expect(parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml').generation).toBe(8);
  });
});
