import { open, readFile, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import {
  applyPreparedAgentEnvironmentProjection,
  prepareAgentGeneratedProjectionDeletion,
  prepareAgentEnvironmentProjection,
  type PreparedAgentEnvironmentProjection,
  writeManagedFleetRoster,
} from './generated-env-boundary.js';
import {
  parseRosterV2,
  renderRosterV2Yaml,
  validateRosterV2Semantics,
  type FleetRosterV2,
  type FleetRosterV2Agent,
  type FleetRosterV2Launch,
  type FleetRosterV2Lifecycle,
  type RosterV2ReasoningLevel,
  type RosterV2RuntimeName,
} from './roster-v2.js';

export type FleetAgentMutationOperation = 'create' | 'update' | 'delete';

export interface FleetAgentMutationAgent {
  readonly name: string;
  readonly alias: string;
  readonly className: string;
  readonly runtime: RosterV2RuntimeName;
  readonly provider: string;
  readonly model: string;
  readonly reasoning: RosterV2ReasoningLevel;
  readonly toolPolicy: string;
  readonly workingDirectory: string;
  readonly persistentPersona: boolean;
  readonly resetBetweenTasks: boolean;
  readonly launch: FleetRosterV2Launch;
}

export interface FleetAgentMutationRequest {
  readonly operation: FleetAgentMutationOperation;
  readonly expectedGeneration: number;
  readonly name?: string;
  readonly agent?: FleetAgentMutationAgent;
  readonly persistedStart?: boolean;
}

export interface FleetAgentMutationPlan {
  readonly operation: FleetAgentMutationOperation;
  readonly currentGeneration: number;
  readonly nextGeneration: number;
  readonly changed: boolean;
  readonly roster: FleetRosterV2;
  readonly agent?: FleetRosterV2Agent;
}

export type FleetAgentMutationAuthoritativeRosterState = 'unchanged' | 'committed';
export type FleetAgentMutationProjectionState = 'not-applied' | 'complete' | 'incomplete';

export interface FleetAgentMutationResult {
  /** True only when every requested roster and projection write completed. */
  readonly applied: boolean;
  /** Whether the authoritative roster changed on disk. */
  readonly authoritativeRoster: FleetAgentMutationAuthoritativeRosterState;
  /** Whether derived projections were skipped, complete, or require recovery. */
  readonly projections: FleetAgentMutationProjectionState;
  readonly plan: FleetAgentMutationPlan;
  readonly recovery?: FleetAgentMutationRecovery;
}

export interface FleetAgentMutationRecovery {
  readonly code: 'projection-apply-failed';
  readonly rosterPath: string;
  readonly action: 'regenerate-projections-from-roster';
}

export interface FleetAgentMutationOptions {
  readonly roster: FleetRosterV2;
  readonly request: FleetAgentMutationRequest;
  readonly mosaicHome: string;
  readonly rosterPath: string;
  readonly agentEnvDir: string;
  readonly rolesDir: string;
  readonly overrideDir: string;
  readonly dryRun?: boolean;
  readonly projectionApplier?: (prepared: PreparedAgentEnvironmentProjection) => Promise<unknown>;
}

export class FleetAgentMutationError extends Error {
  constructor(
    readonly code:
      | 'stale-generation'
      | 'agent-conflict'
      | 'agent-not-found'
      | 'invalid-request'
      | 'concurrent-mutation'
      | 'projection-apply-failed',
    message: string,
  ) {
    super(message);
    this.name = FleetAgentMutationError.name;
  }
}

/** Plans a generation-guarded desired-state mutation without writing any file. */
export function planFleetAgentMutation(
  roster: FleetRosterV2,
  request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
  if (request.expectedGeneration !== roster.generation) {
    throw new FleetAgentMutationError(
      'stale-generation',
      `Expected roster generation ${request.expectedGeneration}; current generation is ${roster.generation}.`,
    );
  }

  if (request.operation === 'create') return planCreate(roster, request);
  if (request.operation === 'update') return planUpdate(roster, request);
  return planDelete(roster, request);
}

/**
 * Validates the complete proposed roster and its deterministic projections before
 * changing the roster authority. The only post-roster write is a derived projection.
 */
export async function executeFleetAgentMutation(
  options: FleetAgentMutationOptions,
): Promise<FleetAgentMutationResult> {
  const plan = planFleetAgentMutation(options.roster, options.request);
  await validateRosterV2Semantics(plan.roster, {
    rolesDir: options.rolesDir,
    overrideDir: options.overrideDir,
  });
  const prepared = await prepareProjections(plan.roster, options.mosaicHome, options.agentEnvDir);
  if (plan.operation === 'delete' && plan.agent) {
    // Delete validates only the exact generated projection. Local overrides,
    // legacy input, and quarantine records are retained operator state.
    await prepareAgentGeneratedProjectionDeletion({
      mosaicHome: options.mosaicHome,
      agentEnvDir: options.agentEnvDir,
      agentName: plan.agent.name,
    });
  }

  if (options.dryRun === true || !plan.changed) {
    return { applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', plan };
  }

  const lockPath = `${options.rosterPath}.mutation.lock`;
  const release = await acquireMutationLock(lockPath);
  try {
    const persisted = parseRosterV2(await readFile(options.rosterPath, 'utf8'), 'yaml');
    if (persisted.generation !== options.roster.generation) {
      throw new FleetAgentMutationError(
        'stale-generation',
        `Expected roster generation ${options.roster.generation}; current generation is ${persisted.generation}.`,
      );
    }

    await writeManagedFleetRoster(
      options.mosaicHome,
      options.rosterPath,
      renderRosterV2Yaml(plan.roster),
    );
    const apply = options.projectionApplier ?? applyPreparedAgentEnvironmentProjection;
    try {
      for (const projection of prepared) await apply(projection);
      if (plan.operation === 'delete' && plan.agent) {
        await prepareAgentGeneratedProjectionDeletion({
          mosaicHome: options.mosaicHome,
          agentEnvDir: options.agentEnvDir,
          agentName: plan.agent.name,
        });
        await removeGeneratedProjection(options.agentEnvDir, plan.agent.name);
      }
    } catch {
      throw new FleetAgentMutationError(
        'projection-apply-failed',
        `Projection application failed after roster write; regenerate projections from ${options.rosterPath}.`,
      );
    }
    return { applied: true, authoritativeRoster: 'committed', projections: 'complete', plan };
  } catch (error: unknown) {
    if (error instanceof FleetAgentMutationError && error.code === 'projection-apply-failed') {
      return {
        applied: false,
        authoritativeRoster: 'committed',
        projections: 'incomplete',
        plan,
        recovery: {
          code: 'projection-apply-failed',
          rosterPath: options.rosterPath,
          action: 'regenerate-projections-from-roster',
        },
      };
    }
    throw error;
  } finally {
    await release();
  }
}

function planCreate(
  roster: FleetRosterV2,
  request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
  if (!request.agent)
    throw new FleetAgentMutationError('invalid-request', 'Create requires an agent.');
  const existing = roster.agents.find(
    (agent: FleetRosterV2Agent): boolean => agent.name === request.agent?.name,
  );
  const created = toRosterAgent(request.agent, request.persistedStart === true);
  if (existing) {
    if (sameAgent(existing, created)) return unchangedPlan(roster, request.operation, existing);
    throw new FleetAgentMutationError('agent-conflict', `Agent "${created.name}" already exists.`);
  }
  return changedPlan(roster, request.operation, [...roster.agents, created], created);
}

function planUpdate(
  roster: FleetRosterV2,
  request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
  if (!request.name || !request.agent) {
    throw new FleetAgentMutationError('invalid-request', 'Update requires name and agent.');
  }
  const existing = roster.agents.find(
    (agent: FleetRosterV2Agent): boolean => agent.name === request.name,
  );
  if (!existing)
    throw new FleetAgentMutationError(
      'agent-not-found',
      `Agent "${request.name}" is not in roster.`,
    );
  if (request.agent.name !== request.name) {
    throw new FleetAgentMutationError(
      'invalid-request',
      'Agent names are immutable during update.',
    );
  }
  const updated = {
    ...toRosterAgent(request.agent, existing.lifecycle.desiredState === 'running'),
    lifecycle: existing.lifecycle,
  };
  if (sameAgent(existing, updated)) return unchangedPlan(roster, request.operation, existing);
  return changedPlan(
    roster,
    request.operation,
    roster.agents.map(
      (agent: FleetRosterV2Agent): FleetRosterV2Agent =>
        agent.name === request.name ? updated : agent,
    ),
    updated,
  );
}

function planDelete(
  roster: FleetRosterV2,
  request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
  if (!request.name) throw new FleetAgentMutationError('invalid-request', 'Delete requires name.');
  const existing = roster.agents.find(
    (agent: FleetRosterV2Agent): boolean => agent.name === request.name,
  );
  if (!existing) return unchangedPlan(roster, request.operation);
  return changedPlan(
    roster,
    request.operation,
    roster.agents.filter((agent: FleetRosterV2Agent): boolean => agent.name !== request.name),
    existing,
  );
}

function toRosterAgent(
  input: FleetAgentMutationAgent,
  persistedStart: boolean,
): FleetRosterV2Agent {
  const lifecycle: FleetRosterV2Lifecycle = {
    enabled: true,
    desiredState: persistedStart ? 'running' : 'stopped',
  };
  return { ...input, lifecycle };
}

function changedPlan(
  roster: FleetRosterV2,
  operation: FleetAgentMutationOperation,
  agents: readonly FleetRosterV2Agent[],
  agent?: FleetRosterV2Agent,
): FleetAgentMutationPlan {
  const proposed = { ...roster, generation: roster.generation + 1, agents };
  const normalized = parseRosterV2(renderRosterV2Yaml(proposed), 'yaml');
  return {
    operation,
    currentGeneration: roster.generation,
    nextGeneration: normalized.generation,
    changed: true,
    roster: normalized,
    ...(agent ? { agent } : {}),
  };
}

function unchangedPlan(
  roster: FleetRosterV2,
  operation: FleetAgentMutationOperation,
  agent?: FleetRosterV2Agent,
): FleetAgentMutationPlan {
  return {
    operation,
    currentGeneration: roster.generation,
    nextGeneration: roster.generation,
    changed: false,
    roster,
    ...(agent ? { agent } : {}),
  };
}

function sameAgent(left: FleetRosterV2Agent, right: FleetRosterV2Agent): boolean {
  return (
    left.name === right.name &&
    left.alias === right.alias &&
    left.className === right.className &&
    left.runtime === right.runtime &&
    left.provider === right.provider &&
    left.model === right.model &&
    left.reasoning === right.reasoning &&
    left.toolPolicy === right.toolPolicy &&
    left.workingDirectory === right.workingDirectory &&
    left.persistentPersona === right.persistentPersona &&
    left.resetBetweenTasks === right.resetBetweenTasks &&
    left.launch.yolo === right.launch.yolo &&
    left.lifecycle.enabled === right.lifecycle.enabled &&
    left.lifecycle.desiredState === right.lifecycle.desiredState
  );
}

async function prepareProjections(
  roster: FleetRosterV2,
  mosaicHome: string,
  agentEnvDir: string,
): Promise<readonly PreparedAgentEnvironmentProjection[]> {
  const projections: PreparedAgentEnvironmentProjection[] = [];
  for (const agent of roster.agents) {
    projections.push(
      await prepareAgentEnvironmentProjection({
        mosaicHome,
        agentEnvDir,
        agentName: agent.name,
        generated: generatedValues(roster, agent),
      }),
    );
  }
  return projections;
}

function generatedValues(
  roster: FleetRosterV2,
  agent: FleetRosterV2Agent,
): Readonly<Record<string, string>> {
  return {
    MOSAIC_AGENT_NAME: 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,
  };
}

async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise<void> {
  try {
    await unlink(join(agentEnvDir, `${agentName}.env.generated`));
  } catch (error: unknown) {
    if (isMissingFile(error)) return;
    throw error;
  }
}

function isMissingFile(error: unknown): boolean {
  return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}

async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
  try {
    const handle = await open(lockPath, 'wx', 0o600);
    await handle.close();
  } catch {
    throw new FleetAgentMutationError(
      'concurrent-mutation',
      `Another roster mutation is in progress for ${dirname(lockPath)}.`,
    );
  }
  return async (): Promise<void> => {
    await unlink(lockPath).catch((): void => {});
  };
}
