import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import YAML from 'yaml';
import { canonicalizeRoleClass, type PersonaDirs } from '../commands/fleet-personas.js';
import { compareCodePoints } from './deterministic-order.js';
import {
  previewAgentEnvironmentProjection,
  type AgentEnvironmentDiagnostic,
} from './generated-env-boundary.js';
import {
  SHIPPED_FLEET_ARTIFACT_DISPOSITIONS,
  validateShippedFleetArtifactDispositions,
  type FleetArtifactDisposition,
  type ValidateShippedFleetArtifactDispositionsOptions,
} from './example-profile-dispositions.js';
import {
  parseRosterV2,
  renderRosterV2Yaml,
  validateRosterV2Semantics,
  type FleetRosterV2,
  type RosterV2DesiredState,
  type RosterV2ReasoningLevel,
  type RosterV2RuntimeName,
} from './roster-v2.js';

const AUTOMATIC_CLASSES = new Set([
  'code',
  'review',
  'interaction',
  'merge-gate',
  'validator',
  'orchestrator',
  'team-leader',
  'enhancer',
]);
const V1_RUNTIME_RESETS: Readonly<Record<RosterV2RuntimeName, string>> = Object.freeze({
  claude: '/clear',
  codex: '/clear',
  opencode: '/clear',
  pi: '/new',
});
const REMOTE_AGENT_INVENTORY_FIELDS = ['host', 'socket', 'ssh'] as const;
const ROOT_FIELDS = new Set([
  'version',
  'transport',
  'tmux',
  'defaults',
  'runtimes',
  'agents',
  'connector',
]);
const TMUX_FIELDS = new Set(['socket_name', 'socketName', 'holder_session', 'holderSession']);
const DEFAULT_FIELDS = new Set(['working_directory', 'workingDirectory']);
const RUNTIME_FIELDS = new Set(['reset_command', 'resetCommand']);
const DECISION_ROOT_FIELDS = new Set([
  'generation',
  'fleetHost',
  'socketName',
  'defaultRuntime',
  'agents',
]);
const AGENT_DECISION_FIELDS = new Set([
  'provider',
  'model',
  'reasoning',
  'enabled',
  'launchYolo',
  'classDisposition',
  'toolPolicyDisposition',
  'kickstartTemplate',
  'persistentPersona',
]);
const CLASS_DISPOSITION_FIELDS = new Set(['action', 'className']);
const OBSERVATION_FIELDS = new Set(['systemd', 'tmux']);
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
const V1_TMUX_SOCKET_PATTERN = /^[A-Za-z0-9_.-]+$/;
const V1_REMOTE_TARGET_PATTERNS: Readonly<
  Record<(typeof REMOTE_AGENT_INVENTORY_FIELDS)[number], RegExp>
> = Object.freeze({
  host: /^[A-Za-z0-9_.:[\]-]+$/,
  ssh: /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
  socket: V1_TMUX_SOCKET_PATTERN,
});
const AGENT_FIELDS = new Set([
  'name',
  'alias',
  'provider',
  'runtime',
  'class',
  'host',
  'ssh',
  'socket',
  'working_directory',
  'workingDirectory',
  'model_hint',
  'modelHint',
  'reasoning_level',
  'reasoningLevel',
  'tool_policy',
  'toolPolicy',
  'persistent_persona',
  'persistentPersona',
  'reset_between_tasks',
  'resetBetweenTasks',
  'kickstart_template',
  'kickstartTemplate',
]);

export type MigrationObservationSystemd = 'active' | 'inactive' | 'unknown';
export type MigrationObservationTmux = 'present' | 'missing';

export interface V1MigrationObservation {
  readonly systemd: MigrationObservationSystemd;
  readonly tmux: MigrationObservationTmux;
}

export interface V1ClassDisposition {
  readonly action: 'preserve' | 'replace';
  readonly className?: string;
}

export interface V1AgentMigrationDecision {
  readonly provider: string;
  readonly model: string;
  readonly reasoning: RosterV2ReasoningLevel;
  readonly enabled: boolean;
  readonly launchYolo: boolean;
  readonly classDisposition?: V1ClassDisposition;
  readonly toolPolicyDisposition?: V1ClassDisposition;
  readonly kickstartTemplate?: 'inventory-only';
  readonly persistentPersona?: boolean;
}

export interface V1ToV2MigrationDecisions {
  readonly generation: number;
  readonly fleetHost?: string;
  readonly socketName?: string;
  readonly defaultRuntime?: RosterV2RuntimeName;
  readonly agents: Record<string, V1AgentMigrationDecision>;
}

export interface V1ToV2MigrationPreviewOptions {
  readonly source: string;
  readonly sourcePath?: string;
  readonly decisions: V1ToV2MigrationDecisions;
  readonly observations: Readonly<Record<string, V1MigrationObservation>>;
  readonly personaDirs?: PersonaDirs;
  readonly environment?: {
    readonly mosaicHome: string;
    readonly agentEnvDir: string;
  };
}

export interface MigrationInventoryEntry {
  readonly path: string;
  readonly disposition: 'candidate' | 'inventory-only' | 'requires-decision';
}

export interface MigrationBlocker {
  readonly code: string;
  readonly path: string;
  readonly agent?: string;
  readonly detail: string;
}

export interface InventoryOnlyAgent {
  readonly name: string;
  readonly fields: readonly string[];
  readonly disposition: 'inventory-only';
}

export interface EnvironmentMigrationPreview {
  readonly agent: string;
  readonly generated: 'rebuild';
  readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
  readonly relocatedKeys: readonly string[];
  readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}

export interface V1ToV2MigrationEvidence {
  readonly source: { readonly path?: string; readonly sha256: string };
  readonly candidate?: { readonly sha256: string; readonly generation: number };
  readonly observations: readonly {
    readonly agent: string;
    readonly systemd: MigrationObservationSystemd;
    readonly tmux: MigrationObservationTmux;
    readonly preservedAs: RosterV2DesiredState;
  }[];
  readonly excludedInventory: readonly string[];
  readonly recovery: {
    readonly executable: false;
    readonly canaryExecuted: false;
    readonly rollbackExecuted: false;
    readonly requiredArtifacts: readonly string[];
    readonly gateOwner: 'FCM-M4-002';
  };
}

