/**
 * Persona override layer + resolver (North Star H4).
 *
 * Baseline personas are markdown role contracts seeded by the framework into
 *   <mosaicHome>/fleet/roles/*.md
 * They are RESEEDED on every `mosaic update` (so new baseline personas ship to
 * existing installs). That reseed is exactly what would clobber any local edit,
 * so user customizations must NOT live in roles/.
 *
 * The override layer is a sibling directory:
 *   <mosaicHome>/fleet/roles.local/*.md
 * It is PRESERVE-protected in install.sh (see PRESERVE_PATHS "fleet/roles.local"),
 * so `mosaic update` never deletes it while roles/ keeps reseeding. An override
 * file WINS over the baseline of the same class, and an override file may ADD an
 * entirely new class that has no baseline at all. This delivers AC-NS-7: a
 * user-customized persona survives `mosaic update`.
 *
 * Class identity is encoded INLINE in the role prose, not as YAML frontmatter:
 *   (`class: ceo`, `domain: executive`)
 * The marker value may wrap across a newline. A few engineering personas carry
 * no marker at all and are identified by filename (e.g. planner -> orchestrator).
 *
 * The class-extraction logic here is the SINGLE SOURCE OF TRUTH for "what
 * persona classes exist"; fleet-profiles.ts imports it (DRY) so a profile roster
 * can reference a customized or user-added persona.
 */

import { readFileSync, readdirSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import type { Command } from 'commander';

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

/** Baseline persona role contracts (reseeded on update). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
  return join(mosaicHome, 'fleet', 'roles');
}

/** PRESERVE-protected override layer (survives update; wins on merge). */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
  return join(mosaicHome, 'fleet', 'roles.local');
}

/**
 * Match a `class: X` marker even when the value wrapped onto the next line.
 * Allow surrounding backtick(s); the value is a single kebab-case token.
 * Shared by every caller so the definition of "a class marker" lives once.
 */
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
/** LIBRARY.md persona rows: the first table cell is the persona name. */
const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;

/** Where a resolved persona's definition came from. */
export type PersonaLayer = 'baseline' | 'override';

/** One discovered persona file (a single role contract on disk). */
export interface PersonaFile {
  klass: string;
  /** The markdown file the class was found in. */
  file: string;
  domain?: string;
}

/** The set of persona classes a directory of role contracts defines. */
export interface DirClasses {
  /** Every class name the dir contributes (markers + filenames + LIBRARY rows). */
  classes: Set<string>;
  /** For classes whose file carries a marker, the file + domain that defined it. */
  byClass: Map<string, PersonaFile>;
}

/**
 * Scan one directory of role contracts and extract the persona classes it
 * defines. THIS is the shared extraction both fleet-personas and fleet-profiles
 * rely on. Sources, unioned (each needed — see module doc):
 *   1. inline `class: X` markers in roles/*.md (primary; may wrap a newline),
 *   2. persona-name cells from LIBRARY.md index tables,
 *   3. the role filename stem (covers marker-less alias docs like planner).
 *
 * Missing dir / unreadable files degrade gracefully to whatever was found.
 * `byClass` records the defining file+domain for marker-bearing classes so the
 * resolver can map a class back to its file; filename-only and LIBRARY-only
 * classes still appear in `classes` for membership checks.
 */
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
  const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
  let entries: string[];
  try {
    entries = await readdir(dir);
  } catch {
    return acc;
  }

  for (const entry of entries) {
    if (!entry.endsWith('.md')) continue;
    let text: string;
    try {
      text = await readFile(join(dir, entry), 'utf8');
    } catch {
      continue;
    }
    accumulateEntry(acc, dir, entry, text);
  }
  return acc;
}

/**
 * Synchronous twin of {@link extractClassesFromDir}. Identical extraction
 * semantics (same markers, same union of marker/filename/LIBRARY sources) on
 * sync fs, for the synchronous launch-time prompt path (composeContract) which
 * cannot await. Missing dir / unreadable files degrade gracefully.
 */
