import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import YAML from 'yaml';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadFleetRoster } from '../commands/fleet.js';
import { executeFleetReconcile } from './fleet-reconciler.js';
import { compareCodePoints } from './deterministic-order.js';
import type { FleetRoster } from './fleet-roster-v1.js';
import { SHIPPED_FLEET_ARTIFACT_DISPOSITIONS } from './example-profile-dispositions.js';
import {
  collectShippedFleetMigrationDispositions,
  previewV1ToV2Migration,
  validateShippedFleetMigrationDispositions,
  type V1ToV2MigrationDecisions,
  type V1ToV2MigrationPreview,
} from './v1-v2-migration.js';

const temporaryDirectories: string[] = [];

type V1StringParityCase = Readonly<{
  field: string;
  source: () => string;
  productionValue?: (roster: FleetRoster) => unknown;
  expectedProduction?: unknown;
  productionError?: string;
  expectedStatus: 'ready' | 'blocked';
  blockerCode?: string;
  blockerPath?: string;
  migrationDecisions?: () => V1ToV2MigrationDecisions;
  candidateValue?: (preview: V1ToV2MigrationPreview) => unknown;
  expectedCandidate?: unknown;
}>;

const frameworkFleet = resolve(
  dirname(fileURLToPath(import.meta.url)),
  '..',
  '..',
  'framework',
  'fleet',
);

afterEach(async (): Promise<void> => {
  await Promise.all(
    temporaryDirectories
      .splice(0)
      .map((path): Promise<void> => rm(path, { recursive: true, force: true })),
  );
});

async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
  const root = await mkdtemp(join(tmpdir(), 'v1-v2-migration-roles-'));
  temporaryDirectories.push(root);
  const rolesDir = join(root, 'roles');
  const overrideDir = join(root, 'roles.local');
  await mkdir(rolesDir);
  await mkdir(overrideDir);
  for (const className of ['code', 'review', 'orchestrator', 'interaction', 'custom-domain']) {
    await writeFile(join(rolesDir, `${className}.md`), `# ${className}\n`, 'utf8');
  }
  return { rolesDir, overrideDir };
}

const v1Source = `
version: 1
transport: tmux
tmux:
  socket_name: mosaic-v1
  holder_session: _holder
defaults:
  working_directory: /srv/fleet
runtimes:
  pi:
    reset_command: /new
agents:
  - name: coder0
    alias: Coder Zero
    provider: openai
    runtime: pi
    class: implementer
    working_directory: /srv/coder0
    model_hint: legacy/model:high
    reasoning_level: high
    tool_policy: implementer
    persistent_persona: true
    reset_between_tasks: false
    kickstart_template: legacy-template
  - name: remote0
    runtime: pi
    class: reviewer
    host: build.example.test
    ssh: operator@build.example.test
    socket: mosaic-v1
connector:
  kind: discord
  discord:
    channel_id: channel
`;

function decisions(): V1ToV2MigrationDecisions {
  return {
    generation: 2,
    fleetHost: 'fleet.example.test',
    defaultRuntime: 'pi',
    agents: {
      coder0: {
        provider: 'openai',
        model: 'gpt-5.6-sol',
        reasoning: 'high',
        enabled: true,
        launchYolo: false,
        kickstartTemplate: 'inventory-only',
      },
    },
  };
}

function observations(
  observation: { systemd: 'active' | 'inactive'; tmux: 'present' | 'missing' } = {
    systemd: 'inactive',
    tmux: 'missing',
  },
): Record<string, typeof observation> {
  return { coder0: observation };
}

async function shippedV1PreviewEvidence(): Promise<
  Record<
    string,
    {
      decisions: V1ToV2MigrationDecisions;
      observations: Record<string, { systemd: 'inactive'; tmux: 'missing' }>;
    }
  >
> {
  const evidence: Record<
    string,
    {
      decisions: V1ToV2MigrationDecisions;
      observations: Record<string, { systemd: 'inactive'; tmux: 'missing' }>;
    }
  > = {};
  const explicitReplacements: Readonly<Record<string, string>> = {
    worker: 'code',
    canary: 'code',
    analyst: 'researcher',
  };
  const aliases: Readonly<Record<string, string>> = {
    implementer: 'code',
    reviewer: 'review',
    'operator-interaction': 'interaction',
  };
  const automatic = new Set(['orchestrator', 'enhancer', ...Object.keys(aliases)]);
  for (const artifact of SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
    ({ disposition }) => disposition === 'v1-fixture',
  )) {
    const source = await readFile(join(frameworkFleet, artifact.path), 'utf8');
    const raw = YAML.parse(source) as {
      agents: Array<{ name: string; class?: string; tool_policy?: string }>;
    };
    const agents: V1ToV2MigrationDecisions['agents'] = {};
    const lifecycle: Record<string, { systemd: 'inactive'; tmux: 'missing' }> = {};
    for (const agent of raw.agents) {
      const requestedClass = agent.class ?? 'worker';
      const replacement = explicitReplacements[requestedClass];
      const finalClass = replacement ?? aliases[requestedClass] ?? requestedClass;
      agents[agent.name] = {
        provider: 'fixture',
        model: 'fixture-model',
        reasoning: 'low',
        enabled: true,
        launchYolo: false,
        ...(!automatic.has(requestedClass)
          ? {
              classDisposition: replacement
                ? ({ action: 'replace', className: replacement } as const)
                : ({ action: 'preserve' } as const),
            }
          : {}),
        ...(agent.tool_policy === undefined
          ? { toolPolicyDisposition: { action: 'replace', className: finalClass } as const }
          : {}),
      };
      lifecycle[agent.name] = { systemd: 'inactive', tmux: 'missing' };
    }
    evidence[artifact.path] = {
      decisions: { generation: 2, defaultRuntime: 'pi', agents },
      observations: lifecycle,
    };
  }
  return evidence;
}