export interface V1ToV2MigrationPreview {
  readonly status: 'ready' | 'blocked';
  readonly inventory: readonly MigrationInventoryEntry[];
  readonly inventoryOnlyAgents: readonly InventoryOnlyAgent[];
  readonly blockers: readonly MigrationBlocker[];
  readonly canonicalRoster?: FleetRosterV2;
  readonly canonicalYaml?: string;
  readonly environment: readonly EnvironmentMigrationPreview[];
  readonly evidence: V1ToV2MigrationEvidence;
}

export interface ShippedFleetMigrationDisposition {
  readonly path: string;
  readonly currentDisposition: FleetArtifactDisposition;
  readonly migrationDisposition: string;
}

export interface ShippedV1MigrationPreviewEvidence {
  readonly decisions: V1ToV2MigrationDecisions;
  readonly observations: Readonly<Record<string, V1MigrationObservation>>;
}

export interface ValidateShippedFleetMigrationDispositionsOptions extends ValidateShippedFleetArtifactDispositionsOptions {
  readonly v1Previews: Readonly<Record<string, ShippedV1MigrationPreviewEvidence>>;
}

/** Strictly validates the untrusted JSON decision document before migration preview. */
export function parseV1ToV2MigrationDecisions(value: unknown): V1ToV2MigrationDecisions {
  const root = requireRecord(value, 'decisions');
  assertOnlyKeys(root, DECISION_ROOT_FIELDS, 'decisions');
  const generation = root.generation;
  if (!Number.isSafeInteger(generation) || (generation as number) <= 0) {
    throw new Error('decisions.generation must be a positive safe integer.');
  }
  const defaultRuntime = root.defaultRuntime;
  if (defaultRuntime !== undefined && !isRuntime(stringValue(defaultRuntime))) {
    throw new Error('decisions.defaultRuntime must be a supported runtime.');
  }
  const fleetHost = optionalNonEmptyString(root.fleetHost, 'decisions.fleetHost');
  const socketName = optionalNonEmptyString(root.socketName, 'decisions.socketName');
  const rawAgents = requireRecord(root.agents, 'decisions.agents');
  const agents: Record<string, V1AgentMigrationDecision> = {};
  for (const [name, rawDecision] of Object.entries(rawAgents)) {
    if (!AGENT_NAME_PATTERN.test(name)) {
      throw new Error('decisions.agents contains an invalid agent identifier.');
    }
    const decision = requireRecord(rawDecision, `decisions.agents.${name}`);
    assertOnlyKeys(decision, AGENT_DECISION_FIELDS, `decisions.agents.${name}`);
    const provider = requiredNonEmptyString(decision.provider, `decisions.agents.${name}.provider`);
    const model = requiredNonEmptyString(decision.model, `decisions.agents.${name}.model`);
    const reasoning = decision.reasoning;
    if (reasoning !== 'low' && reasoning !== 'medium' && reasoning !== 'high') {
      throw new Error(`decisions.agents.${name}.reasoning must be low, medium, or high.`);
    }
    if (typeof decision.enabled !== 'boolean') {
      throw new Error(`decisions.agents.${name}.enabled must be boolean.`);
    }
    if (typeof decision.launchYolo !== 'boolean') {
      throw new Error(`decisions.agents.${name}.launchYolo must be boolean.`);
    }
    const kickstartTemplate = decision.kickstartTemplate;
    if (kickstartTemplate !== undefined && kickstartTemplate !== 'inventory-only') {
      throw new Error(
        `decisions.agents.${name}.kickstartTemplate must be inventory-only when present.`,
      );
    }
    if (
      decision.persistentPersona !== undefined &&
      typeof decision.persistentPersona !== 'boolean'
    ) {
      throw new Error(`decisions.agents.${name}.persistentPersona must be boolean when present.`);
    }
    const classDisposition = parseClassDisposition(
      decision.classDisposition,
      `decisions.agents.${name}.classDisposition`,
    );
    const toolPolicyDisposition = parseClassDisposition(
      decision.toolPolicyDisposition,
      `decisions.agents.${name}.toolPolicyDisposition`,
    );
    agents[name] = {
      provider,
      model,
      reasoning,
      enabled: decision.enabled,
      launchYolo: decision.launchYolo,
      ...(classDisposition ? { classDisposition } : {}),
      ...(toolPolicyDisposition ? { toolPolicyDisposition } : {}),
      ...(kickstartTemplate ? { kickstartTemplate } : {}),
      ...(typeof decision.persistentPersona === 'boolean'
        ? { persistentPersona: decision.persistentPersona }
        : {}),
    };
  }
  const normalizedDefaultRuntime = stringValue(defaultRuntime);
  return {
    generation: generation as number,
    ...(fleetHost ? { fleetHost } : {}),
    ...(socketName ? { socketName } : {}),
    ...(isRuntime(normalizedDefaultRuntime) ? { defaultRuntime: normalizedDefaultRuntime } : {}),
    agents,
  };
}

/** Strictly validates the untrusted JSON lifecycle-observation document. */
export function parseV1MigrationObservations(
  value: unknown,
): Readonly<Record<string, V1MigrationObservation>> {
  const root = requireRecord(value, 'observations');
  const observations: Record<string, V1MigrationObservation> = {};
  for (const [name, rawObservation] of Object.entries(root)) {
    if (!AGENT_NAME_PATTERN.test(name)) {
      throw new Error('observations contains an invalid agent identifier.');
    }
    const observation = requireRecord(rawObservation, `observations.${name}`);
    assertOnlyKeys(observation, OBSERVATION_FIELDS, `observations.${name}`);
    const systemd = observation.systemd;
    const tmux = observation.tmux;
    if (systemd !== 'active' && systemd !== 'inactive' && systemd !== 'unknown') {
      throw new Error(`observations.${name}.systemd has an unsupported state.`);
    }
    if (tmux !== 'present' && tmux !== 'missing') {
      throw new Error(`observations.${name}.tmux has an unsupported state.`);
    }
    observations[name] = { systemd, tmux };
  }
  return observations;
}

/**
 * Builds a complete, non-mutating migration preview. It never runs systemd,
 * tmux, a connector, or the reconciler, and it never writes prepared files.
 */
