/**
 * `mosaic fleet provision --profile <id>` — turn a declared SYSTEM TYPE (a
 * profile) into a concrete fleet roster (North Star H3).
 *
 * A profile (fleet/profiles/<id>.yaml) is a DECLARATIVE mapping from a system
 * type to a persona roster + org topology (H2). This command MATERIALIZES that
 * declaration into the concrete `roster.yaml` shape the live fleet consumes — the
 * same shape `fleet.ts` parses (version/transport/tmux/defaults/runtimes/agents).
 *
 * DRY-RUN-FIRST + REVIEWABLE: with no --write it prints the roster it WOULD
 * generate plus a topology summary and writes nothing. --write persists it, and
 * REFUSES to clobber an existing roster.yaml without --force (protects operator
 * customizations).
 *
 * DRY: profile parsing/validation is reused wholesale from fleet-profiles.ts
 * (loadProfile/validateProfile) and persona resolution from fleet-personas.ts
 * (resolvePersona, override-aware). This module owns ONLY the profile→roster
 * generation policy, documented inline below so each default is reviewable.
 */

import { access, mkdir, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import type { Command } from 'commander';
import YAML from 'yaml';
import {
  loadProfile,
  validateProfile,
  type FleetProfile,
  type ProfileRosterEntry,
  defaultProfilesDir,
  defaultRolesDir,
  listPersonaClassesWithOverrides,
} from './fleet-profiles.js';
import {
  defaultOverrideDir,
  extractClassesFromDir,
  resolvePersonaFrom,
  type PersonaLayer,
} from './fleet-personas.js';

function defaultMosaicHome(): string {
  return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}

// ---------------------------------------------------------------------------
// GENERATION RULES — each default below is intentionally simple and documented
// so a reviewer can ratify or override the policy. See the PR body for the open
// runtime-per-class question (RULE 4).
// ---------------------------------------------------------------------------

/**
 * RULE 4 — Runtime assignment policy (THE one open design question).
 *
 * Default: EVERY seat → `runtime: claude`. Claude runs every persona, so it is
 * the safe universal default and guarantees a structurally-valid, launchable
 * roster regardless of how the policy ultimately lands. We deliberately do NOT
 * hardcode pi / gpt-5.5 per class here. The live roster today runs coders on
 * pi + openai-codex/gpt-5.5:high — whether provisioning should encode a
 * class→runtime/model map (and WHERE: the profile schema vs a separate
 * runtime-policy file) is flagged in the PR body for ratification.
 *
 * Centralized so changing the policy is a ONE-edit change. If a future profile
 * entry (or persona) declares a runtime/model preference, honor it here; until
 * then everything defaults to claude.
 */
export const DEFAULT_RUNTIME = 'claude';

/** Result of applying the runtime policy to one seat. */
interface RuntimeChoice {
  runtime: string;
  modelHint?: string;
}

/**
 * The single centralized runtime-policy function. Today it returns the universal
 * `claude` default for every seat. To encode a class→runtime/model map later,
 * edit ONLY this function (and/or extend the profile schema and read it here).
 */
export function resolveSeatRuntime(
  _klass: string,
  _isFloor: boolean,
  _isLead: boolean,
): RuntimeChoice {
  return { runtime: DEFAULT_RUNTIME };
}

/** One generated seat, fully resolved for emission + topology display. */
export interface GeneratedSeat {
  name: string;
  className: string;
  runtime: string;
  modelHint?: string;
  persistentPersona?: boolean;
  resetBetweenTasks?: boolean;
  /** Topology edge from the profile (NOT emitted to roster.yaml — see RULE 6). */
  reportsTo?: string;
  /** Which persona layer the class resolved from (baseline/override). */
  personaLayer: PersonaLayer;
}

export interface GenerateRosterResult {
  seats: GeneratedSeat[];
  /** The roster.yaml text (parser-valid, drop-in). */
  yaml: string;
}

export interface ProvisionOptions {
  /** Materialize the entire profile roster (multiplicity expanded). */
  full?: boolean;
  mosaicHome?: string;
  /** Test overrides — mirror fleet-profiles LoadProfilesOptions. */
  profilesDir?: string;
  rolesDir?: string;
  overrideDir?: string;
}

function resolveDirs(opts: ProvisionOptions): {
  mosaicHome: string;
  profilesDir: string;
  rolesDir: string;
  overrideDir: string;
} {
  const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
  return {
    mosaicHome,
    profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome),
    rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
    overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
  };
}

/**
 * RULE 1 — Seat naming. multiplicity 1 → name = class (e.g. `planner`).
 * multiplicity N>1 → `<class>0`,`<class>1`,… (e.g. `code` ×2 → code0/code1).
 * Names are deterministic, following profile roster order.
 */