export function extractClassesFromDirSync(dir: string): DirClasses {
  const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
  let entries: string[];
  try {
    entries = readdirSync(dir);
  } catch {
    return acc;
  }

  for (const entry of entries) {
    if (!entry.endsWith('.md')) continue;
    let text: string;
    try {
      text = readFileSync(join(dir, entry), 'utf8');
    } catch {
      continue;
    }
    accumulateEntry(acc, dir, entry, text);
  }
  return acc;
}

/**
 * Apply the class-extraction rules for ONE role file's text into `acc`. Pure
 * over already-read content, so the async and sync directory scanners share a
 * single definition of "what classes a file contributes" (DRY — no semantic
 * drift between the launch-time and command-time paths).
 */
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
  const { classes, byClass } = acc;
  if (entry === 'LIBRARY.md') {
    for (const m of text.matchAll(LIBRARY_ROW)) {
      const name = m[1];
      if (name && name !== 'persona') classes.add(name);
    }
    return;
  }
  // The filename stem is itself a valid class (covers marker-less alias docs).
  const stem = basename(entry, '.md');
  classes.add(stem);
  const domainMatch = DOMAIN_MARKER.exec(text);
  const domain = domainMatch?.[1];
  let markedClassForFile: string | undefined;
  for (const m of text.matchAll(CLASS_MARKER)) {
    const klass = m[1];
    if (!klass) continue;
    classes.add(klass);
    // Record the FIRST marker as the file's defining class (the prose names
    // the persona's own class up top; later mentions reference siblings).
    if (!markedClassForFile) {
      markedClassForFile = klass;
      byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
    }
  }
  // A marker-less file still maps its stem to itself (no domain known).
  if (!markedClassForFile && !byClass.has(stem)) {
    byClass.set(stem, { klass: stem, file: join(dir, entry) });
  }
}

export interface PersonaDirs {
  /** Baseline roles dir. Defaults to <mosaicHome>/fleet/roles. */
  rolesDir?: string;
  /** Override dir. Defaults to <mosaicHome>/fleet/roles.local. */
  overrideDir?: string;
  mosaicHome?: string;
}

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

/**
 * UNION of baseline classes and override classes. Overrides may ADD entirely new
 * classes not present in the baseline, so callers (e.g. profile roster
 * validation) treat a user-added persona as a real class.
 */
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const [base, over] = await Promise.all([
    extractClassesFromDir(rolesDir),
    extractClassesFromDir(overrideDir),
  ]);
  const union = new Set<string>(base.classes);
  for (const c of over.classes) union.add(c);
  return union;
}

export type PersonaStatus = 'baseline' | 'overridden' | 'custom';

export interface PersonaResolution {
  klass: string;
  layer: PersonaLayer;
  /** The file the resolved persona was read from (override wins). */
  file: string;
  content: string;
  domain?: string;
}

/**
 * Resolve a persona class to its winning definition: the override file if
 * roles.local/ defines that class, else the baseline. Match by inline `class:`
 * marker first, then by filename stem (roles.local/<klass>.md) as a fallback.
 * Returns null if neither layer defines the class.
 */
export async function resolvePersona(
  klass: string,
  opts: PersonaDirs = {},
): Promise<PersonaResolution | null> {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const [base, over] = await Promise.all([
    extractClassesFromDir(rolesDir),
    extractClassesFromDir(overrideDir),
  ]);
  return resolvePersonaFrom(klass, { rolesDir, overrideDir, base, over });
}

/**
 * Resolve a single class against ALREADY-EXTRACTED layer maps. Callers that
 * resolve many classes against the same two directories (e.g. provisioning a
 * full roster) should {@link extractClassesFromDir} each dir ONCE and reuse the
 * result here, rather than paying a full directory re-scan per class. Precedence
 * is identical to {@link resolvePersona}: override layer wins, then baseline.
 */