export async function previewV1ToV2Migration(
  options: V1ToV2MigrationPreviewOptions,
): Promise<V1ToV2MigrationPreview> {
  const decisions = parseV1ToV2MigrationDecisions(options.decisions);
  const reviewedObservations = parseV1MigrationObservations(options.observations);
  const raw = parseV1Source(options.source);
  const inventory = inventorySource(raw, decisions.fleetHost);
  const blockers: MigrationBlocker[] = [];
  const inventoryOnlyAgents: InventoryOnlyAgent[] = [];
  const observations: Array<{
    agent: string;
    systemd: MigrationObservationSystemd;
    tmux: MigrationObservationTmux;
    preservedAs: RosterV2DesiredState;
  }> = [];

  assertV1Root(raw, blockers);
  validateV1FieldCatalog(raw, blockers);
  blockPresentEmptyV1Fields(raw, decisions.fleetHost, blockers);
  blockLossyV1StringNormalization(raw, decisions.fleetHost, blockers);
  const tmux = record(raw.tmux);
  const defaults = record(raw.defaults);
  const runtimes = normalizeRuntimes(record(raw.runtimes), blockers);
  const hasDeclaredSnakeSocket = Object.hasOwn(tmux, 'socket_name');
  const hasDeclaredCamelSocket = Object.hasOwn(tmux, 'socketName');
  const hasDeclaredSocket = hasDeclaredSnakeSocket || hasDeclaredCamelSocket;
  const declaredSocketValue = hasDeclaredSnakeSocket ? tmux.socket_name : tmux.socketName;
  const sourceSocketName = typeof declaredSocketValue === 'string' ? declaredSocketValue : '';
  const declaredSocketPath = hasDeclaredSnakeSocket ? 'tmux.socket_name' : 'tmux.socketName';
  if (sourceSocketName && !V1_TMUX_SOCKET_PATTERN.test(sourceSocketName)) {
    blockers.push(
      blocker(
        'invalid-v1-targeting-characters',
        declaredSocketPath,
        'Fleet tmux socket contains unsupported targeting characters.',
      ),
    );
  }
  const socketName = sourceSocketName;
  if (
    hasDeclaredSocket &&
    decisions.socketName !== undefined &&
    decisions.socketName !== sourceSocketName
  ) {
    blockers.push(
      blocker(
        'socket-name-conflict',
        'decisions.socketName',
        'Decision socket must match the socket declared by the v1 roster.',
      ),
    );
  }
  const rawAgents = Array.isArray(raw.agents) ? raw.agents : [];
  const candidateAgents: Record<string, unknown>[] = [];

  for (let index = 0; index < rawAgents.length; index += 1) {
    const rawAgent = record(rawAgents[index]);
    const name = exactStringValue(rawAgent.name);
    const path = `agents[${index}]`;
    if (!name) {
      blockers.push(blocker('invalid-agent-name', `${path}.name`, 'Agent name is required.'));
      continue;
    }
    const agentSocket = typeof rawAgent.socket === 'string' ? rawAgent.socket : undefined;
    if (agentSocket !== undefined && agentSocket !== socketName) {
      blockers.push(
        blocker(
          'agent-socket-disposition-required',
          `${path}.socket`,
          'Agent socket must match the canonical v1 fleet socket; independent per-agent sockets are not supported.',
          name,
        ),
      );
    }
    const locality = classifyAgentLocality(rawAgent, decisions.fleetHost);
    const localityFields = REMOTE_AGENT_INVENTORY_FIELDS.filter(
      (field) => rawAgent[field] !== undefined,
    );
    if (locality === 'remote') {
      inventoryOnlyAgents.push({
        name,
        fields: [...localityFields].sort(compareCodePoints),
        disposition: 'inventory-only',
      });
      continue;
    }
    if (locality === 'ambiguous') {
      blockers.push(
        blocker(
          'agent-locality-disposition-required',
          `${path}.host`,
          'Agent locality is ambiguous or contradicts the reviewed fleet-host identity.',
          name,
        ),
      );
    }

    const decision = decisions.agents[name];
    if (!decision) {
      blockers.push(
        blocker(
          'agent-disposition-required',
          path,
          'Local agent requires explicit migration decisions.',
          name,
        ),
      );
      continue;
    }

    const runtime = exactStringValue(rawAgent.runtime);
    if (!isRuntime(runtime)) {
      blockers.push(
        blocker('unsupported-runtime', `${path}.runtime`, 'Runtime must be supported.', name),
      );
    }
    const requestedClass = exactStringValue(rawAgent.class) ?? 'worker';
    const className = resolveClass(
      requestedClass,
      decision.classDisposition,
      `${path}.class`,
      name,
      blockers,
    );
    const requestedToolPolicy =
      exactStringValue(rawAgent.tool_policy) ?? exactStringValue(rawAgent.toolPolicy);
    const toolPolicy = resolveToolPolicy(
      requestedToolPolicy,
      decision.toolPolicyDisposition,
      `${path}.tool_policy`,
      name,
      blockers,
    );
    const observation = reviewedObservations[name];
    const desiredState = resolveLifecycle(observation, path, name, blockers);
    if (desiredState && observation) {
      if (desiredState === 'running' && !decision.enabled) {
        blockers.push(
          blocker(
            'disabled-running-observation',
            `${path}.lifecycle`,
            'Observed-running agent cannot migrate as disabled.',
            name,
          ),
        );
      }
      observations.push({ agent: name, ...observation, preservedAs: desiredState });
    }
    const persistentRaw = rawAgent.persistent_persona ?? rawAgent.persistentPersona;
    let persistentPersona: boolean;
    if (typeof persistentRaw === 'boolean') persistentPersona = persistentRaw;
    else if (persistentRaw === undefined) persistentPersona = decision.persistentPersona ?? false;
    else if (decision.persistentPersona !== undefined)
      persistentPersona = decision.persistentPersona;
    else {
      blockers.push(
        blocker(
          'persistent-persona-disposition-required',
          `${path}.persistent_persona`,
          'String persistent_persona requires an explicit boolean disposition.',
          name,
        ),
      );
      persistentPersona = false;
    }
    if (
      (rawAgent.kickstart_template !== undefined || rawAgent.kickstartTemplate !== undefined) &&
      decision.kickstartTemplate !== 'inventory-only'
    ) {
      blockers.push(
        blocker(
          'kickstart-template-disposition-required',
          `${path}.kickstart_template`,
          'kickstart_template has no v2 field and must be explicitly retained as inventory-only.',
          name,
        ),
      );
    }

    if (!runtime || !className || !toolPolicy || !desiredState) continue;
    candidateAgents.push({
      name,
      alias: exactStringValue(rawAgent.alias) ?? name,
      class: className,
      runtime,
      provider: decision.provider,
      model: decision.model,
      reasoning: decision.reasoning,
      tool_policy: toolPolicy,
      working_directory:
        exactStringValue(rawAgent.working_directory) ??
        exactStringValue(rawAgent.workingDirectory) ??
        exactStringValue(defaults.working_directory) ??
        exactStringValue(defaults.workingDirectory) ??
        '~/src',
      persistent_persona: persistentPersona,
      reset_between_tasks:
        booleanValue(rawAgent.reset_between_tasks) ??
        booleanValue(rawAgent.resetBetweenTasks) ??
        false,
      lifecycle: { enabled: decision.enabled, desired_state: desiredState },
      launch: { yolo: decision.launchYolo },
    });
  }

  const localAgentNames = new Set(
    rawAgents
      .map((candidate) => record(candidate))
      .filter((candidate) => classifyAgentLocality(candidate, decisions.fleetHost) !== 'remote')
      .map((candidate) => exactStringValue(candidate.name))
      .filter((name): name is string => name !== undefined),
  );
  for (const decisionName of Object.keys(decisions.agents)) {
    if (!localAgentNames.has(decisionName)) {
      blockers.push(
        blocker(
          'extra-agent-decision',
          `decisions.agents.${decisionName}`,
          'Decision does not correspond to a local migration candidate.',
          decisionName,
        ),
      );
    }
  }
  for (const observedName of Object.keys(reviewedObservations)) {
    if (!localAgentNames.has(observedName)) {
      blockers.push(
        blocker(
          'extra-lifecycle-observation',
          `observations.${observedName}`,
          'Observation does not correspond to a local migration candidate.',
          observedName,
        ),
      );
    }
  }

  if (!decisions.defaultRuntime) {
    blockers.push(
      blocker(
        'default-runtime-disposition-required',
        'defaults.runtime',
        'v1 has no default runtime; an explicit v2 default is required.',
      ),
    );
  }
  if (candidateAgents.length === 0) {
    blockers.push(
      blocker('no-local-candidates', 'agents', 'No local agents are eligible for v2 output.'),
    );
  }

  let canonicalRoster: FleetRosterV2 | undefined;
  let canonicalYaml: string | undefined;
  const environment: EnvironmentMigrationPreview[] = [];

  if (blockers.length === 0 && decisions.defaultRuntime) {
    const defaultRuntime = decisions.defaultRuntime;
    try {
      canonicalRoster = parseRosterV2(
        YAML.stringify({
          version: 2,
          generation: decisions.generation,
          transport: 'tmux',
          tmux: {
            socket_name: socketName,
            holder_session:
              exactStringValue(tmux.holder_session) ??
              exactStringValue(tmux.holderSession) ??
              '_holder',
          },
          defaults: {
            working_directory:
              exactStringValue(defaults.working_directory) ??
              exactStringValue(defaults.workingDirectory) ??
              '~/src',
            runtime: defaultRuntime,
          },
          runtimes,
          agents: candidateAgents,
        }),
        'yaml',
      );
      canonicalYaml = renderRosterV2Yaml(canonicalRoster);
      await validateRosterV2Semantics(canonicalRoster, options.personaDirs);
      if (options.environment) {
        for (const agent of canonicalRoster.agents) {
          try {
            const prepared = await previewAgentEnvironmentProjection({
              ...options.environment,
              agentName: agent.name,
              generated: generatedValues(canonicalRoster, agent),
            });
            environment.push({
              agent: agent.name,
              generated: 'rebuild',
              legacy: prepared.legacy,
              relocatedKeys: prepared.relocatedKeys,
              diagnostics: prepared.diagnostics,
            });
          } catch (error: unknown) {
            const diagnostic = safeEnvironmentDiagnostic(error);
            blockers.push({
              code: 'environment-preflight-failed',
              path: `agents.${agent.name}.environment`,
              agent: agent.name,
              detail: diagnostic,
            });
          }
        }
      }
    } catch {
      blockers.push(
        blocker(
          'v2-compilation-failed',
          'candidate',
          'Candidate failed canonical v2 compilation or semantic validation.',
        ),
      );
      canonicalRoster = undefined;
      canonicalYaml = undefined;
    }
  }

  const canonicalInventoryOnlyAgents = [...inventoryOnlyAgents].sort((left, right): number =>
    compareCodePoints(left.name, right.name),
  );
  const status = blockers.length === 0 ? 'ready' : 'blocked';
  return {
    status,
    inventory,
    inventoryOnlyAgents: canonicalInventoryOnlyAgents,
    blockers,
    ...(status === 'ready' && canonicalRoster && canonicalYaml
      ? { canonicalRoster, canonicalYaml }
      : {}),
    environment,
    evidence: {
      source: {
        ...(options.sourcePath ? { path: options.sourcePath } : {}),
        sha256: sha256(options.source),
      },
      ...(status === 'ready' && canonicalYaml
        ? { candidate: { sha256: sha256(canonicalYaml), generation: decisions.generation } }
        : {}),
      observations: [...observations].sort((left, right): number =>
        compareCodePoints(left.agent, right.agent),
      ),
      excludedInventory: [
        ...(raw.connector === undefined ? [] : ['connector']),
        ...canonicalInventoryOnlyAgents.map(({ name }): string => `agents.${name}`),
      ],
      recovery: {
        executable: false,
        canaryExecuted: false,
        rollbackExecuted: false,
        requiredArtifacts: [
          'v1-roster-backup',
          'environment-backup',
          'observed-lifecycle-evidence',
          'candidate-v2-roster',
        ],
        gateOwner: 'FCM-M4-002',
      },
    },
  };
}