function seatNames(entry: ProfileRosterEntry): string[] {
  if (entry.multiplicity <= 1) return [entry.class];
  return Array.from({ length: entry.multiplicity }, (_, i) => `${entry.class}${i}`);
}

/**
 * Generate the concrete seats + roster.yaml for a validated profile.
 *
 * Seat selection:
 *   --full  → the ENTIRE profile roster, multiplicity expanded.
 *   default → ONLY the `floor` classes (the always-staffed minimum), matching
 *             the profile note "two-agent floor always staffed; every other seat
 *             added on demand."
 *
 * Per-seat flags:
 *   RULE 2 persistent_persona: true for floor classes AND the lead; else omitted.
 *   RULE 3 reset_between_tasks: true for non-floor, non-lead execution seats;
 *           floor/lead omit it (mirrors today's coders resetting while the
 *           orchestrator/enhancer do not).
 *   RULE 4 runtime: via resolveSeatRuntime (defaults claude).
 *   RULE 6 reports_to: tracked on the seat for the topology summary but NOT
 *           emitted to roster.yaml — the fleet.ts parser rejects unknown agent
 *           keys, so writing reports_to would break round-trip. Confirmed against
 *           normalizeAgent's allow-list in fleet.ts.
 *
 * Persona resolution: every emitted class is resolved override-aware via
 * resolvePersona so we can (a) record the layer for the summary and (b) refuse
 * to emit a roster that references a nonexistent persona.
 */
export async function generateRoster(
  profile: FleetProfile,
  opts: ProvisionOptions,
): Promise<GenerateRosterResult> {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const floor = new Set(profile.floor);
  const lead = profile.lead;

  const selected: ProfileRosterEntry[] = opts.full
    ? profile.roster
    : profile.roster.filter((e) => floor.has(e.class));

  // Scan the persona directories ONCE, then resolve every roster entry against
  // the in-memory maps. resolvePersona() would otherwise re-scan both dirs per
  // entry — O(entries × files) redundant reads that push --full provisioning
  // past the test timeout on slow/contended filesystems.
  const [base, over] = await Promise.all([
    extractClassesFromDir(rolesDir),
    extractClassesFromDir(overrideDir),
  ]);

  const seats: GeneratedSeat[] = [];
  for (const entry of selected) {
    const isFloor = floor.has(entry.class);
    const isLead = entry.class === lead;

    const resolved = await resolvePersonaFrom(entry.class, { rolesDir, overrideDir, base, over });
    if (!resolved) {
      // Defensive: validateProfile already guards this, but a class can resolve
      // for membership yet have no readable file. Fail loudly rather than emit a
      // roster pointing at a persona we cannot load.
      throw new Error(
        `Cannot provision: roster class "${entry.class}" does not resolve to a readable persona.`,
      );
    }

    const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
    for (const name of seatNames(entry)) {
      const seat: GeneratedSeat = {
        name,
        className: entry.class,
        runtime: runtimeChoice.runtime,
        personaLayer: resolved.layer,
      };
      if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
      if (isFloor || isLead) seat.persistentPersona = true;
      if (!isFloor && !isLead) seat.resetBetweenTasks = true;
      if (entry.reportsTo) seat.reportsTo = entry.reportsTo;
      seats.push(seat);
    }
  }

  if (seats.length === 0) {
    throw new Error(
      `Profile "${profile.id}" produced no seats. ` +
        (opts.full ? 'Its roster is empty.' : 'No floor seats are defined — try --full.'),
    );
  }

  return { seats, yaml: renderRosterYaml(seats) };
}

/**
 * RULE 5 — Standard roster scaffolding. We emit the same generic, non-personal
 * scaffold the committed example presets use (socket_name: mosaic-fleet,
 * holder_session: _holder, working_directory: ~, claude + pi runtimes) so the
 * output is a drop-in valid roster. No operator-personal data is copied.
 *
 * Built via the `yaml` lib (same serializer the parser uses) so the result
 * round-trips. reports_to is intentionally NOT included on agents (RULE 6).
 */
function renderRosterYaml(seats: GeneratedSeat[]): string {
  const agents = seats.map((s) => {
    const a: Record<string, unknown> = {
      name: s.name,
      runtime: s.runtime,
      class: s.className,
    };
    if (s.persistentPersona) a['persistent_persona'] = true;
    if (s.modelHint) a['model_hint'] = s.modelHint;
    if (s.resetBetweenTasks) a['reset_between_tasks'] = true;
    return a;
  });

  const doc = {
    version: 1,
    transport: 'tmux',
    tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' },
    defaults: { working_directory: '~' },
    runtimes: {
      claude: { reset_command: '/clear' },
      pi: { reset_command: '/new' },
    },
    agents,
  };

  return YAML.stringify(doc);
}

