/**
 * SET 75 — the watcher: a DUMB agent in the agentic sense (Andy, 2026-07-27:
 * "not an LLM watcher agent, a dumb agent … and an LLM just powers it every
 * applicable epoch-cycle").
 *
 * Everything about the agent is deterministic: when it fires (epoch boundary),
 * what it reads (the supergit ledger, whole-artifact), how it digests that
 * into evidence, what shape it may emit, and what happens to the output
 * (advisory context page — never a validator, never auto-admitted). The LLM
 * supplies ONE interpretation per epoch over a digest it did not choose.
 *
 * This is L2 doctrine turned on the run's own history instead of a single
 * finding: the digest is the schema, the ledger is the evidence, and the
 * model executes a procedure rather than reasoning free-hand.
 */
import type { LedgerRow } from '../core/types';

/** Epoch label — the deterministic firing points. */
export type WatchEpoch = 'ir' | 'build' | 'rha';

export interface ArtifactShape {
  path: string;
  bytes: number;
  exports: string[];
}

/**
 * Deterministic digest of the run so far. Computed from the ledger + the
 * artifact with ZERO model involvement; useful on its own even when the
 * model call is unavailable or unparseable.
 */
export interface EpochDigest {
  epoch: WatchEpoch;
  /** The node this synthesis is FOR — context is assembled at the moment it
   * is salient, so the digest leads with that feature's own history. */
  featureId: string | null;
  /** This feature's own ledger trail (most recent first). */
  nodeTrail: string[];
  nodes: number;
  /** classKey → occurrences, most frequent first. */
  recurringClasses: { classKey: string; count: number }[];
  /** Repairs that healed a class which then recurred — the oscillation set. */
  oscillating: { classKey: string; heals: number; recurrences: number }[];
  /** Fixes attributed by layer, from repair-node summaries. */
  layerMix: { L1: number; L2: number; L3: number; L4: number };
  /** Features with the widest forward blast radius (dependency edges out). */
  hotDependencies: { featureId: string; downstream: number }[];
  /** Findings still open at this epoch. */
  unhealed: { featureId: string; classKey: string; summary: string }[];
  /** Whole-artifact shape: what actually exists right now. */
  artifact: ArtifactShape[];
}

const LAYER_RE = /^(L[1-4])\b/;

/** Build the digest. Pure, deterministic, 0 tokens. */
export function buildEpochDigest(opts: {
  epoch: WatchEpoch;
  rows: readonly LedgerRow[];
  artifact: { path: string; content: string }[];
  /** Scope the synthesis to the node whose draw is about to happen. */
  featureId?: string | null;
}): EpochDigest {
  const { rows } = opts;
  const featureId = opts.featureId ?? null;
  const nodeTrail = featureId
    ? rows.filter(r => r.featureId === featureId).slice(-8).reverse()
      .map(r => `${r.id} [${r.type}${r.bugClass ? ' ' + r.bugClass : ''}${r.status ? ' ' + r.status : ''}] ${r.summary}`)
    : [];
  const errors = rows.filter(r => r.type === 'error');
  const repairs = rows.filter(r => r.type === 'repair');

  const counts = new Map<string, number>();
  for (const e of errors) {
    if (!e.bugClass) continue;
    counts.set(e.bugClass, (counts.get(e.bugClass) ?? 0) + 1);
  }
  const recurringClasses = [...counts.entries()]
    .map(([classKey, count]) => ({ classKey, count }))
    .sort((a, b) => b.count - a.count || a.classKey.localeCompare(b.classKey))
    .slice(0, 12);

  // Oscillation: a class healed at least once that keeps coming back.
  const healsByClass = new Map<string, number>();
  for (const r of repairs) {
    if (!r.bugClass) continue;
    healsByClass.set(r.bugClass, (healsByClass.get(r.bugClass) ?? 0) + 1);
  }
  const oscillating = [...healsByClass.entries()]
    .map(([classKey, heals]) => ({ classKey, heals, recurrences: counts.get(classKey) ?? 0 }))
    .filter(o => o.heals >= 1 && o.recurrences > o.heals)
    .sort((a, b) => (b.recurrences - b.heals) - (a.recurrences - a.heals))
    .slice(0, 8);

  const layerMix = { L1: 0, L2: 0, L3: 0, L4: 0 };
  for (const r of repairs) {
    const m = LAYER_RE.exec(r.summary);
    if (m) layerMix[m[1] as keyof typeof layerMix]++;
  }

  // Forward blast radius per feature, from edge rows → spec-node features.
  const specFeature = new Map<string, string>();
  for (const r of rows) if (r.type === 'spec' && r.featureId) specFeature.set(r.id, r.featureId);
  const downstreamCount = new Map<string, number>();
  for (const r of rows) {
    if (r.type !== 'edge' || !r.edgeFrom) continue;
    const fid = specFeature.get(r.edgeFrom);
    if (!fid) continue;
    downstreamCount.set(fid, (downstreamCount.get(fid) ?? 0) + 1);
  }
  const hotDependencies = [...downstreamCount.entries()]
    .map(([featureId, downstream]) => ({ featureId, downstream }))
    .sort((a, b) => b.downstream - a.downstream || a.featureId.localeCompare(b.featureId))
    .slice(0, 6);

  // SET 89 §10.4 — open findings are inferred from RESOLUTION nodes, not
  // repair applications. A repair being applied is not evidence the defect
  // is resolved; a later authoritative measurement in the same scope
  // (feature) that no longer contains the defect identity is.
  //
  // Fallback: when the ledger predates SET 89 (no `resolution` rows exist
  // in this run), the older heuristic (later repair on same feature+class)
  // remains — historical runs must still render as best-effort.
  const resolutions = rows.filter(r => r.type === 'resolution');
  const hasResolutionRows = resolutions.length > 0;
  const unhealed: EpochDigest['unhealed'] = [];
  for (const e of errors) {
    let healed = false;
    if (hasResolutionRows) {
      // A resolution row's causeId links to the CAUSE error node — the one
      // that identifies the resolved defect. A resolution whose causeId
      // equals this error's id, or one that is later in the same feature +
      // bugClass scope, counts as healing.
      healed = resolutions.some(res =>
        (res.causeId && res.causeId === e.id)
        || (res.featureId === e.featureId && res.step > e.step),
      );
    } else {
      healed = repairs.some(r =>
        r.featureId === e.featureId && r.bugClass === e.bugClass && r.step > e.step);
    }
    if (!healed && e.featureId) {
      unhealed.push({ featureId: e.featureId, classKey: e.bugClass ?? 'unclassified', summary: e.summary });
    }
  }

  const artifact = opts.artifact.map(f => ({
    path: f.path,
    bytes: f.content.length,
    exports: [...f.content.matchAll(/export\s+(?:async\s+)?(?:function|class|const|type|interface)\s+([A-Za-z_$][\w$]*)/g)]
      .map(m => m[1]!)
      .slice(0, 12),
  }));

  return {
    epoch: opts.epoch,
    featureId,
    nodeTrail,
    nodes: rows.length,
    recurringClasses,
    oscillating,
    layerMix,
    hotDependencies,
    unhealed: unhealed.slice(0, 12),
    artifact,
  };
}