/**
 * Runs the existing executable M1 guard before emitting the exact M4 migration posture.
 * New, removed, or invalid shipped artifacts therefore fail closed through one inventory owner.
 */
export async function validateShippedFleetMigrationDispositions(
  options: ValidateShippedFleetMigrationDispositionsOptions,
): Promise<readonly ShippedFleetMigrationDisposition[]> {
  await validateShippedFleetArtifactDispositions(options);
  const v1Fixtures = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
    ({ disposition }): boolean => disposition === 'v1-fixture',
  );
  const expected = new Set(v1Fixtures.map(({ path }): string => path));
  for (const supplied of Object.keys(options.v1Previews)) {
    if (!expected.has(supplied)) {
      throw new Error('Migration evidence was supplied for an unknown v1 fixture.');
    }
  }
  for (const artifact of v1Fixtures) {
    const evidence = options.v1Previews[artifact.path];
    if (!evidence) throw new Error(`Missing explicit migration evidence for ${artifact.path}.`);
    const source = await readFile(join(options.frameworkFleet, artifact.path), 'utf8');
    const preview = await previewV1ToV2Migration({
      source,
      sourcePath: artifact.path,
      decisions: evidence.decisions,
      observations: evidence.observations,
      personaDirs: {
        rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'),
        overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'),
      },
    });
    if (preview.status !== 'ready') {
      throw new Error(
        `Shipped v1 fixture migration preview is blocked: ${artifact.path} (${preview.blockers
          .map(({ code, path }) => `${code}:${path}`)
          .join(', ')}).`,
      );
    }
  }
  return collectShippedFleetMigrationDispositions();
}