export async function resolvePersonaFrom(
  klass: string,
  layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
): Promise<PersonaResolution | null> {
  const { rolesDir, overrideDir, base, over } = layers;

  const fromLayer = async (
    dir: string,
    extracted: DirClasses,
    layer: PersonaLayer,
  ): Promise<PersonaResolution | null> => {
    // Prefer the marker-defined file; fall back to the filename stem.
    let pf = extracted.byClass.get(klass);
    if (!pf) {
      const byName = join(dir, `${klass}.md`);
      if (!extracted.classes.has(klass)) return null;
      // Class known only via filename/LIBRARY: read the stem file if present.
      try {
        const content = await readFile(byName, 'utf8');
        const dm = DOMAIN_MARKER.exec(content);
        return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
      } catch {
        return null;
      }
    }
    try {
      const content = await readFile(pf.file, 'utf8');
      return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
    } catch {
      return null;
    }
  };

  return (
    (await fromLayer(overrideDir, over, 'override')) ??
    (await fromLayer(rolesDir, base, 'baseline'))
  );
}

/**
 * Synchronous twin of {@link resolvePersona} — same override-wins precedence
 * (roles.local/ beats roles/, by marker first then filename stem), returning
 * null if neither layer defines the class. Exists for the synchronous launch
 * prompt path (composeContract → readPersonaContractBlock) which cannot await.
 * Keeping it here, beside the async resolver, keeps the resolution semantics in
 * one module so the launch-time and command-time resolutions never diverge.
 */
export function resolvePersonaSync(
  klass: string,
  opts: PersonaDirs = {},
): PersonaResolution | null {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const base = extractClassesFromDirSync(rolesDir);
  const over = extractClassesFromDirSync(overrideDir);

  const fromLayer = (
    dir: string,
    extracted: DirClasses,
    layer: PersonaLayer,
  ): PersonaResolution | null => {
    // Prefer the marker-defined file; fall back to the filename stem.
    const pf = extracted.byClass.get(klass);
    if (!pf) {
      if (!extracted.classes.has(klass)) return null;
      const byName = join(dir, `${klass}.md`);
      try {
        const content = readFileSync(byName, 'utf8');
        const dm = DOMAIN_MARKER.exec(content);
        return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
      } catch {
        return null;
      }
    }
    try {
      const content = readFileSync(pf.file, 'utf8');
      return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
    } catch {
      return null;
    }
  };

  return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline');
}

export interface PersonaStatusEntry {
  klass: string;
  status: PersonaStatus;
  domain?: string;
}

/**
 * Classify every known class:
 *   - baseline   — present only in roles/
 *   - overridden — present in BOTH roles/ and roles.local/ (override wins)
 *   - custom     — present only in roles.local/ (user-added)
 * Domain is taken from the WINNING layer (override domain wins if present).
 */
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const [base, over] = await Promise.all([
    extractClassesFromDir(rolesDir),
    extractClassesFromDir(overrideDir),
  ]);

  const all = new Set<string>([...base.classes, ...over.classes]);
  const domainOf = (extracted: DirClasses, klass: string): string | undefined =>
    extracted.byClass.get(klass)?.domain;

  const entries: PersonaStatusEntry[] = [];
  for (const klass of all) {
    const inBase = base.classes.has(klass);
    const inOver = over.classes.has(klass);
    const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline';
    const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass);
    entries.push({ klass, status, ...(domain ? { domain } : {}) });
  }
  entries.sort((a, b) => a.klass.localeCompare(b.klass));
  return entries;
}

// ─── CLI: `mosaic fleet persona <list|show|customize>` ───────────────────────

function printPersonaList(entries: PersonaStatusEntry[]): void {
  if (entries.length === 0) {
    console.log('(no personas)');
    return;
  }
  for (const e of entries) {
    console.log(`${e.klass}\t[${e.status}]\tdomain=${e.domain ?? '-'}`);
  }
}

/** Minimal override scaffold for a brand-new (no-baseline) class. */
function scaffoldOverride(klass: string): string {
  return `# ${klass} — fleet role definition (override)

The **${klass}** persona (\`class: ${klass}\`) is a user-defined override that
lives in the PRESERVE-protected \`fleet/roles.local/\` layer and survives
\`mosaic update\`. Edit this file to define the persona's mandate and boundaries.

## Mandate

1. (describe what this persona owns)

## Boundaries

- (describe what this persona does NOT do)
`;
}