// ---------------------------------------------------------------------------
// Validation — reuse fleet-profiles.validateProfile (override-aware classes) and
// name any unresolved class clearly. Never generate a roster referencing a
// nonexistent persona.
// ---------------------------------------------------------------------------

/**
 * Validate the profile against the override-aware persona library. Throws with a
 * clear, class-naming message if any referenced class is unresolved.
 */
export async function validateProfileForProvision(
  profile: FleetProfile,
  opts: ProvisionOptions,
): Promise<void> {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
  const problems = validateProfile(profile, validClasses);
  if (problems.length > 0) {
    throw new Error(
      `Profile "${profile.id}" is invalid; cannot provision:\n  - ${problems.join('\n  - ')}`,
    );
  }
}

// ---------------------------------------------------------------------------
// Topology summary (printed in dry-run and after write).
// ---------------------------------------------------------------------------

function formatTopologySummary(seats: GeneratedSeat[]): string {
  const lines: string[] = [];
  lines.push(`Topology (${seats.length} seat(s)):`);
  for (const s of seats) {
    const reports = s.reportsTo ? `reports_to=${s.reportsTo}` : 'reports_to=- (lead)';
    lines.push(
      `  - ${s.name}\tclass=${s.className}\truntime=${s.runtime}\t${reports}\tpersona=${s.personaLayer}`,
    );
  }
  return lines.join('\n');
}

// ---------------------------------------------------------------------------
// CLI wiring — mirror registerFleetProfileCommand / registerFleetPersonaCommand.
// ---------------------------------------------------------------------------

export interface ProvisionRunResult {
  yaml: string;
  summary: string;
  wrote?: string;
}

/**
 * Core provision flow shared by the CLI: load + validate the profile, generate
 * the roster, optionally write it. Returns the artifacts for printing/testing.
 */
export async function runProvision(
  profileId: string,
  opts: ProvisionOptions & { write?: boolean; force?: boolean },
): Promise<ProvisionRunResult> {
  const dirs = resolveDirs(opts);
  const profile = await loadProfile(profileId, {
    mosaicHome: dirs.mosaicHome,
    profilesDir: dirs.profilesDir,
    rolesDir: dirs.rolesDir,
    overrideDir: dirs.overrideDir,
  });
  // loadProfile already validates, but re-run with our explicit error wording so
  // an unresolved class is named clearly even if invoked directly.
  await validateProfileForProvision(profile, opts);

  const { seats, yaml } = await generateRoster(profile, opts);
  const summary = formatTopologySummary(seats);

  if (!opts.write) {
    return { yaml, summary };
  }

  const rosterPath = join(dirs.mosaicHome, 'fleet', 'roster.yaml');
  if (!opts.force) {
    let exists = false;
    try {
      await access(rosterPath, constants.F_OK);
      exists = true;
    } catch {
      exists = false;
    }
    if (exists) {
      throw new Error(
        `Refusing to overwrite existing roster: ${rosterPath}. ` +
          `Pass --force to overwrite, or edit it by hand.`,
      );
    }
  }
  await mkdir(dirname(rosterPath), { recursive: true });
  await writeFile(rosterPath, yaml, 'utf8');
  return { yaml, summary, wrote: rosterPath };
}

/**
 * Register `provision` under an existing `fleet` command. `mosaicHomeFor`
 * resolves the active --mosaic-home (parent flag) at call time, exactly like the
 * profile/persona/backlog subcommands.
 */
export function registerFleetProvisionCommand(
  fleetCmd: Command,
  mosaicHomeFor: () => string,
): Command {
  const provisionCmd = fleetCmd
    .command('provision')
    .description('Materialize a roster.yaml from a system-type profile (H3). DRY-RUN by default.')
    .requiredOption('--profile <id>', 'System-type profile id to provision')
    .option('--full', 'Materialize the entire profile roster (default: floor seats only)')
    .option('--write', 'Write the generated roster to <mosaicHome>/fleet/roster.yaml')
    .option('--force', 'Overwrite an existing roster.yaml (requires --write)')
    .action(async (opts: { profile: string; full?: boolean; write?: boolean; force?: boolean }) => {
      try {
        const result = await runProvision(opts.profile, {
          mosaicHome: mosaicHomeFor(),
          full: opts.full,
          write: opts.write,
          force: opts.force,
        });
        if (result.wrote) {
          console.log(`Wrote roster: ${result.wrote}`);
          console.log('');
          console.log(result.summary);
        } else {
          // DRY RUN: print the roster it WOULD generate + topology, write nothing.
          console.log('# DRY RUN — no files written. Re-run with --write to persist.');
          console.log(result.yaml.trimEnd());
          console.log('');
          console.log(result.summary);
        }
      } catch (err) {
        process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
        process.exitCode = 1;
      }
    });

  return provisionCmd;
}