/** Adds M4 migration posture to the exact executable M1 artifact inventory. */
export function collectShippedFleetMigrationDispositions(): readonly ShippedFleetMigrationDisposition[] {
  return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map((artifact) => ({
    path: artifact.path,
    currentDisposition: artifact.disposition,
    migrationDisposition:
      artifact.disposition === 'v1-fixture'
        ? 'preview-with-explicit-class-and-lifecycle-evidence'
        : artifact.disposition === 'canonical-profile'
          ? 'retain-through-shared-persona-resolver'
          : 'retain-generic-policy-with-approved-tool-policy-alias',
  }));
}

function parseV1Source(source: string): Record<string, unknown> {
  let parsed: unknown;
  try {
    parsed = source.trimStart().startsWith('{') ? JSON.parse(source) : YAML.parse(source);
  } catch (error: unknown) {
    throw new Error(
      `V1 roster parse failed: ${error instanceof Error ? error.message : 'unknown parse error'}`,
    );
  }
  return record(parsed);
}

type AgentLocality = 'local' | 'remote' | 'ambiguous';

function classifyAgentLocality(
  agent: Record<string, unknown>,
  fleetHost: string | undefined,
): AgentLocality {
  const host = exactStringValue(agent.host);
  const ssh = exactStringValue(agent.ssh);
  const hasHost = agent.host !== undefined;
  const hasSsh = agent.ssh !== undefined;
  if (!hasHost && !hasSsh) return 'local';
  if (!host || !fleetHost) return 'ambiguous';
  if (hasSsh) {
    if (!ssh) return 'ambiguous';
    const separator = ssh.lastIndexOf('@');
    const sshHost = separator >= 0 ? ssh.slice(separator + 1) : ssh;
    if (sshHost !== host) return 'ambiguous';
  }
  return host === fleetHost ? 'local' : 'remote';
}