/**
 * Register `persona` under an existing `fleet` command. `mosaicHomeFor` resolves
 * the active --mosaic-home (parent flag) at call time, mirroring the backlog and
 * profile subcommand wiring.
 */
export function registerFleetPersonaCommand(
  fleetCmd: Command,
  mosaicHomeFor: () => string,
): Command {
  const personaCmd = fleetCmd
    .command('persona')
    .description('Update-surviving persona overrides: baseline ⊕ roles.local layer (H4)');

  personaCmd
    .command('list')
    .description('List every persona class with its status (baseline/overridden/custom) and domain')
    .option('--json', 'Print JSON')
    .action(async (opts: { json?: boolean }) => {
      try {
        const entries = await personaStatus({ mosaicHome: mosaicHomeFor() });
        if (opts.json) {
          console.log(JSON.stringify(entries));
          return;
        }
        printPersonaList(entries);
      } catch (err) {
        process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
        process.exitCode = 1;
      }
    });

  personaCmd
    .command('show <class>')
    .description('Show the RESOLVED persona (override wins) and which layer it came from')
    .option('--json', 'Print JSON')
    .action(async (klass: string, opts: { json?: boolean }) => {
      try {
        const resolved = await resolvePersona(klass, { mosaicHome: mosaicHomeFor() });
        if (!resolved) {
          process.stderr.write(`Unknown persona class "${klass}"\n`);
          process.exitCode = 1;
          return;
        }
        if (opts.json) {
          console.log(JSON.stringify(resolved));
          return;
        }
        console.log(`# class: ${resolved.klass}`);
        console.log(`# layer: ${resolved.layer}`);
        console.log(`# domain: ${resolved.domain ?? '-'}`);
        console.log(`# file: ${resolved.file}`);
        console.log('');
        console.log(resolved.content);
      } catch (err) {
        process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
        process.exitCode = 1;
      }
    });

  personaCmd
    .command('customize <class>')
    .description(
      'Copy the baseline persona into fleet/roles.local/ to edit (override layer). ' +
        '--new scaffolds a brand-new persona with no baseline.',
    )
    .option('--new', 'Scaffold a minimal override for a brand-new class (no baseline required)')
    .action(async (klass: string, opts: { new?: boolean }) => {
      try {
        const { mkdir, writeFile, copyFile, access } = await import('node:fs/promises');
        const { constants } = await import('node:fs');
        const mosaicHome = mosaicHomeFor();
        const rolesDir = defaultRolesDir(mosaicHome);
        const overrideDir = defaultOverrideDir(mosaicHome);
        const target = join(overrideDir, `${klass}.md`);

        await mkdir(overrideDir, { recursive: true });

        // Do not clobber an existing override.
        try {
          await access(target, constants.F_OK);
          console.log(`Override already exists, not clobbering: ${target}`);
          return;
        } catch {
          // not present — proceed
        }

        if (opts.new) {
          await writeFile(target, scaffoldOverride(klass), 'utf8');
          console.log(`Scaffolded new persona override: ${target}`);
          return;
        }

        // Copy the baseline. Prefer the marker-defining file; fall back to stem.
        const base = await extractClassesFromDir(rolesDir);
        const pf = base.byClass.get(klass);
        const source = pf?.file ?? join(rolesDir, `${klass}.md`);
        try {
          await access(source, constants.F_OK);
        } catch {
          process.stderr.write(
            `No baseline persona "${klass}" to copy. Use --new to scaffold one.\n`,
          );
          process.exitCode = 1;
          return;
        }
        await copyFile(source, target);
        console.log(`Copied baseline persona to override layer: ${target}`);
        console.log('Edit it there; it wins over the baseline and survives `mosaic update`.');
      } catch (err) {
        process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
        process.exitCode = 1;
      }
    });

  return personaCmd;
}