/** Human/model-readable rendering of the digest — the agent's fixed input. */
export function formatDigest(d: EpochDigest): string {
  const lines: string[] = [
    `Supergit state at "${d.epoch}"${d.featureId ? ` for ${d.featureId}` : ''} — ${d.nodes} ledger nodes.`,
  ];
  if (d.nodeTrail.length) {
    lines.push(`This node's trail (newest first):`, ...d.nodeTrail.map(t => `  ${t}`));
  }
  // SET 77 — decipherable: a bare `L1=3 L2=1` says nothing about whether the
  // run is healing cheaply or burning entropy. State the share and its meaning.
  {
    const mix = d.layerMix;
    const total = mix.L1 + mix.L2 + mix.L3 + mix.L4;
    if (total === 0) {
      lines.push('No repairs have been accepted yet.');
    } else {
      const share = (n: number) => `${n} of ${total}`;
      lines.push(
        `Repairs accepted so far: ${share(mix.L1)} were deterministic substrate fixes costing no tokens, `
        + `${share(mix.L2)} came from schema-guided draws, `
        + `${share(mix.L3)} from free permutation batches, and `
        + `${share(mix.L4)} required discarding the implementation entirely (entropy reset).`
        + (mix.L4 > mix.L1
          ? ' Whole-file rewrites outnumber mechanical fixes — the substrate is not catching this run.'
          : ''),
      );
    }
  }
  if (d.recurringClasses.length) {
    lines.push('Failure classes seen, by number of separate sightings: '
      + d.recurringClasses.map(c => `${c.classKey} (${c.count})`).join(', ')
      + '.');
  }
  if (d.oscillating.length) {
    for (const o of d.oscillating) {
      lines.push(`${o.classKey} was repaired ${o.heals} time${o.heals === 1 ? '' : 's'} `
        + `but has been seen ${o.recurrences} times — the fix is not holding.`);
    }
  }
  if (d.hotDependencies.length) {
    lines.push('Blast radius: '
      + d.hotDependencies.map(h => `changing ${h.featureId} affects ${h.downstream} dependent node${h.downstream === 1 ? '' : 's'}`).join('; ')
      + '.');
  }
  if (d.unhealed.length) {
    lines.push(`Still failing with no repair recorded: `
      + d.unhealed.map(u => `${u.featureId} (${u.classKey})`).join(', ') + '.');
  }
  if (d.artifact.length) {
    lines.push('Artifact as it stands: '
      + d.artifact.map(a => `${a.path} is ${a.bytes} bytes and exports ${a.exports.length ? a.exports.join(', ') : 'NOTHING'}`).join('; ')
      + '.');
  }
  return lines.join('\n');
}

/**
 * SET 78 — the agent synthesizes IN FULL on every trigger (Andy, 2026-07-27:
 * "Did you make an arbitrary threshold as to how much was substantial enough
 * to matter? It needs to be every single time"). SET 76 gated the model
 * reading behind an invented staleness threshold; that gate is gone. Context
 * submitted to a model is always freshly synthesized — digest AND reading.
 */