function inventorySource(
  value: Record<string, unknown>,
  fleetHost: string | undefined,
): readonly MigrationInventoryEntry[] {
  const paths: MigrationInventoryEntry[] = [];
  walkInventory(value, '', paths);
  const remoteAgentPrefixes = (Array.isArray(value.agents) ? value.agents : [])
    .map((agent, index): string | undefined =>
      classifyAgentLocality(record(agent), fleetHost) === 'remote' ? `agents[${index}]` : undefined,
    )
    .filter((prefix): prefix is string => prefix !== undefined);
  return paths
    .map(
      (entry): MigrationInventoryEntry =>
        remoteAgentPrefixes.some(
          (prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}.`),
        )
          ? { ...entry, disposition: 'inventory-only' }
          : entry,
    )
    .sort((left, right) => compareCodePoints(left.path, right.path));
}

function walkInventory(value: unknown, path: string, output: MigrationInventoryEntry[]): void {
  if (Array.isArray(value)) {
    value.forEach((entry, index) => walkInventory(entry, `${path}[${index}]`, output));
    return;
  }
  if (typeof value === 'object' && value !== null) {
    for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
      const entryPath = path ? `${path}.${key}` : key;
      output.push(inventoryEntry(entryPath));
      if (typeof entry === 'object' && entry !== null) walkInventory(entry, entryPath, output);
    }
  }
}

function inventoryEntry(path: string): MigrationInventoryEntry {
  const inventoryOnly = path === 'connector' || path.startsWith('connector.');
  const requiresDecision =
    path.endsWith('.kickstart_template') || path.endsWith('.kickstartTemplate');
  return {
    path,
    disposition: inventoryOnly
      ? 'inventory-only'
      : requiresDecision
        ? 'requires-decision'
        : 'candidate',
  };
}

function blockPresentEmptyV1Fields(
  raw: Record<string, unknown>,
  fleetHost: string | undefined,
  blockers: MigrationBlocker[],
): void {
  const tmux = record(raw.tmux);
  blockPresentEmptyField(tmux, ['holder_session', 'holderSession'], 'tmux', blockers);
  const defaults = record(raw.defaults);
  blockPresentEmptyField(defaults, ['working_directory', 'workingDirectory'], 'defaults', blockers);
  for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) {
    blockPresentEmptyField(
      record(runtimeValue),
      ['reset_command', 'resetCommand'],
      `runtimes.${runtimeName}`,
      blockers,
    );
  }
  for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) {
    const agent = record(agentValue);
    if (classifyAgentLocality(agent, fleetHost) === 'remote') continue;
    blockPresentEmptyField(
      agent,
      ['alias', 'class', 'tool_policy', 'toolPolicy'],
      `agents[${index}]`,
      blockers,
    );
    blockPresentEmptyField(
      record(agentValue),
      ['working_directory', 'workingDirectory'],
      `agents[${index}]`,
      blockers,
    );
  }
}

function blockPresentEmptyField(
  value: Record<string, unknown>,
  aliases: readonly string[],
  path: string,
  blockers: MigrationBlocker[],
): void {
  for (const field of aliases) {
    if (Object.hasOwn(value, field) && trimmedString(value[field]) === '') {
      blockers.push(
        blocker(
          'empty-v1-field-disposition-required',
          `${path}.${field}`,
          'An explicit empty v1 value cannot be silently replaced by a migration default.',
        ),
      );
    }
  }
}

function blockLossyV1StringNormalization(
  raw: Record<string, unknown>,
  fleetHost: string | undefined,
  blockers: MigrationBlocker[],
): void {
  const tmux = record(raw.tmux);
  blockPaddedV1Field(tmux, ['holder_session', 'holderSession'], 'tmux', blockers);
  const defaults = record(raw.defaults);
  blockPaddedV1Field(defaults, ['working_directory', 'workingDirectory'], 'defaults', blockers);
  for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) {
    blockPaddedV1Field(
      record(runtimeValue),
      ['reset_command', 'resetCommand'],
      `runtimes.${runtimeName}`,
      blockers,
    );
  }
  for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) {
    const agent = record(agentValue);
    if (classifyAgentLocality(agent, fleetHost) === 'remote') continue;
    blockPaddedV1Field(
      agent,
      ['alias', 'tool_policy', 'toolPolicy', 'working_directory', 'workingDirectory'],
      `agents[${index}]`,
      blockers,
    );
  }
}

function blockPaddedV1Field(
  value: Record<string, unknown>,
  fields: readonly string[],
  path: string,
  blockers: MigrationBlocker[],
): void {
  for (const field of fields) {
    const source = exactStringValue(value[field]);
    if (source !== undefined && source !== source.trim()) {
      blockers.push(
        blocker(
          'lossy-v1-string-normalization',
          `${path}.${field}`,
          'A whitespace-padded v1 value cannot be silently normalized by the v2 compiler.',
        ),
      );
    }
  }
}

function validateV1FieldCatalog(raw: Record<string, unknown>, blockers: MigrationBlocker[]): void {
  rejectUnknownKeys(raw, ROOT_FIELDS, 'root', blockers);
  requireObjectWhenPresent(raw.tmux, 'tmux', blockers);
  requireObjectWhenPresent(raw.defaults, 'defaults', blockers);
  requireObjectWhenPresent(raw.runtimes, 'runtimes', blockers);
  const tmux = record(raw.tmux);
  rejectUnknownKeys(tmux, TMUX_FIELDS, 'tmux', blockers);
  rejectOptionalStringFields(tmux, [...TMUX_FIELDS], 'tmux', blockers);
  rejectSynonymCollision(tmux, 'socket_name', 'socketName', 'tmux', blockers);
  rejectSynonymCollision(tmux, 'holder_session', 'holderSession', 'tmux', blockers);
  const defaults = record(raw.defaults);
  rejectUnknownKeys(defaults, DEFAULT_FIELDS, 'defaults', blockers);
  rejectOptionalStringFields(defaults, [...DEFAULT_FIELDS], 'defaults', blockers, true);
  rejectSynonymCollision(defaults, 'working_directory', 'workingDirectory', 'defaults', blockers);
  for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) {
    requireObjectWhenPresent(runtimeValue, `runtimes.${runtimeName}`, blockers);
    const runtime = record(runtimeValue);
    rejectUnknownKeys(runtime, RUNTIME_FIELDS, `runtimes.${runtimeName}`, blockers);
    rejectOptionalStringFields(runtime, [...RUNTIME_FIELDS], `runtimes.${runtimeName}`, blockers);
    rejectSynonymCollision(
      runtime,
      'reset_command',
      'resetCommand',
      `runtimes.${runtimeName}`,
      blockers,
    );
  }
  const names = new Set<string>();
  for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) {
    requireObjectWhenPresent(agentValue, `agents[${index}]`, blockers);
    const agent = record(agentValue);
    const path = `agents[${index}]`;
    rejectUnknownKeys(agent, AGENT_FIELDS, path, blockers);
    rejectOptionalStringFields(
      agent,
      [
        'name',
        'alias',
        'provider',
        'runtime',
        'class',
        'working_directory',
        'workingDirectory',
        'model_hint',
        'modelHint',
        'reasoning_level',
        'reasoningLevel',
        'tool_policy',
        'toolPolicy',
        'kickstart_template',
        'kickstartTemplate',
      ],
      path,
      blockers,
    );
    const name = exactStringValue(agent.name);
    if (!name || !AGENT_NAME_PATTERN.test(name)) {
      blockers.push(
        blocker('invalid-v1-field-type', `${path}.name`, 'A valid agent name is required.'),
      );
    }
    if (exactStringValue(agent.runtime) === undefined) {
      blockers.push(
        blocker('invalid-v1-field-type', `${path}.runtime`, 'Agent runtime is required.'),
      );
    }
    const resetBetweenTasks = agent.reset_between_tasks ?? agent.resetBetweenTasks;
    if (resetBetweenTasks !== undefined && typeof resetBetweenTasks !== 'boolean') {
      blockers.push(
        blocker(
          'invalid-v1-field-type',
          `${path}.reset_between_tasks`,
          'reset_between_tasks must be boolean when present.',
        ),
      );
    }
    const persistentPersona = agent.persistent_persona ?? agent.persistentPersona;
    if (
      persistentPersona !== undefined &&
      typeof persistentPersona !== 'boolean' &&
      typeof persistentPersona !== 'string'
    ) {
      blockers.push(
        blocker(
          'invalid-v1-field-type',
          `${path}.persistent_persona`,
          'persistent_persona must be boolean or string when present.',
        ),
      );
    }
    for (const key of REMOTE_AGENT_INVENTORY_FIELDS) {
      const target = agent[key];
      if (target !== undefined && typeof target !== 'string') {
        blockers.push(
          blocker(
            'invalid-v1-field-type',
            `${path}.${key}`,
            'Remote inventory field must be a string.',
          ),
        );
      } else if (typeof target === 'string' && !V1_REMOTE_TARGET_PATTERNS[key].test(target)) {
        blockers.push(
          blocker(
            'invalid-v1-targeting-characters',
            `${path}.${key}`,
            'Remote inventory field contains unsupported targeting characters.',
            name,
          ),
        );
      }
    }
    for (const [snake, camel] of [
      ['working_directory', 'workingDirectory'],
      ['model_hint', 'modelHint'],
      ['reasoning_level', 'reasoningLevel'],
      ['tool_policy', 'toolPolicy'],
      ['persistent_persona', 'persistentPersona'],
      ['reset_between_tasks', 'resetBetweenTasks'],
      ['kickstart_template', 'kickstartTemplate'],
    ] as const) {
      rejectSynonymCollision(agent, snake, camel, path, blockers);
    }
    if (name && names.has(name)) {
      blockers.push(
        blocker('duplicate-agent-name', `${path}.name`, 'Agent name is duplicated.', name),
      );
    }
    if (name) names.add(name);
  }
  requireObjectWhenPresent(raw.connector, 'connector', blockers);
  validateConnector(record(raw.connector), raw.connector !== undefined, blockers);
}

function validateConnector(
  connector: Record<string, unknown>,
  present: boolean,
  blockers: MigrationBlocker[],
): void {
  if (!present) return;
  rejectUnknownKeys(connector, new Set(['kind', 'matrix', 'discord']), 'connector', blockers);
  const kind = typeof connector.kind === 'string' ? connector.kind : undefined;
  if (!kind || !['tmux', 'discord', 'matrix'].includes(kind)) {
    blockers.push(blocker('invalid-connector', 'connector.kind', 'Unsupported connector kind.'));
  }
  if (kind === 'tmux') {
    if (connector.matrix !== undefined) {
      blockers.push(
        blocker(
          'invalid-connector',
          'connector.matrix',
          'Tmux connector must not define Matrix connector data.',
        ),
      );
    }
    if (connector.discord !== undefined) {
      blockers.push(
        blocker(
          'invalid-connector',
          'connector.discord',
          'Tmux connector must not define Discord connector data.',
        ),
      );
    }
  }
  if (kind === 'discord' && connector.matrix !== undefined) {
    blockers.push(
      blocker(
        'invalid-connector',
        'connector.matrix',
        'Discord connector must not define Matrix connector data.',
      ),
    );
  }
  if (kind === 'matrix' && connector.discord !== undefined) {
    blockers.push(
      blocker(
        'invalid-connector',
        'connector.discord',
        'Matrix connector must not define Discord connector data.',
      ),
    );
  }
  if (kind === 'matrix' && connector.matrix === undefined) {
    blockers.push(
      blocker('invalid-connector', 'connector.matrix', 'Matrix connector data is required.'),
    );
  }
  if (kind === 'discord' && connector.discord === undefined) {
    blockers.push(
      blocker('invalid-connector', 'connector.discord', 'Discord connector data is required.'),
    );
  }
  if (connector.matrix !== undefined) {
    requireObjectWhenPresent(connector.matrix, 'connector.matrix', blockers);
    const matrix = record(connector.matrix);
    rejectUnknownKeys(
      matrix,
      new Set(['homeserver_url', 'user_id', 'room_id']),
      'connector.matrix',
      blockers,
    );
    rejectMissingStringFields(
      matrix,
      ['homeserver_url', 'user_id', 'room_id'],
      'connector.matrix',
      blockers,
    );
  }
  if (connector.discord !== undefined) {
    requireObjectWhenPresent(connector.discord, 'connector.discord', blockers);
    const discord = record(connector.discord);
    rejectUnknownKeys(discord, new Set(['channel_id']), 'connector.discord', blockers);
    rejectMissingStringFields(discord, ['channel_id'], 'connector.discord', blockers);
  }
}

function requireObjectWhenPresent(
  value: unknown,
  path: string,
  blockers: MigrationBlocker[],
): void {
  if (
    value !== undefined &&
    (typeof value !== 'object' || value === null || Array.isArray(value))
  ) {
    blockers.push(blocker('invalid-v1-field-type', path, 'Field must be an object when present.'));
  }
}

function rejectOptionalStringFields(
  value: Record<string, unknown>,
  fields: readonly string[],
  path: string,
  blockers: MigrationBlocker[],
  allowYamlTilde = false,
): void {
  for (const field of fields) {
    const requested = value[field];
    if (
      requested !== undefined &&
      typeof requested !== 'string' &&
      !(allowYamlTilde && requested === null)
    ) {
      blockers.push(
        blocker(
          'invalid-v1-field-type',
          `${path}.${field}`,
          'Field must be a string when present.',
        ),
      );
    }
  }
}

function rejectMissingStringFields(
  value: Record<string, unknown>,
  required: readonly string[],
  path: string,
  blockers: MigrationBlocker[],
): void {
  for (const key of required) {
    if (stringValue(value[key]) === undefined) {
      blockers.push(
        blocker(
          'invalid-v1-field-type',
          `${path}.${key}`,
          'Required inventory field must be a non-empty string.',
        ),
      );
    }
  }
}

function rejectUnknownKeys(
  value: Record<string, unknown>,
  allowed: ReadonlySet<string>,
  path: string,
  blockers: MigrationBlocker[],
): void {
  for (const key of Object.keys(value)) {
    if (!allowed.has(key)) {
      blockers.push(
        blocker(
          'unsupported-v1-field',
          path === 'root' ? key : `${path}.${key}`,
          'Field is not declared by the shipped v1 contract.',
        ),
      );
    }
  }
}

function rejectSynonymCollision(
  value: Record<string, unknown>,
  snake: string,
  camel: string,
  path: string,
  blockers: MigrationBlocker[],
): void {
  if (value[snake] !== undefined && value[camel] !== undefined) {
    blockers.push(
      blocker(
        'v1-synonym-collision',
        `${path}.${snake}`,
        `Both ${snake} and ${camel} are present; precedence is not inferred.`,
      ),
    );
  }
}

function assertV1Root(raw: Record<string, unknown>, blockers: MigrationBlocker[]): void {
  if (raw.version !== 1)
    blockers.push(blocker('invalid-v1-version', 'version', 'Version must be 1.'));
  if (raw.transport !== 'tmux') {
    blockers.push(blocker('invalid-v1-transport', 'transport', 'Transport must be tmux.'));
  }
  if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
    blockers.push(blocker('invalid-v1-agents', 'agents', 'At least one v1 agent is required.'));
  }
}

function normalizeRuntimes(
  raw: Record<string, unknown>,
  blockers: MigrationBlocker[],
): Record<string, { reset_command: string }> {
  const result: Record<string, { reset_command: string }> = Object.fromEntries(
    Object.entries(V1_RUNTIME_RESETS).map(([name, resetCommand]) => [
      name,
      { reset_command: resetCommand },
    ]),
  );
  for (const [name, value] of Object.entries(raw)) {
    if (!isRuntime(name)) {
      blockers.push(blocker('unsupported-runtime', `runtimes.${name}`, 'Unsupported v2 runtime.'));
      continue;
    }
    const runtime = record(value);
    const resetCommand =
      exactStringValue(runtime.reset_command) ?? exactStringValue(runtime.resetCommand) ?? '/clear';
    result[name] = { reset_command: resetCommand };
  }
  return result;
}

function resolveClass(
  requested: string,
  disposition: V1ClassDisposition | undefined,
  path: string,
  agent: string,
  blockers: MigrationBlocker[],
): string | undefined {
  const canonical = canonicalizeRoleClass(requested).canonicalClass;
  if (AUTOMATIC_CLASSES.has(canonical)) {
    if (disposition !== undefined) {
      blockers.push(
        blocker(
          'redundant-class-disposition',
          path,
          'Automatically canonical classes must not carry a competing disposition.',
          agent,
        ),
      );
    }
    return canonical;
  }
  if (!disposition) {
    blockers.push(
      blocker(
        'class-disposition-required',
        path,
        'Noncanonical class requires explicit preserve or replacement disposition.',
        agent,
      ),
    );
    return undefined;
  }
  if (disposition.action === 'preserve') {
    if (disposition.className !== undefined && disposition.className !== requested) {
      blockers.push(
        blocker(
          'invalid-class-disposition',
          path,
          'Preserve disposition cannot change the requested class.',
          agent,
        ),
      );
      return undefined;
    }
    return requested;
  }
  if (!disposition.className || disposition.className === requested) {
    blockers.push(
      blocker(
        'invalid-class-disposition',
        path,
        'Replace disposition requires a different non-empty class.',
        agent,
      ),
    );
    return undefined;
  }
  return canonicalizeRoleClass(disposition.className).canonicalClass;
}

function resolveToolPolicy(
  requested: string | undefined,
  disposition: V1ClassDisposition | undefined,
  path: string,
  agent: string,
  blockers: MigrationBlocker[],
): string | undefined {
  if (requested === undefined) {
    if (disposition?.action !== 'replace' || !disposition.className) {
      blockers.push(
        blocker(
          'tool-policy-disposition-required',
          path,
          'Missing v1 tool_policy requires an explicit replacement; class is not inferred.',
          agent,
        ),
      );
      return undefined;
    }
    return canonicalizeRoleClass(disposition.className).canonicalClass;
  }
  return resolveClass(requested, disposition, path, agent, blockers);
}

function resolveLifecycle(
  observation: V1MigrationObservation | undefined,
  path: string,
  agent: string,
  blockers: MigrationBlocker[],
): RosterV2DesiredState | undefined {
  if (!observation) {
    blockers.push(
      blocker(
        'missing-lifecycle-observation',
        `${path}.lifecycle`,
        'Explicit systemd and tmux observation is required.',
        agent,
      ),
    );
    return undefined;
  }
  if (observation.systemd === 'active' && observation.tmux === 'present') return 'running';
  if (observation.systemd === 'inactive' && observation.tmux === 'missing') return 'stopped';
  blockers.push(
    blocker(
      'ambiguous-lifecycle-observation',
      `${path}.lifecycle`,
      'Systemd and tmux evidence is unknown or contradictory.',
      agent,
    ),
  );
  return undefined;
}

function generatedValues(
  roster: FleetRosterV2,
  agent: FleetRosterV2['agents'][number],
): Readonly<Record<string, string>> {
  return {
    MOSAIC_AGENT_NAME: agent.name,
    MOSAIC_GIT_IDENTITY: agent.name,
    MOSAIC_AGENT_CLASS: agent.className,
    MOSAIC_AGENT_RUNTIME: agent.runtime,
    MOSAIC_AGENT_MODEL: agent.model,
    MOSAIC_AGENT_REASONING: agent.reasoning,
    MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
    MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
    MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
  };
}

function safeEnvironmentDiagnostic(error: unknown): string {
  if (
    typeof error === 'object' &&
    error !== null &&
    'diagnostic' in error &&
    typeof error.diagnostic === 'object' &&
    error.diagnostic !== null
  ) {
    const diagnostic = error.diagnostic as Partial<AgentEnvironmentDiagnostic>;
    return `code=${diagnostic.code ?? 'unknown'} key=${diagnostic.key ?? '(unknown)'} sha256=${diagnostic.sha256 ?? sha256('')}`;
  }
  return 'Environment projection preflight failed without publishable detail.';
}

function parseClassDisposition(value: unknown, path: string): V1ClassDisposition | undefined {
  if (value === undefined) return undefined;
  const disposition = requireRecord(value, path);
  assertOnlyKeys(disposition, CLASS_DISPOSITION_FIELDS, path);
  if (disposition.action !== 'preserve' && disposition.action !== 'replace') {
    throw new Error(`${path}.action must be preserve or replace.`);
  }
  const className = optionalNonEmptyString(disposition.className, `${path}.className`);
  if (disposition.action === 'replace' && className === undefined) {
    throw new Error(`${path}.className is required for replacement.`);
  }
  return { action: disposition.action, ...(className ? { className } : {}) };
}

function requireRecord(value: unknown, path: string): Record<string, unknown> {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    throw new Error(`${path} must be an object.`);
  }
  return value as Record<string, unknown>;
}

function assertOnlyKeys(
  value: Record<string, unknown>,
  allowed: ReadonlySet<string>,
  path: string,
): void {
  const unknown = Object.keys(value).find((key) => !allowed.has(key));
  if (unknown !== undefined) throw new Error(`${path}.${unknown} is not supported.`);
}

function requiredNonEmptyString(value: unknown, path: string): string {
  const normalized = stringValue(value);
  if (normalized === undefined) throw new Error(`${path} must be a non-empty string.`);
  return normalized;
}

function optionalNonEmptyString(value: unknown, path: string): string | undefined {
  if (value === undefined) return undefined;
  return requiredNonEmptyString(value, path);
}

function blocker(code: string, path: string, detail: string, agent?: string): MigrationBlocker {
  return { code, path, ...(agent ? { agent } : {}), detail };
}

function record(value: unknown): Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : {};
}

function exactStringValue(value: unknown): string | undefined {
  return typeof value === 'string' ? value : undefined;
}

function trimmedString(value: unknown): string | undefined {
  return typeof value === 'string' ? value.trim() : undefined;
}

function stringValue(value: unknown): string | undefined {
  const trimmed = trimmedString(value);
  return trimmed ? trimmed : undefined;
}

function booleanValue(value: unknown): boolean | undefined {
  return typeof value === 'boolean' ? value : undefined;
}

function isRuntime(value: string | undefined): value is RosterV2RuntimeName {
  return value === 'claude' || value === 'codex' || value === 'opencode' || value === 'pi';
}

function sha256(value: string): string {
  return createHash('sha256').update(value).digest('hex');
}