describe('v1-to-v2 migration preview', (): void => {
  it('inventories every v1 field without values and compiles only local agents through v2', async () => {
    const dirs = await semanticDirs();
    const preview = await previewV1ToV2Migration({
      source: v1Source,
      sourcePath: '/fixtures/roster.yaml',
      decisions: decisions(),
      observations: observations(),
      personaDirs: dirs,
    });

    expect(preview.status).toBe('ready');
    expect(preview.blockers).toEqual([]);
    expect(preview.inventory.map(({ path }) => path)).toEqual(
      expect.arrayContaining([
        'version',
        'tmux.socket_name',
        'defaults.working_directory',
        'runtimes.pi.reset_command',
        'agents[0].kickstart_template',
        'agents[1].host',
        'agents[1].ssh',
        'agents[1].socket',
        'connector.discord.channel_id',
      ]),
    );
    expect(preview.inventory.every((entry) => !Object.hasOwn(entry, 'value'))).toBe(true);
    expect(preview.inventoryOnlyAgents).toEqual([
      {
        name: 'remote0',
        fields: ['host', 'socket', 'ssh'],
        disposition: 'inventory-only',
      },
    ]);
    expect(
      preview.inventory
        .filter(({ path }) => path.startsWith('agents[1]'))
        .every(({ disposition }) => disposition === 'inventory-only'),
    ).toBe(true);
    expect(preview.canonicalRoster?.agents).toHaveLength(1);
    expect(preview.canonicalRoster?.agents[0]).toMatchObject({
      name: 'coder0',
      className: 'code',
      toolPolicy: 'code',
      lifecycle: { enabled: true, desiredState: 'stopped' },
    });
    expect(preview.canonicalYaml).toContain('class: code');
    expect(preview.canonicalYaml).not.toContain('remote0');
    expect(preview.evidence.source.sha256).toMatch(/^[a-f0-9]{64}$/);
    expect(preview.evidence.candidate?.sha256).toMatch(/^[a-f0-9]{64}$/);
    expect(preview.evidence.recovery).toEqual({
      executable: false,
      canaryExecuted: false,
      rollbackExecuted: false,
      requiredArtifacts: [
        'v1-roster-backup',
        'environment-backup',
        'observed-lifecycle-evidence',
        'candidate-v2-roster',
      ],
      gateOwner: 'FCM-M4-002',
    });
  });

  it.each([
    {
      label: 'host equal to reviewed fleet host',
      agentFields:
        '    host: fleet.example.test\n    ssh: operator@fleet.example.test\n    socket: mosaic-v1',
      fleetHost: 'fleet.example.test',
      expectedStatus: 'ready',
      expectedInventoryOnly: false,
      expectedBlocker: undefined,
    },
    {
      label: 'host different from reviewed fleet host',
      agentFields:
        '    host: build.example.test\n    ssh: operator@build.example.test\n    socket: mosaic-v1',
      fleetHost: 'fleet.example.test',
      expectedStatus: 'ready',
      expectedInventoryOnly: true,
      expectedBlocker: undefined,
    },
    {
      label: 'ssh only',
      agentFields: '    ssh: operator@build.example.test\n    socket: mosaic-v1',
      fleetHost: 'fleet.example.test',
      expectedStatus: 'blocked',
      expectedInventoryOnly: false,
      expectedBlocker: 'agent-locality-disposition-required',
    },
    {
      label: 'contradictory host and ssh targets',
      agentFields:
        '    host: fleet.example.test\n    ssh: operator@other.example.test\n    socket: mosaic-v1',
      fleetHost: 'fleet.example.test',
      expectedStatus: 'blocked',
      expectedInventoryOnly: false,
      expectedBlocker: 'agent-locality-disposition-required',
    },
    {
      label: 'host evidence without reviewed fleet host',
      agentFields: '    host: build.example.test\n    socket: mosaic-v1',
      fleetHost: undefined,
      expectedStatus: 'blocked',
      expectedInventoryOnly: false,
      expectedBlocker: 'agent-locality-disposition-required',
    },
  ])(
    'classifies $label without inferring locality',
    async ({ agentFields, fleetHost, expectedStatus, expectedInventoryOnly, expectedBlocker }) => {
      const source = v1Source.replace(
        '    host: build.example.test\n    ssh: operator@build.example.test\n    socket: mosaic-v1',
        agentFields,
      );
      const migrationDecisions = decisions();
      const baseRemoteDecision = {
        provider: 'openai',
        model: 'gpt-5.6-sol',
        reasoning: 'high' as const,
        enabled: true,
        launchYolo: false,
        toolPolicyDisposition: { action: 'replace' as const, className: 'review' },
      };
      if (!expectedInventoryOnly) migrationDecisions.agents.remote0 = baseRemoteDecision;
      const reviewedDecisions: V1ToV2MigrationDecisions = fleetHost
        ? { ...migrationDecisions, fleetHost }
        : { ...migrationDecisions, fleetHost: undefined };
      const preview = await previewV1ToV2Migration({
        source,
        decisions: reviewedDecisions,
        observations: expectedInventoryOnly
          ? observations()
          : {
              ...observations(),
              remote0: { systemd: 'inactive', tmux: 'missing' },
            },
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe(expectedStatus);
      expect(preview.inventoryOnlyAgents.some(({ name }) => name === 'remote0')).toBe(
        expectedInventoryOnly,
      );
      if (expectedInventoryOnly) {
        expect(preview.inventory.filter(({ path }) => path.startsWith('agents[1]'))).toSatisfy(
          (entries: Array<{ disposition: string }>) =>
            entries.every(({ disposition }) => disposition === 'inventory-only'),
        );
        expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
      } else {
        expect(
          preview.inventory
            .filter(({ path }) => path.startsWith('agents[1]'))
            .every(({ disposition }) => disposition !== 'inventory-only'),
        ).toBe(true);
      }
      if (expectedBlocker) {
        expect(preview.blockers).toContainEqual(
          expect.objectContaining({ code: expectedBlocker, agent: 'remote0' }),
        );
        expect(preview.evidence.observations).toContainEqual({
          agent: 'remote0',
          systemd: 'inactive',
          tmux: 'missing',
          preservedAs: 'stopped',
        });
      } else if (!expectedInventoryOnly) {
        expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual([
          'coder0',
          'remote0',
        ]);
      }
    },
  );

  it('matches production v1 locality for a remote agent with a bare-host SSH target', async () => {
    const source = v1Source.replace('ssh: operator@build.example.test', 'ssh: build.example.test');
    const rosterPath = join(
      await mkdtemp(join(tmpdir(), 'v1-bare-host-ssh-parity-')),
      'roster.yaml',
    );
    temporaryDirectories.push(dirname(rosterPath));
    await writeFile(rosterPath, source);

    await expect(loadFleetRoster(rosterPath)).resolves.toMatchObject({
      agents: [
        expect.any(Object),
        expect.objectContaining({ host: 'build.example.test', ssh: 'build.example.test' }),
      ],
    });

    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('ready');
    expect(preview.blockers).toEqual([]);
    expect(preview.inventoryOnlyAgents).toEqual([
      {
        name: 'remote0',
        fields: ['host', 'socket', 'ssh'],
        disposition: 'inventory-only',
      },
    ]);
    expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
  });

  it('matches production v1 rejection for a whitespace-padded agent name', async () => {
    const source = v1Source.replace('name: coder0', 'name: " coder0 "');
    const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-agent-name-parity-')), 'roster.yaml');
    temporaryDirectories.push(dirname(rosterPath));
    await writeFile(rosterPath, source);

    await expect(loadFleetRoster(rosterPath)).rejects.toThrow('Invalid fleet agent name');

    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('blocked');
    expect(preview.blockers).toContainEqual({
      code: 'invalid-v1-field-type',
      path: 'agents[0].name',
      detail: 'A valid agent name is required.',
    });
    expect(preview).not.toHaveProperty('canonicalRoster');
    expect(preview).not.toHaveProperty('canonicalYaml');
    expect(preview.evidence).not.toHaveProperty('candidate');
  });

  it.each([
    {
      field: 'runtime',
      source: (): string => v1Source.replace('runtime: pi', 'runtime: " pi "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.runtime,
      expectedProduction: ' pi ',
      expectedStatus: 'blocked',
      blockerCode: 'unsupported-runtime',
      blockerPath: 'agents[0].runtime',
    },
    {
      field: 'class',
      source: (): string => v1Source.replace('class: implementer', 'class: " implementer "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.className,
      expectedProduction: 'code',
      expectedStatus: 'ready',
      candidateValue: (preview: V1ToV2MigrationPreview): unknown =>
        preview.canonicalRoster?.agents[0]?.className,
      expectedCandidate: 'code',
    },
    {
      field: 'custom class',
      source: (): string => v1Source.replace('class: implementer', 'class: " custom-domain "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.className,
      expectedProduction: 'custom-domain',
      expectedStatus: 'blocked',
      blockerCode: 'class-disposition-required',
      blockerPath: 'agents[0].class',
    },
    {
      field: 'empty class with preserve disposition',
      source: (): string => v1Source.replace('class: implementer', 'class: ""'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.className,
      expectedProduction: '',
      expectedStatus: 'blocked',
      blockerCode: 'empty-v1-field-disposition-required',
      blockerPath: 'agents[0].class',
      migrationDecisions: (): V1ToV2MigrationDecisions => ({
        ...decisions(),
        agents: {
          ...decisions().agents,
          coder0: {
            ...decisions().agents.coder0!,
            classDisposition: { action: 'preserve' },
          },
        },
      }),
    },
    {
      field: 'empty tool policy with preserve disposition',
      source: (): string => v1Source.replace('tool_policy: implementer', 'tool_policy: ""'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.toolPolicy,
      expectedProduction: '',
      expectedStatus: 'blocked',
      blockerCode: 'empty-v1-field-disposition-required',
      blockerPath: 'agents[0].tool_policy',
      migrationDecisions: (): V1ToV2MigrationDecisions => ({
        ...decisions(),
        agents: {
          ...decisions().agents,
          coder0: {
            ...decisions().agents.coder0!,
            toolPolicyDisposition: { action: 'preserve' },
          },
        },
      }),
    },
    {
      field: 'tool policy',
      source: (): string =>
        v1Source.replace('tool_policy: implementer', 'tool_policy: " implementer "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.toolPolicy,
      expectedProduction: ' implementer ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'agents[0].tool_policy',
    },
    {
      field: 'camel-case tool policy',
      source: (): string =>
        v1Source.replace('tool_policy: implementer', 'toolPolicy: " implementer "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.toolPolicy,
      expectedProduction: ' implementer ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'agents[0].toolPolicy',
    },
    {
      field: 'alias',
      source: (): string => v1Source.replace('alias: Coder Zero', 'alias: " Coder Zero "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.alias,
      expectedProduction: ' Coder Zero ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'agents[0].alias',
    },
    {
      field: 'agent working directory',
      source: (): string =>
        v1Source.replace('working_directory: /srv/coder0', 'working_directory: " /srv/coder0 "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.workingDirectory,
      expectedProduction: ' /srv/coder0 ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'agents[0].working_directory',
    },
    {
      field: 'camel-case agent working directory',
      source: (): string =>
        v1Source.replace('working_directory: /srv/coder0', 'workingDirectory: " /srv/coder0 "'),
      productionValue: (roster: FleetRoster): unknown => roster.agents[0]?.workingDirectory,
      expectedProduction: ' /srv/coder0 ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'agents[0].workingDirectory',
    },
    {
      field: 'default working directory',
      source: (): string =>
        v1Source
          .replace('working_directory: /srv/fleet', 'working_directory: " /srv/fleet "')
          .replace('    working_directory: /srv/coder0\n', ''),
      productionValue: (roster: FleetRoster): unknown => roster.defaults.workingDirectory,
      expectedProduction: ' /srv/fleet ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'defaults.working_directory',
    },
    {
      field: 'camel-case default working directory',
      source: (): string =>
        v1Source
          .replace('working_directory: /srv/fleet', 'workingDirectory: " /srv/fleet "')
          .replace('    working_directory: /srv/coder0\n', ''),
      productionValue: (roster: FleetRoster): unknown => roster.defaults.workingDirectory,
      expectedProduction: ' /srv/fleet ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'defaults.workingDirectory',
    },
    {
      field: 'tmux holder session',
      source: (): string =>
        v1Source.replace('holder_session: _holder', 'holder_session: " _holder "'),
      productionValue: (roster: FleetRoster): unknown => roster.tmux.holderSession,
      expectedProduction: ' _holder ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'tmux.holder_session',
    },
    {
      field: 'camel-case tmux holder session',
      source: (): string =>
        v1Source.replace('holder_session: _holder', 'holderSession: " _holder "'),
      productionValue: (roster: FleetRoster): unknown => roster.tmux.holderSession,
      expectedProduction: ' _holder ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'tmux.holderSession',
    },
    {
      field: 'runtime reset command',
      source: (): string => v1Source.replace('reset_command: /new', 'reset_command: " /new "'),
      productionValue: (roster: FleetRoster): unknown => roster.runtimes.pi?.resetCommand,
      expectedProduction: ' /new ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'runtimes.pi.reset_command',
    },
    {
      field: 'camel-case runtime reset command',
      source: (): string => v1Source.replace('reset_command: /new', 'resetCommand: " /new "'),
      productionValue: (roster: FleetRoster): unknown => roster.runtimes.pi?.resetCommand,
      expectedProduction: ' /new ',
      expectedStatus: 'blocked',
      blockerCode: 'lossy-v1-string-normalization',
      blockerPath: 'runtimes.pi.resetCommand',
    },
    {
      field: 'remote inventory alias',
      source: (): string =>
        v1Source.replace(
          '  - name: remote0\n    runtime: pi',
          '  - name: remote0\n    alias: " Remote Zero "\n    runtime: pi',
        ),
      productionValue: (roster: FleetRoster): unknown => roster.agents[1]?.alias,
      expectedProduction: ' Remote Zero ',
      expectedStatus: 'ready',
      candidateValue: (preview: V1ToV2MigrationPreview): unknown =>
        preview.inventoryOnlyAgents.find(({ name }) => name === 'remote0')?.disposition,
      expectedCandidate: 'inventory-only',
    },
    {
      field: 'host',
      source: (): string =>
        v1Source.replace('host: build.example.test', 'host: " build.example.test "'),
      productionError: 'unsupported targeting characters',
      expectedStatus: 'blocked',
      blockerCode: 'invalid-v1-targeting-characters',
      blockerPath: 'agents[1].host',
    },
    {
      field: 'SSH target',
      source: (): string =>
        v1Source.replace(
          'ssh: operator@build.example.test',
          'ssh: " operator@build.example.test "',
        ),
      productionError: 'unsupported targeting characters',
      expectedStatus: 'blocked',
      blockerCode: 'invalid-v1-targeting-characters',
      blockerPath: 'agents[1].ssh',
    },
  ] satisfies readonly V1StringParityCase[])(
    'matches production v1 semantics for padded $field source',
    async ({
      source: buildSource,
      productionValue,
      expectedProduction,
      productionError,
      expectedStatus,
      blockerCode,
      blockerPath,
      migrationDecisions,
      candidateValue,
      expectedCandidate,
    }: V1StringParityCase) => {
      const source = buildSource();
      const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-string-parity-')), 'roster.yaml');
      temporaryDirectories.push(dirname(rosterPath));
      await writeFile(rosterPath, source);

      if (productionError) {
        await expect(loadFleetRoster(rosterPath)).rejects.toThrow(productionError);
      } else {
        const productionRoster = await loadFleetRoster(rosterPath);
        expect(productionValue?.(productionRoster)).toBe(expectedProduction);
      }

      const preview = await previewV1ToV2Migration({
        source,
        decisions: migrationDecisions?.() ?? decisions(),
        observations: observations(),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe(expectedStatus);
      if (expectedStatus === 'blocked') {
        expect(preview.blockers).toContainEqual(
          expect.objectContaining({ code: blockerCode, path: blockerPath }),
        );
        expect(preview).not.toHaveProperty('canonicalRoster');
        expect(preview).not.toHaveProperty('canonicalYaml');
        expect(preview.evidence).not.toHaveProperty('candidate');
      } else {
        expect(preview.blockers).toEqual([]);
        expect(candidateValue?.(preview)).toBe(expectedCandidate);
      }
    },
  );

  it.each([
    ['socket_name', "  socket_name: ''\n  holder_session: _holder"],
    ['socketName', "  socketName: ''\n  holder_session: _holder"],
  ] as const)(
    'preserves an explicit empty tmux.%s declaration as the literal default server without a decision',
    async (_field, tmuxBlock) => {
      const source = v1Source
        .replace('  socket_name: mosaic-v1\n  holder_session: _holder', tmuxBlock)
        .replace('    socket: mosaic-v1\n', '');
      const preview = await previewV1ToV2Migration({
        source,
        decisions: decisions(),
        observations: observations({ systemd: 'active', tmux: 'present' }),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe('ready');
      expect(preview.blockers).toEqual([]);
      expect(preview.canonicalRoster?.tmux.socketName).toBe('');
      expect(preview.canonicalYaml).toContain('socket_name: ""');
    },
  );

  it.each([
    ['socket_name', "  socket_name: ''\n  holder_session: _holder"],
    ['socketName', "  socketName: ''\n  holder_session: _holder"],
  ] as const)(
    'treats an explicit empty tmux.%s declaration as authoritative and blocks a named decision',
    async (_field, tmuxBlock) => {
      const source = v1Source.replace(
        '  socket_name: mosaic-v1\n  holder_session: _holder',
        tmuxBlock,
      );
      const preview = await previewV1ToV2Migration({
        source,
        decisions: { ...decisions(), socketName: 'decision-socket' },
        observations: observations({ systemd: 'active', tmux: 'present' }),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe('blocked');
      expect(preview.canonicalRoster).toBeUndefined();
      expect(preview.blockers).toContainEqual(
        expect.objectContaining({
          code: 'socket-name-conflict',
          path: 'decisions.socketName',
        }),
      );
      expect(preview.evidence.observations).toContainEqual({
        agent: 'coder0',
        systemd: 'active',
        tmux: 'present',
        preservedAs: 'running',
      });
    },
  );

  it.each([
    ['socket_name', 'mosaic-v1', 'mosaic-v1', 'ready'],
    ['socket_name', 'mosaic-v1', 'other-socket', 'blocked'],
    ['socketName', 'mosaic-v1', 'mosaic-v1', 'ready'],
    ['socketName', 'mosaic-v1', 'other-socket', 'blocked'],
  ] as const)(
    '%s named declaration with a %s reviewed decision preserves declared authority',
    async (field, declaredSocket, decisionSocket, expectedStatus) => {
      const source = v1Source.replace('  socket_name: mosaic-v1', `  ${field}: ${declaredSocket}`);
      const migrationDecisions: V1ToV2MigrationDecisions = {
        ...decisions(),
        socketName: decisionSocket,
      };
      const preview = await previewV1ToV2Migration({
        source,
        decisions: migrationDecisions,
        observations: observations({ systemd: 'active', tmux: 'present' }),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe(expectedStatus);
      expect(preview.evidence.observations).toContainEqual({
        agent: 'coder0',
        systemd: 'active',
        tmux: 'present',
        preservedAs: 'running',
      });
      if (expectedStatus === 'ready') {
        expect(preview.canonicalRoster?.tmux.socketName).toBe('mosaic-v1');
        expect(preview.canonicalRoster?.agents[0]?.lifecycle.desiredState).toBe('running');
      } else {
        expect(preview.canonicalRoster).toBeUndefined();
        expect(preview.blockers).toContainEqual(
          expect.objectContaining({
            code: 'socket-name-conflict',
            path: 'decisions.socketName',
          }),
        );
      }
    },
  );

  it('migrates a compatible socket-only local agent with reviewed lifecycle evidence', async () => {
    const source = v1Source
      .replace('    host: build.example.test\n', '')
      .replace('    ssh: operator@build.example.test\n', '')
      .replace('    socket: remote-fleet', '    socket: mosaic-v1');
    const migrationDecisions = decisions();
    migrationDecisions.agents.remote0 = {
      provider: 'openai',
      model: 'gpt-5.6-sol',
      reasoning: 'high',
      enabled: true,
      launchYolo: false,
      toolPolicyDisposition: { action: 'replace', className: 'review' },
    };
    const preview = await previewV1ToV2Migration({
      source,
      decisions: migrationDecisions,
      observations: {
        ...observations(),
        remote0: { systemd: 'inactive', tmux: 'missing' },
      },
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('ready');
    expect(preview.inventoryOnlyAgents).toEqual([]);
    expect(preview.inventory).toContainEqual({
      path: 'agents[1].socket',
      disposition: 'candidate',
    });
    expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0', 'remote0']);
    expect(preview.evidence.observations).toContainEqual({
      agent: 'remote0',
      systemd: 'inactive',
      tmux: 'missing',
      preservedAs: 'stopped',
    });
  });

  it('blocks a socket-only local agent that conflicts with the canonical fleet socket', async () => {
    const source = v1Source
      .replace('    host: build.example.test\n', '')
      .replace('    ssh: operator@build.example.test\n', '')
      .replace('    socket: mosaic-v1', '    socket: other-socket');
    const migrationDecisions = decisions();
    migrationDecisions.agents.remote0 = {
      provider: 'openai',
      model: 'gpt-5.6-sol',
      reasoning: 'high',
      enabled: true,
      launchYolo: false,
      toolPolicyDisposition: { action: 'replace', className: 'review' },
    };
    const preview = await previewV1ToV2Migration({
      source,
      decisions: migrationDecisions,
      observations: {
        ...observations(),
        remote0: { systemd: 'active', tmux: 'present' },
      },
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('blocked');
    expect(preview.inventoryOnlyAgents).toEqual([]);
    expect(preview.blockers).toContainEqual(
      expect.objectContaining({
        code: 'agent-socket-disposition-required',
        path: 'agents[1].socket',
        agent: 'remote0',
      }),
    );
    expect(preview.canonicalRoster).toBeUndefined();
    expect(preview.evidence.observations).toContainEqual({
      agent: 'remote0',
      systemd: 'active',
      tmux: 'present',
      preservedAs: 'running',
    });
  });

  it('matches production v1 reset defaults when a declared runtime config is empty', async () => {
    const source = v1Source.replace('  pi:\n    reset_command: /new', '  pi: {}');
    const productionSource = source
      .replace(/  - name: remote0[\s\S]*?connector:/, 'connector:')
      .replace(/connector:[\s\S]*$/, '');
    const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-runtime-parity-')), 'roster.yaml');
    temporaryDirectories.push(dirname(rosterPath));
    await writeFile(rosterPath, productionSource);
    const productionRoster = await loadFleetRoster(rosterPath);
    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('ready');
    expect(productionRoster.runtimes.pi?.resetCommand).toBe('/clear');
    expect(preview.canonicalRoster?.runtimes.pi?.resetCommand).toBe(
      productionRoster.runtimes.pi?.resetCommand,
    );
  });

  it('emits byte-identical preview evidence when equivalent local agents are reordered', async () => {
    const coder1 = `  - name: coder1
    alias: Coder One
    provider: openai
    runtime: pi
    class: reviewer
    working_directory: /srv/coder1
    reasoning_level: high
    tool_policy: reviewer
`;
    const sourceA = v1Source.replace('  - name: remote0', `${coder1}  - name: remote0`);
    const sourceB = sourceA.replace(
      /(  - name: coder0[\s\S]*?)(  - name: coder1[\s\S]*?)(  - name: remote0)/,
      '$2$1$3',
    );
    const migrationDecisions = decisions();
    migrationDecisions.agents.coder1 = {
      provider: 'openai',
      model: 'gpt-5.6-sol',
      reasoning: 'high',
      enabled: true,
      launchYolo: false,
    };
    const lifecycle = {
      ...observations(),
      coder1: { systemd: 'active' as const, tmux: 'present' as const },
    };
    const dirs = await semanticDirs();
    const previews = await Promise.all(
      [sourceA, sourceB].map((source) =>
        previewV1ToV2Migration({
          source,
          decisions: migrationDecisions,
          observations: lifecycle,
          personaDirs: dirs,
        }),
      ),
    );
    const previewA = previews[0]!;
    const previewB = previews[1]!;

    expect(previewA.status).toBe('ready');
    expect(previewB.status).toBe('ready');
    expect(previewB.evidence.observations).toEqual(previewA.evidence.observations);
    expect({
      ...previewB.evidence,
      source: { ...previewB.evidence.source, sha256: '<source-specific>' },
    }).toEqual({
      ...previewA.evidence,
      source: { ...previewA.evidence.source, sha256: '<source-specific>' },
    });
    expect(previewB.canonicalYaml).toBe(previewA.canonicalYaml);
  });

  it('emits byte-identical preview evidence when equivalent remote agents are reordered', async () => {
    const remote1 = `  - name: remote1
    runtime: pi
    class: reviewer
    host: review.example.test
    ssh: operator@review.example.test
    socket: mosaic-v1
`;
    const sourceA = v1Source.replace('connector:', `${remote1}connector:`);
    const sourceB = sourceA.replace(
      /(  - name: remote0[\s\S]*?)(  - name: remote1[\s\S]*?)(connector:)/,
      '$2$1$3',
    );
    const dirs = await semanticDirs();
    const previews = await Promise.all(
      [sourceA, sourceB].map((source) =>
        previewV1ToV2Migration({
          source,
          decisions: decisions(),
          observations: observations(),
          personaDirs: dirs,
        }),
      ),
    );
    const previewA = previews[0]!;
    const previewB = previews[1]!;
    const semanticOutput = (preview: V1ToV2MigrationPreview): object => ({
      ...preview,
      evidence: {
        ...preview.evidence,
        source: { ...preview.evidence.source, sha256: '<source-specific>' },
      },
    });

    expect(previewA.status).toBe('ready');
    expect(previewB.status).toBe('ready');
    expect(semanticOutput(previewB)).toEqual(semanticOutput(previewA));
  });

  it('emits byte-identical canonical evidence regardless of locale collation', async () => {
    expect(['😀', 'z', '', 'ä'].sort(compareCodePoints)).toEqual(['z', 'ä', '', '😀']);
    const dirs = await semanticDirs();
    const renderUnderLocale = async (locale: string): Promise<string> => {
      const collator = new Intl.Collator(locale);
      const localeCompare = vi
        .spyOn(String.prototype, 'localeCompare')
        .mockImplementation(function (this: string, other: string): number {
          return collator.compare(this, other);
        });
      try {
        const preview = await previewV1ToV2Migration({
          source: v1Source,
          decisions: decisions(),
          observations: observations(),
          personaDirs: dirs,
        });
        return JSON.stringify(preview);
      } finally {
        localeCompare.mockRestore();
      }
    };

    expect(await renderUnderLocale('en')).toBe(await renderUnderLocale('sv'));
  });

  it.each([
    [{ systemd: 'active' as const, tmux: 'present' as const }, 'running'],
    [{ systemd: 'inactive' as const, tmux: 'missing' as const }, 'stopped'],
  ])('preserves an unambiguous observed lifecycle %j as %s', async (observation, expected) => {
    const preview = await previewV1ToV2Migration({
      source: v1Source,
      sourcePath: '/fixtures/roster.yaml',
      decisions: decisions(),
      observations: observations(observation),
      personaDirs: await semanticDirs(),
    });
    expect(preview.canonicalRoster?.agents[0]?.lifecycle.desiredState).toBe(expected);
    expect(preview.evidence.observations[0]).toMatchObject({
      agent: 'coder0',
      systemd: observation.systemd,
      tmux: observation.tmux,
      preservedAs: expected,
    });
  });

  it('blocks missing or contradictory lifecycle evidence instead of inferring it', async () => {
    const missing = decisions();
    const missingPreview = await previewV1ToV2Migration({
      source: v1Source,
      decisions: missing,
      observations: {},
      personaDirs: await semanticDirs(),
    });
    expect(missingPreview.status).toBe('blocked');
    expect(missingPreview.canonicalYaml).toBeUndefined();
    expect(missingPreview.canonicalRoster).toBeUndefined();
    expect(missingPreview.evidence.candidate).toBeUndefined();
    expect(missingPreview.blockers).toContainEqual(
      expect.objectContaining({ code: 'missing-lifecycle-observation', agent: 'coder0' }),
    );

    const contradictory = await previewV1ToV2Migration({
      source: v1Source,
      decisions: decisions(),
      observations: observations({ systemd: 'active', tmux: 'missing' }),
      personaDirs: await semanticDirs(),
    });
    expect(contradictory.status).toBe('blocked');
    expect(contradictory.blockers).toContainEqual(
      expect.objectContaining({ code: 'ambiguous-lifecycle-observation', agent: 'coder0' }),
    );
  });

  it('uses only approved aliases and requires explicit disposition for domain classes', async () => {
    const source = v1Source.replace('class: implementer', 'class: custom-domain');
    const blocked = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(blocked.status).toBe('blocked');
    expect(blocked.blockers).toContainEqual(
      expect.objectContaining({ code: 'class-disposition-required', agent: 'coder0' }),
    );

    const explicit = decisions();
    const coderDecision = explicit.agents.coder0;
    if (!coderDecision) throw new Error('fixture decision missing');
    const explicitDecisions: V1ToV2MigrationDecisions = {
      ...explicit,
      agents: {
        ...explicit.agents,
        coder0: {
          ...coderDecision,
          classDisposition: { action: 'preserve' },
        },
      },
    };
    const ready = await previewV1ToV2Migration({
      source,
      decisions: explicitDecisions,
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(ready.status).toBe('ready');
    expect(ready.canonicalRoster?.agents[0]?.className).toBe('custom-domain');
  });

  it.each([
    [
      'holder_session',
      'tmux:\n  socket_name: mosaic-v1\n  holder_session: _holder',
      "tmux:\n  socket_name: mosaic-v1\n  holder_session: ''",
      'tmux.holder_session',
    ],
    [
      'holderSession',
      'tmux:\n  socket_name: mosaic-v1\n  holder_session: _holder',
      "tmux:\n  socket_name: mosaic-v1\n  holderSession: ''",
      'tmux.holderSession',
    ],
    [
      'default working_directory',
      'defaults:\n  working_directory: /srv/fleet',
      "defaults:\n  working_directory: ''",
      'defaults.working_directory',
    ],
    [
      'default workingDirectory',
      'defaults:\n  working_directory: /srv/fleet',
      "defaults:\n  workingDirectory: ''",
      'defaults.workingDirectory',
    ],
    [
      'runtime reset_command',
      'runtimes:\n  pi:\n    reset_command: /new',
      "runtimes:\n  pi:\n    reset_command: ''",
      'runtimes.pi.reset_command',
    ],
    [
      'runtime resetCommand',
      'runtimes:\n  pi:\n    reset_command: /new',
      "runtimes:\n  pi:\n    resetCommand: ''",
      'runtimes.pi.resetCommand',
    ],
    ['agent alias', '    alias: Coder Zero', "    alias: '   '", 'agents[0].alias'],
    [
      'agent working_directory',
      '    working_directory: /srv/coder0',
      "    working_directory: ''",
      'agents[0].working_directory',
    ],
    [
      'agent workingDirectory',
      '    working_directory: /srv/coder0',
      "    workingDirectory: ''",
      'agents[0].workingDirectory',
    ],
  ] as const)(
    'blocks present-empty %s instead of silently applying a fallback',
    async (_case, anchor, replacement, expectedPath) => {
      const source = v1Source.replace(anchor, replacement);
      const preview = await previewV1ToV2Migration({
        source,
        decisions: decisions(),
        observations: observations(),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe('blocked');
      expect(preview.canonicalRoster).toBeUndefined();
      expect(preview.blockers).toContainEqual(
        expect.objectContaining({
          code: 'empty-v1-field-disposition-required',
          path: expectedPath,
        }),
      );
    },
  );

  it.each([
    ['absent', undefined, []],
    ['regenerate-only', 'MOSAIC_AGENT_NAME=legacy-generated\n', []],
    ['relocate-local', 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', ['MOSAIC_RUNTIME_BIN']],
    ['quarantine', 'MOSAIC_AGENT_COMMAND=never-publish\n', []],
  ] as const)(
    'preserves the %s legacy environment disposition in migration evidence',
    async (expectedLegacy, legacySource, relocatedKeys) => {
      const dirs = await semanticDirs();
      const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-environment-disposition-'));
      temporaryDirectories.push(mosaicHome);
      const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
      await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
      await chmod(mosaicHome, 0o700);
      await chmod(join(mosaicHome, 'fleet'), 0o700);
      await chmod(agentEnvDir, 0o700);
      if (legacySource !== undefined) {
        await writeFile(join(agentEnvDir, 'coder0.env'), legacySource, { mode: 0o600 });
      }

      const preview = await previewV1ToV2Migration({
        source: v1Source,
        decisions: decisions(),
        observations: observations(),
        personaDirs: dirs,
        environment: { mosaicHome, agentEnvDir },
      });

      expect(preview.environment).toContainEqual(
        expect.objectContaining({
          agent: 'coder0',
          legacy: expectedLegacy,
          relocatedKeys: [...relocatedKeys],
        }),
      );
    },
  );

  it('preflights legacy env relocation and quarantine without exposing values or changing files', async () => {
    const dirs = await semanticDirs();
    const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-migration-home-'));
    temporaryDirectories.push(mosaicHome);
    const fleetDir = join(mosaicHome, 'fleet');
    const agentEnvDir = join(fleetDir, 'agents');
    await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
    await chmod(mosaicHome, 0o700);
    await chmod(fleetDir, 0o700);
    await chmod(agentEnvDir, 0o700);
    const forbiddenValue = 'run-privileged --token never-print-this';
    const legacyPath = join(agentEnvDir, 'coder0.env');
    await writeFile(
      legacyPath,
      `MOSAIC_RUNTIME_BIN=/usr/bin/pi\nMOSAIC_AGENT_COMMAND=${forbiddenValue}\n`,
      { mode: 0o600 },
    );

    const preview = await previewV1ToV2Migration({
      source: v1Source.replace('working_directory: /srv/coder0', 'working_directory: ~/src'),
      decisions: decisions(),
      observations: observations(),
      personaDirs: dirs,
      environment: { mosaicHome, agentEnvDir },
    });
    const serialized = JSON.stringify(preview);
    expect(preview.status).toBe('ready');
    expect(preview.environment).toEqual([
      expect.objectContaining({
        agent: 'coder0',
        generated: 'rebuild',
        legacy: 'quarantine',
        relocatedKeys: ['MOSAIC_RUNTIME_BIN'],
        diagnostics: [
          expect.objectContaining({
            code: 'unknown-key',
            key: 'MOSAIC_AGENT_COMMAND',
            sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
          }),
        ],
      }),
    ]);
    expect(serialized).not.toContain(forbiddenValue);
    expect(
      await import('node:fs/promises').then(({ readFile }) => readFile(legacyPath, 'utf8')),
    ).toContain(forbiddenValue);
  });

  it('feeds a ready home-relative candidate through the production projection boundary', async () => {
    const source = v1Source.replace('working_directory: /srv/coder0', 'working_directory: ~/src');
    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(preview.status).toBe('ready');
    expect(preview.canonicalRoster?.agents[0]?.workingDirectory).toBe('~/src');
    expect(preview.canonicalYaml).toContain('working_directory: ~/src');

    const canonicalRoster = preview.canonicalRoster!;
    const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-production-projection-'));
    temporaryDirectories.push(mosaicHome);
    const fleetDir = join(mosaicHome, 'fleet');
    const agentEnvDir = join(fleetDir, 'agents');
    await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
    await chmod(mosaicHome, 0o700);
    await chmod(fleetDir, 0o700);
    await chmod(agentEnvDir, 0o700);

    const projectionDirs = await semanticDirs();
    await expect(
      executeFleetReconcile({
        roster: canonicalRoster,
        command: 'plan',
        deps: {
          mosaicHome,
          rolesDir: projectionDirs.rolesDir,
          overrideDir: projectionDirs.overrideDir,
          runner: async (command, args) => {
            if (command === 'tmux' && args.includes('list-sessions')) {
              return { stdout: '', stderr: '', exitCode: 1 };
            }
            return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
          },
        },
      }),
    ).resolves.toMatchObject({ applied: false, projections: 'not-applied' });
  });

  it('blocks undeclared v1 fields, synonym collisions, and duplicate names', async () => {
    const baseOptions = {
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    };
    const unknown = await previewV1ToV2Migration({
      ...baseOptions,
      source: v1Source.replace('version: 1', 'version: 1\nunknown_root: value'),
    });
    expect(unknown.blockers).toContainEqual(
      expect.objectContaining({ code: 'unsupported-v1-field', path: 'unknown_root' }),
    );

    const collision = await previewV1ToV2Migration({
      ...baseOptions,
      source: v1Source.replace(
        'socket_name: mosaic-v1',
        'socket_name: mosaic-v1\n  socketName: other',
      ),
    });
    expect(collision.blockers).toContainEqual(
      expect.objectContaining({ code: 'v1-synonym-collision' }),
    );

    const duplicate = await previewV1ToV2Migration({
      ...baseOptions,
      source: v1Source.replace('name: remote0', 'name: coder0'),
    });
    expect(duplicate.blockers).toContainEqual(
      expect.objectContaining({ code: 'duplicate-agent-name', agent: 'coder0' }),
    );
  });

  it('rejects extra observations and an observed-running disabled disposition', async () => {
    const extra = await previewV1ToV2Migration({
      source: v1Source,
      decisions: decisions(),
      observations: { ...observations(), remote0: { systemd: 'active', tmux: 'present' } },
      personaDirs: await semanticDirs(),
    });
    expect(extra.blockers).toContainEqual(
      expect.objectContaining({ code: 'extra-lifecycle-observation', agent: 'remote0' }),
    );

    const base = decisions();
    const coder = base.agents.coder0;
    if (!coder) throw new Error('fixture decision missing');
    const disabled = await previewV1ToV2Migration({
      source: v1Source,
      decisions: {
        ...base,
        agents: { ...base.agents, coder0: { ...coder, enabled: false } },
      },
      observations: observations({ systemd: 'active', tmux: 'present' }),
      personaDirs: await semanticDirs(),
    });
    expect(disabled.blockers).toContainEqual(
      expect.objectContaining({ code: 'disabled-running-observation', agent: 'coder0' }),
    );
  });

  it('rejects unknown or malformed decision and observation fields before preview', async () => {
    const unknownDecision = decisions() as V1ToV2MigrationDecisions & { unexpected: string };
    unknownDecision.unexpected = 'value';
    await expect(
      previewV1ToV2Migration({
        source: v1Source,
        decisions: unknownDecision,
        observations: observations(),
        personaDirs: await semanticDirs(),
      }),
    ).rejects.toThrow('decisions.unexpected is not supported');

    await expect(
      previewV1ToV2Migration({
        source: v1Source,
        decisions: decisions(),
        observations: {
          coder0: { systemd: 'active', tmux: 'missing', extra: true },
        } as never,
        personaDirs: await semanticDirs(),
      }),
    ).rejects.toThrow('observations.coder0.extra is not supported');
  });

  it('rejects extra agent decisions and never derives a missing tool policy from class', async () => {
    const base = decisions();
    const coder = base.agents.coder0;
    if (!coder) throw new Error('fixture decision missing');
    const noToolPolicy = v1Source.replace('    tool_policy: implementer\n', '');
    const blocked = await previewV1ToV2Migration({
      source: noToolPolicy,
      decisions: {
        ...base,
        agents: { ...base.agents, extra0: coder },
      },
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(blocked.blockers).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ code: 'tool-policy-disposition-required', agent: 'coder0' }),
        expect.objectContaining({ code: 'extra-agent-decision', agent: 'extra0' }),
      ]),
    );
  });

  it('requires no competing disposition for automatic aliases', async () => {
    const base = decisions();
    const coder = base.agents.coder0;
    if (!coder) throw new Error('fixture decision missing');
    const blocked = await previewV1ToV2Migration({
      source: v1Source,
      decisions: {
        ...base,
        agents: {
          coder0: { ...coder, classDisposition: { action: 'replace', className: 'review' } },
        },
      },
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(blocked.blockers).toContainEqual(
      expect.objectContaining({ code: 'redundant-class-disposition', agent: 'coder0' }),
    );
  });

  it('produces byte-stable evidence for repeated YAML and equivalent JSON input', async () => {
    const dirs = await semanticDirs();
    const options = {
      source: v1Source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: dirs,
    };
    const first = await previewV1ToV2Migration(options);
    const second = await previewV1ToV2Migration(options);
    expect(second).toEqual(first);

    const parsed = (await import('yaml')).default.parse(v1Source) as unknown;
    const json = await previewV1ToV2Migration({ ...options, source: JSON.stringify(parsed) });
    expect(json.canonicalYaml).toBe(first.canonicalYaml);
    expect(json.evidence.candidate).toEqual(first.evidence.candidate);
  });

  it('inventories Matrix connectors and remote agents without environment preflight', async () => {
    const source = v1Source.replace(
      /connector:\n[\s\S]*$/,
      'connector:\n  kind: matrix\n  matrix:\n    homeserver_url: https://matrix.example.test\n    user_id: "@bot:example.test"\n    room_id: "!room:example.test"\n',
    );
    const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-remote-no-preflight-'));
    temporaryDirectories.push(mosaicHome);
    const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
    await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
    await chmod(mosaicHome, 0o700);
    await chmod(join(mosaicHome, 'fleet'), 0o700);
    await chmod(agentEnvDir, 0o700);
    await writeFile(join(agentEnvDir, 'remote0.env'), 'MOSAIC_AGENT_COMMAND=never-publish\n', {
      mode: 0o644,
    });
    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
      environment: { mosaicHome, agentEnvDir },
    });
    expect(preview.status).toBe('ready');
    expect(preview.evidence.excludedInventory).toEqual(
      expect.arrayContaining(['connector', 'agents.remote0']),
    );
    expect(preview.inventory).toContainEqual(
      expect.objectContaining({ path: 'connector.matrix.room_id', disposition: 'inventory-only' }),
    );
    expect(preview.environment.map(({ agent }) => agent)).toEqual(['coder0']);
  });

  it.each([
    [
      'tmux connector with matrix settings',
      'connector:\n  kind: tmux\n  matrix:\n    homeserver_url: https://rejected-matrix.example.test\n    user_id: "@bot:example.test"\n    room_id: "!room:example.test"\n',
      'connector.matrix',
      'https://rejected-matrix.example.test',
    ],
    [
      'tmux connector with discord settings',
      'connector:\n  kind: tmux\n  discord:\n    channel_id: rejected-discord-channel\n',
      'connector.discord',
      'rejected-discord-channel',
    ],
    [
      'discord connector with matrix settings',
      'connector:\n  kind: discord\n  discord:\n    channel_id: channel\n  matrix:\n    homeserver_url: https://rejected-matrix.example.test\n    user_id: "@bot:example.test"\n    room_id: "!room:example.test"\n',
      'connector.matrix',
      'https://rejected-matrix.example.test',
    ],
    [
      'matrix connector with discord settings',
      'connector:\n  kind: matrix\n  matrix:\n    homeserver_url: https://matrix.example.test\n    user_id: "@bot:example.test"\n    room_id: "!room:example.test"\n  discord:\n    channel_id: rejected-discord-channel\n',
      'connector.discord',
      'rejected-discord-channel',
    ],
  ] as const)(
    'blocks %s to match production v1 connector exclusivity',
    async (_label, connectorSource, expectedPath, rejectedValue) => {
      const preview = await previewV1ToV2Migration({
        source: v1Source.replace(/connector:\n[\s\S]*$/, connectorSource),
        decisions: decisions(),
        observations: observations(),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe('blocked');
      expect(preview.blockers).toContainEqual(
        expect.objectContaining({ code: 'invalid-connector', path: expectedPath }),
      );
      expect(preview).not.toHaveProperty('canonicalRoster');
      expect(preview).not.toHaveProperty('canonicalYaml');
      expect(JSON.stringify(preview.blockers)).not.toContain(rejectedValue);
    },
  );

  it('blocks whitespace-padded connector kinds to match exact production v1 parsing', async () => {
    const rejectedKind = ' discord ';
    const preview = await previewV1ToV2Migration({
      source: v1Source.replace('kind: discord', `kind: "${rejectedKind}"`),
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('blocked');
    expect(preview.blockers).toContainEqual(
      expect.objectContaining({ code: 'invalid-connector', path: 'connector.kind' }),
    );
    expect(preview).not.toHaveProperty('canonicalRoster');
    expect(preview).not.toHaveProperty('canonicalYaml');
    expect(preview.evidence).not.toHaveProperty('candidate');
    expect(JSON.stringify(preview.blockers)).not.toContain(rejectedKind);
  });

  it.each([
    ['leading whitespace', ' mosaic-v1'],
    ['trailing whitespace', 'mosaic-v1 '],
    ['unsupported characters', 'mosaic/v1'],
  ] as const)(
    'blocks a declared root socket with %s before migration decisions can normalize it',
    async (_label, rejectedSocket) => {
      const source = v1Source.replace(
        'socket_name: mosaic-v1',
        `socket_name: ${JSON.stringify(rejectedSocket)}`,
      );
      const rosterPath = join(
        await mkdtemp(join(tmpdir(), 'v1-root-socket-parity-')),
        'roster.yaml',
      );
      temporaryDirectories.push(dirname(rosterPath));
      await writeFile(rosterPath, source);
      await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
        'contains unsupported targeting characters',
      );

      const preview = await previewV1ToV2Migration({
        source,
        decisions: { ...decisions(), socketName: 'mosaic-v1' },
        observations: observations(),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe('blocked');
      expect(preview.blockers).toContainEqual({
        code: 'invalid-v1-targeting-characters',
        path: 'tmux.socket_name',
        detail: 'Fleet tmux socket contains unsupported targeting characters.',
      });
      expect(preview).not.toHaveProperty('canonicalRoster');
      expect(preview).not.toHaveProperty('canonicalYaml');
      expect(preview.evidence).not.toHaveProperty('candidate');
      expect(JSON.stringify(preview.blockers)).not.toContain(rejectedSocket);
    },
  );

  it('blocks an explicit remote socket against the source default before a decision can rename the candidate socket', async () => {
    const rejectedSocket = 'mosaic-v1';
    const source = v1Source.replace('  socket_name: mosaic-v1\n', '');
    const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-root-socket-parity-')), 'roster.yaml');
    temporaryDirectories.push(dirname(rosterPath));
    await writeFile(rosterPath, source);
    await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
      'socket must equal the fleet-wide tmux socket_name',
    );

    const preview = await previewV1ToV2Migration({
      source,
      decisions: { ...decisions(), socketName: rejectedSocket },
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('blocked');
    expect(preview.blockers).toContainEqual({
      code: 'agent-socket-disposition-required',
      path: 'agents[1].socket',
      agent: 'remote0',
      detail:
        'Agent socket must match the canonical v1 fleet socket; independent per-agent sockets are not supported.',
    });
    expect(preview).not.toHaveProperty('canonicalRoster');
    expect(preview).not.toHaveProperty('canonicalYaml');
    expect(preview.evidence).not.toHaveProperty('candidate');
    expect(JSON.stringify(preview.blockers)).not.toContain(rejectedSocket);
  });

  it('uses the production empty default when both the source root and remote agent socket are absent', async () => {
    const source = v1Source
      .replace('  socket_name: mosaic-v1\n', '')
      .replace('    socket: mosaic-v1\n', '');
    const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-root-socket-parity-')), 'roster.yaml');
    temporaryDirectories.push(dirname(rosterPath));
    await writeFile(rosterPath, source);
    await expect(loadFleetRoster(rosterPath)).resolves.toMatchObject({
      tmux: { socketName: '' },
    });

    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('ready');
    expect(preview.blockers).toEqual([]);
    expect(preview.inventoryOnlyAgents).toEqual([
      {
        name: 'remote0',
        fields: ['host', 'ssh'],
        disposition: 'inventory-only',
      },
    ]);
    expect(preview.canonicalRoster?.tmux.socketName).toBe('');
    expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
    expect(preview.evidence.candidate).toBeDefined();
  });

  it('blocks a remote inventory-only agent socket that differs from the root socket', async () => {
    const rejectedSocket = 'remote-fleet';
    const source = v1Source.replace('    socket: mosaic-v1', `    socket: ${rejectedSocket}`);
    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('blocked');
    expect(preview.blockers).toEqual([
      {
        code: 'agent-socket-disposition-required',
        path: 'agents[1].socket',
        agent: 'remote0',
        detail:
          'Agent socket must match the canonical v1 fleet socket; independent per-agent sockets are not supported.',
      },
    ]);
    expect(preview.inventoryOnlyAgents).toEqual([
      {
        name: 'remote0',
        fields: ['host', 'socket', 'ssh'],
        disposition: 'inventory-only',
      },
    ]);
    expect(preview).not.toHaveProperty('canonicalRoster');
    expect(preview).not.toHaveProperty('canonicalYaml');
    expect(preview.evidence).not.toHaveProperty('candidate');
    expect(JSON.stringify(preview.blockers)).not.toContain(rejectedSocket);
  });

  it('keeps a remote agent without an explicit socket inventory-only on the default tmux socket', async () => {
    const source = v1Source
      .replace('  socket_name: mosaic-v1\n', "  socket_name: ''\n")
      .replace('    socket: mosaic-v1\n', '');
    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('ready');
    expect(preview.blockers).toEqual([]);
    expect(preview.inventoryOnlyAgents).toEqual([
      {
        name: 'remote0',
        fields: ['host', 'ssh'],
        disposition: 'inventory-only',
      },
    ]);
    expect(preview.canonicalRoster?.tmux.socketName).toBe('');
    expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
    expect(preview.evidence.candidate).toBeDefined();
  });

  it.each([
    ['host', 'bad host'],
    ['ssh', 'bad user@remote.test'],
    ['socket', 'bad socket'],
    ['host', ' build.example.test'],
    ['ssh', 'operator@build.example.test '],
    ['socket', ' remote-fleet'],
    ['host', ''],
    ['ssh', ''],
    ['socket', ''],
  ] as const)(
    'blocks malformed remote %s targeting before inventory-only exclusion',
    async (field, rejectedValue) => {
      const source = v1Source.replace(
        new RegExp(`(${field}: )[^\\n]+`),
        `$1${JSON.stringify(rejectedValue)}`,
      );
      const productionSource = source;
      const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-targeting-parity-')), 'roster.yaml');
      temporaryDirectories.push(dirname(rosterPath));
      await writeFile(rosterPath, productionSource);
      await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
        'contains unsupported targeting characters',
      );
      const preview = await previewV1ToV2Migration({
        source,
        decisions: decisions(),
        observations: observations(),
        personaDirs: await semanticDirs(),
      });

      expect(preview.status).toBe('blocked');
      expect(preview.blockers).toContainEqual(
        expect.objectContaining({
          code: 'invalid-v1-targeting-characters',
          path: `agents[1].${field}`,
          agent: 'remote0',
        }),
      );
      expect(preview).not.toHaveProperty('canonicalRoster');
      expect(preview).not.toHaveProperty('canonicalYaml');
      expect(preview.evidence).not.toHaveProperty('candidate');
      if (rejectedValue) {
        expect(JSON.stringify(preview.blockers)).not.toContain(rejectedValue);
      }
    },
  );

  it('accepts the production v1 targeting character boundaries for a remote inventory-only agent', async () => {
    const source = v1Source
      .replace('host: build.example.test', 'host: node-1.example.test:[2001:db8::1]')
      .replace(
        'ssh: operator@build.example.test',
        'ssh: user.name-1@node-1.example.test:[2001:db8::1]',
      )
      .replace('socket: mosaic-v1', 'socket: mosaic-v1');
    const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-targeting-parity-')), 'roster.yaml');
    temporaryDirectories.push(dirname(rosterPath));
    await writeFile(rosterPath, source);
    await expect(loadFleetRoster(rosterPath)).resolves.toMatchObject({
      agents: [
        expect.objectContaining({ name: 'coder0' }),
        expect.objectContaining({
          name: 'remote0',
          host: 'node-1.example.test:[2001:db8::1]',
          ssh: 'user.name-1@node-1.example.test:[2001:db8::1]',
          socket: 'mosaic-v1',
        }),
      ],
    });
    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });

    expect(preview.status).toBe('ready');
    expect(preview.blockers).toEqual([]);
    expect(preview.inventoryOnlyAgents).toEqual([
      {
        name: 'remote0',
        fields: ['host', 'socket', 'ssh'],
        disposition: 'inventory-only',
      },
    ]);
    expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
    expect(preview.evidence.excludedInventory).toContain('agents.remote0');
  });

  it('blocks malformed schema-only remote and connector fields', async () => {
    const source = v1Source
      .replace('host: build.example.test', 'host: 42')
      .replace('discord:\n    channel_id: channel', 'discord: {}');
    const preview = await previewV1ToV2Migration({
      source,
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(preview.status).toBe('blocked');
    expect(preview.blockers).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[1].host' }),
        expect.objectContaining({
          code: 'invalid-v1-field-type',
          path: 'connector.discord.channel_id',
        }),
      ]),
    );
    const malformedRoot = await previewV1ToV2Migration({
      source: v1Source.replace(
        'tmux:\n  socket_name: mosaic-v1\n  holder_session: _holder',
        'tmux: []',
      ),
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(malformedRoot.blockers).toContainEqual(
      expect.objectContaining({ code: 'invalid-v1-field-type', path: 'tmux' }),
    );

    const malformedScalars = await previewV1ToV2Migration({
      source: v1Source
        .replace('alias: Coder Zero', 'alias: 42')
        .replace('reset_between_tasks: false', 'reset_between_tasks: "false"')
        .replace(
          '    runtime: pi\n    class: reviewer\n    host:',
          '    runtime: ~\n    class: reviewer\n    host:',
        ),
      decisions: decisions(),
      observations: observations(),
      personaDirs: await semanticDirs(),
    });
    expect(malformedScalars.blockers).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[0].alias' }),
        expect.objectContaining({
          code: 'invalid-v1-field-type',
          path: 'agents[0].reset_between_tasks',
        }),
        expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[1].runtime' }),
      ]),
    );
  });

  it('validates all 13 shipped artifacts and executes ready previews for every v1 fixture', async () => {
    const dispositions = await validateShippedFleetMigrationDispositions({
      frameworkFleet,
      v1Previews: await shippedV1PreviewEvidence(),
    });
    expect(dispositions).toHaveLength(13);
    expect(dispositions.map(({ path }) => path)).toEqual(
      SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }) => path),
    );
    await expect(
      validateShippedFleetMigrationDispositions({ frameworkFleet, v1Previews: {} }),
    ).rejects.toThrow('Missing explicit migration evidence');
  });

  it('emits an explicit M4 disposition for every shipped M1 inventory entry', () => {
    const dispositions = collectShippedFleetMigrationDispositions();
    expect(dispositions.map(({ path }) => path)).toEqual(
      SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }) => path),
    );
    expect(dispositions).toHaveLength(13);
    expect(dispositions.every(({ migrationDisposition }) => migrationDisposition.length > 0)).toBe(
      true,
    );
  });
});