export const WATCH_SYSTEM = [
  'You are a static observer of a code-generation run. You do not repair, choose, or instruct.',
  'From the deterministic digest, name the recurring IDIOMS (habits the generator repeats),',
  'TROPES (failure shapes that keep reappearing), and SCHEMES (structural strategies that are working).',
  'Every claim must cite evidence present in the digest. Say only what the digest supports.',
  'Return JSON only.',
].join(' ');

export function watchPrompt(d: EpochDigest): string {
  return [
    formatDigest(d),
    '',
    'Report in EXACTLY this format, one item per line, no prose:',
    'IDIOM: <habit> — <evidence from the digest>',
    'TROPE: <recurring failure shape> — <evidence>',
    'SCHEME: <structural strategy that is working> — <evidence>',
    'UNION: <one-sentence state of the union>',
  ].join('\n');
}

export interface WatchReport {
  idioms: string[];
  tropes: string[];
  schemes: string[];
  union: string;
}

/** Deterministic parse. Anything unparseable yields null → digest-only. */
export function parseWatchReport(raw: string): WatchReport | null {
  const grab = (tag: string) => raw
    .split('\n')
    .map(l => l.trim())
    .filter(l => l.toUpperCase().startsWith(`${tag}:`))
    .map(l => l.slice(tag.length + 1).trim())
    .filter(Boolean)
    .slice(0, 6);
  const idioms = grab('IDIOM');
  const tropes = grab('TROPE');
  const schemes = grab('SCHEME');
  const union = grab('UNION')[0] ?? '';
  if (!idioms.length && !tropes.length && !schemes.length && !union) return null;
  return { idioms, tropes, schemes, union };
}

/**
 * The advisory page. Framed as OBSERVATION, never instruction — a watcher
 * that started dictating repairs would be an LLM judge by the back door.
 */
/** Hard ceiling for the advisory union page (chars). */
export const UNION_PAGE_CAP = 340;

export function formatUnionPage(d: EpochDigest, r: WatchReport | null): string {
  const head = `Page union — observation only, not instruction:`;
  // Most actionable first: what the model can act on (oscillation, open
  // findings, a one-line reading) before the general accounting.
  const body: string[] = [];
  if (r?.union) body.push(r.union);
  for (const o of d.oscillating.slice(0, 2)) {
    body.push(`${o.classKey}: repaired ${o.heals}× but seen ${o.recurrences}× — the fix is not holding.`);
  }
  if (d.unhealed.length) {
    body.push(`Still failing: ${d.unhealed.slice(0, 4).map(u => `${u.featureId} (${u.classKey})`).join(', ')}.`);
  }
  for (const t of (r?.tropes ?? []).slice(0, 2)) body.push(`Trope: ${t}`);
  for (const i of (r?.idioms ?? []).slice(0, 1)) body.push(`Idiom: ${i}`);
  {
    const mix = d.layerMix;
    const total = mix.L1 + mix.L2 + mix.L3 + mix.L4;
    if (total > 0) {
      body.push(`Of ${total} accepted repairs, ${mix.L1} cost no tokens and ${mix.L4} needed a full rewrite.`);
    }
  }
  // SET 82 — advisory pages are BOUNDED HARD (Andy: "far too large to be
  // useful in a 2b model"). Observation must never outweigh the contract:
  // this page was the largest in the buffer, bigger than the spec itself.
  // Lines are emitted most-actionable-first, so the cap truncates the
  // least useful tail rather than the headline.
  return `${head}\n${body.join('\n')}`.slice(0, UNION_PAGE_CAP);
}

/**
 * SET 88 — `engine-retired` is gone, and SET 88f removed `engine-backoff` with
 * it. There is no state in which the engine is not attempted: it either read,
 * returned something unparseable, threw, or was never there.
 */
export type WatchDisposition =
  | 'read' | 'unreadable' | 'no-engine' | 'engine-failed';

export function formatWatchEvent(
  d: EpochDigest,
  r: WatchReport | null,
  disposition: WatchDisposition,
  /**
   * SET 88 — the failure text. The old signature had no way to carry it, so
   * every failure line named the fact and never the cause, and the run log
   * could not answer "why did the watcher die".
   */
  detail?: string,
): string {
  const who = `${d.epoch}${d.featureId ? `/${d.featureId}` : ''}`;
  if (disposition === 'read' && r) {
    return `watcher synthesis @ ${who}: fresh reading — ${r.idioms.length} idioms, ${r.tropes.length} tropes, ${r.schemes.length} schemes over ${d.nodes} ledger nodes (advisory)`;
  }
  if (disposition === 'unreadable') {
    return `watcher synthesis @ ${who}: reading taken but unparseable — digest stands over ${d.nodes} ledger nodes`;
  }
  if (disposition === 'engine-failed') {
    return `watcher synthesis @ ${who}: reading engine threw — ${detail ?? 'unknown error'} — digest stands over ${d.nodes} ledger nodes`;
  }
  return `watcher synthesis @ ${who}: digest over ${d.nodes} ledger nodes (no reading engine on this client)`;
}
