/**
 * OpenAI-compatible local LLM client (Ollama, LM Studio, etc.).
 *
 * Set LOCAL_LLM=1 to activate. Configure:
 *   LOCAL_LLM_URL   — base URL of the local server (default: http://localhost:11434/v1)
 *   LOCAL_LLM_MODEL — model name (default: qwen2.5-coder:32b)
 *
 * The local model must support json_schema response_format. Tested with
 * Ollama >= 0.3 and qwen2.5-coder, deepseek-coder-v2, llama3.1 variants.
 * LM Studio (>= 0.3.5) also works — set LOCAL_LLM_URL=http://localhost:1234/v1.
 */
import type {
  DebugSink,
  DrawOpts,
  FeatureCandidate,
  FeatureNode,
  FeatureProbe,
  FeatureSpec,
  GeneratedFile,
  LlmCallRecord,
  LlmClient,
  RepairInput,
  UsageSink,
} from './types';
import { formatLlmCallEvent } from './llmCallLog';
import {
  BUILD_SYSTEM,
  CONCEPT_SYSTEM,
  ENCODE_SYSTEM,
  FEATURE_ENUM_SYSTEM,
  FEATURE_PROBE_SYSTEM,
  FEATURE_REPAIR_SYSTEM,
  IR_CASE_FIX_SYSTEM,
  IR_SYSTEM,
  RHA_SYSTEM,
  STUB_FIX_SYSTEM,
  buildUser,
  conceptUser,
  encodeUser,
  featureEnumUser,
  featureProbeUser,
  irCaseFixUser,
  irUser,
  repairFeaturesUser,
  rhaUser,
  stubFixUser,
} from './prompts';
import { CONCEPT_DRAW_TEMP, sanitizeConceptPage } from '../tier1/superbuffer';
import { WATCH_SYSTEM } from '../tier1/watcher';
import { STUB_WIRE_SCHEMA, assembleSpecStubs } from './stubScaffold';
import type { WireFeature } from './stubScaffold';
import { assembleTestFile } from './irScaffold';
import type { IRCaseGen, IRImport, IRParts } from './irScaffold';
import { probeHostStatus, resolveActiveModel } from './lmsModels';
import type { HostStatus } from './lmsModels';
import {
  adaptiveQuietProbeMs,
  connectionLostMessage,
  createDegradationState,
  createTokenCeilingState,
  formatElapsedShort,
  formatHangEvidenceLine,
  HANG_MEDIAN_MIN_SAMPLES,
  median,
  observeThroughput,
  observeTokenSpend,
  quietProbeMessage,
  recoveredAfterMessage,
  STAGE_TRANSITION_SPACING_MS,
  tokensPerSec,
  type HangResolved,
  type ProviderEventSink,
  type TokenCeilingState,
} from './providerWatch';

// Module-level env reads must survive browser bundling (no `process` global
// in the extension service worker) — a bare `process.env` here kills the
// whole worker at import time.
const MODULE_ENV: Record<string, string | undefined> =
  typeof process !== 'undefined' ? process.env : {};
const REQUEST_TIMEOUT_MS = Number(MODULE_ENV.LOCAL_LLM_TIMEOUT_MS || 240_000);
const MAX_RETRIES = Math.max(1, Number(MODULE_ENV.LOCAL_LLM_RETRIES || 4));
/**
 * Legacy env absolute quiet override. When unset, SET 40h adaptive per-stage
 * thresholds apply (encode floor 90s; other stages floor 45s).
 */
const QUIET_PROBE_ENV = MODULE_ENV.LOCAL_LLM_QUIET_MS;
const MAX_DESYNC_REISSUES = 2;
const STAGE_DURATION_WINDOW = 32;

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export interface LocalLlmOpts {
  baseURL?: string;
  apiKey?: string;
  model?: string;
  /** SET 22 — provider lifecycle events (desync / retry / degraded / pause). */
  onProviderEvent?: ProviderEventSink;
  /** Cooperative cancel — never triggered by watchdog. */
  isCancelled?: () => boolean;
  /** SET 22d — token-ceiling pause gate (host sets true; Resume clears). */
  isPaused?: () => boolean;
  /** Called when the client pauses issuance (runaway token burn). */
  onPause?: () => void;
  /** Wait until resume or cancel while paused. */
  awaitResume?: () => Promise<void>;
  /** Override host probe (tests). */
  probeHost?: () => Promise<HostStatus>;
  /** Token ceiling multiplier (default 3× high-water estimate). */
  tokenCeilingMultiplier?: number;
  /** Explicit high-water token estimate (tests / config). */
  tokenHighWaterEstimate?: number;
  /**
   * Absolute quiet-probe override (disables adaptive). Tests use short values;
   * production leaves this unset so SET 40h per-stage medians apply.
   */
  quietProbeMs?: number;
  /** Seed completed call durations per stage (tests / warm start). */
  seedStageDurations?: Partial<Record<LlmCallRecord['op'], number[]>>;
}

type FetchResult = { res: Response; retries: number };

/**
 * Resilient fetch: a local model server under load drops sockets, reloads
 * models, or hangs — one bare `fetch failed` must not nuke a 20-minute run.
 * Retries transient transport errors and 502/503/504 with exponential backoff,
 * and bounds each attempt with a timeout so a hung server can't stall forever.
 * SET 22b — every retry emits a visible provider event.
 */
async function resilientFetch(
  url: string,
  init: RequestInit,
  onEvent?: ProviderEventSink,
  externalSignal?: AbortSignal,
): Promise<FetchResult> {
  let lastErr = '';
  let retries = 0;
  let lostAt: number | null = null;
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
    if (externalSignal?.aborted) {
      throw new Error('local LLM call aborted (desync reissue)');
    }
    const ctrl = new AbortController();
    const onAbort = () => ctrl.abort();
    externalSignal?.addEventListener('abort', onAbort, { once: true });
    const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
    try {
      const res = await fetch(url, { ...init, signal: ctrl.signal });
      clearTimeout(timer);
      externalSignal?.removeEventListener('abort', onAbort);
      if (res.status >= 502 && res.status <= 504 && attempt < MAX_RETRIES) {
        lastErr = `HTTP ${res.status}`;
      } else {
        if (lostAt != null) {
          onEvent?.(recoveredAfterMessage(Date.now() - lostAt));
        }
        return { res, retries };
      }
    } catch (e) {
      clearTimeout(timer);
      externalSignal?.removeEventListener('abort', onAbort);
      if (externalSignal?.aborted) {
        throw new Error('local LLM call aborted (desync reissue)');
      }
      lastErr = e instanceof Error ? (e.name === 'AbortError' ? `timeout after ${REQUEST_TIMEOUT_MS}ms` : e.message) : String(e);
      if (attempt >= MAX_RETRIES) break;
    }
    retries += 1;
    if (lostAt == null) lostAt = Date.now();
    onEvent?.(connectionLostMessage(attempt, MAX_RETRIES));
    const wait = Math.min(8000, 500 * 2 ** attempt);
    console.warn(`[localLlm] ${url} attempt ${attempt}/${MAX_RETRIES} failed (${lastErr}); retrying in ${wait}ms`);
    await sleep(wait);
  }
  throw new Error(`local LLM unreachable after ${MAX_RETRIES} attempts: ${lastErr}`);
}

export class LocalLlmClient implements LlmClient {
  private baseURL: string;
  private apiKey: string;
  private modelOverride: string;
  private modelPromise: Promise<string>;
  private onProviderEvent?: ProviderEventSink;
  private isCancelled?: () => boolean;
  private isPaused?: () => boolean;
  private onPause?: () => void;
  private awaitResume?: () => Promise<void>;
  private probeHost: () => Promise<HostStatus>;
  /** Absolute override; null → SET 40h adaptive per-stage threshold. */
  private quietProbeOverride: number | null;
  private stageDurations = new Map<LlmCallRecord['op'], number[]>();
  private lastOp: LlmCallRecord['op'] | null = null;
  private degradation = createDegradationState();
  private ceiling: TokenCeilingState;
  /** Problems surfaced by provider watch (warn-severity). */
  readonly providerProblems: string[] = [];

  constructor(
    private onUsage?: UsageSink,
    private onDebug?: DebugSink,
    opts?: LocalLlmOpts,
  ) {
    // Commercial providers (OpenAI, Gemini OpenAI-compat, OpenRouter) are this
    // same client + bearer key + explicit model. Local servers need neither.
    // env is absent in the browser (Tier 1 extension bundle).
    const env: Record<string, string | undefined> =
      typeof process !== 'undefined' ? process.env : {};
    this.baseURL = (opts?.baseURL || env.LOCAL_LLM_URL || 'http://localhost:1234/v1').replace(/\/$/, '');
    this.apiKey = opts?.apiKey ?? env.LLM_API_KEY ?? '';
    this.modelOverride = opts?.model ?? env.LOCAL_LLM_MODEL ?? '';
    this.modelPromise = this.resolveModel();
    this.onProviderEvent = opts?.onProviderEvent;
    this.isCancelled = opts?.isCancelled;
    this.isPaused = opts?.isPaused;
    this.onPause = opts?.onPause;
    this.awaitResume = opts?.awaitResume;
    this.probeHost = opts?.probeHost
      ?? (() => probeHostStatus(this.baseURL, this.apiKey || undefined));
    // Explicit opt wins; else env absolute; else adaptive (null).
    if (opts?.quietProbeMs != null) {
      this.quietProbeOverride = opts.quietProbeMs;
    } else if (QUIET_PROBE_ENV != null && QUIET_PROBE_ENV !== '') {
      this.quietProbeOverride = Number(QUIET_PROBE_ENV) || null;
    } else {
      this.quietProbeOverride = null;
    }
    if (opts?.seedStageDurations) {
      for (const [op, samples] of Object.entries(opts.seedStageDurations)) {
        if (!samples?.length) continue;
        this.stageDurations.set(
          op as LlmCallRecord['op'],
          samples.slice(-STAGE_DURATION_WINDOW),
        );
      }
    }
    this.ceiling = createTokenCeilingState({
      multiplier: opts?.tokenCeilingMultiplier,
      estimate: opts?.tokenHighWaterEstimate,
    });
  }

  /** SET 40h — quiet probe ms for a stage (adaptive unless overridden). */
  quietProbeMsFor(op: LlmCallRecord['op']): number {
    if (this.quietProbeOverride != null) return this.quietProbeOverride;
    const samples = this.stageDurations.get(op) ?? [];
    const med = samples.length ? median(samples) : null;
    return adaptiveQuietProbeMs(op, med);
  }

  private stageMedianMs(op: LlmCallRecord['op']): number | null {
    const samples = this.stageDurations.get(op) ?? [];
    if (samples.length < HANG_MEDIAN_MIN_SAMPLES) return null;
    return median(samples);
  }

  private recordStageDuration(op: LlmCallRecord['op'], ms: number): void {
    if (!Number.isFinite(ms) || ms <= 0) return;
    let arr = this.stageDurations.get(op);
    if (!arr) {
      arr = [];
      this.stageDurations.set(op, arr);
    }
    arr.push(ms);
    if (arr.length > STAGE_DURATION_WINDOW) arr.shift();
  }

  private hangTarget(featureId: string | null, purpose: string): string {
    const raw = (featureId && featureId.trim()) || purpose || 'call';
    return raw.replace(/\s+/g, ' ').trim().slice(0, 48);
  }

  /**
   * Emit hang evidence when wall time exceeds 3× stage median.
   * Uses samples recorded *before* this call (caller must not have recorded yet).
   */
  private maybeEmitHang(
    op: LlmCallRecord['op'],
    target: string,
    durationMs: number,
    resolved: HangResolved,
  ): void {
    const med = this.stageMedianMs(op);
    if (med == null || med <= 0) return;
    if (durationMs <= 3 * med) return;
    this.emit(formatHangEvidenceLine(op, target, durationMs, resolved));
  }

  private authHeaders(): Record<string, string> {
    return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
  }

  private emit(msg: string) {
    this.onProviderEvent?.(msg);
  }

  private async resolveModel(): Promise<string> {
    // Explicit vault override = hard pin (user's deliberate choice).
    if (this.modelOverride) return this.modelOverride;
    // Otherwise FOLLOW THE LOADED MODEL — the user's load button in LM Studio
    // is the control surface. Never list order (silent swaps), never a
    // hardcoded id (control stolen). Resolved per job; recorded per call.
    const active = await resolveActiveModel(this.baseURL, this.apiKey || undefined);
    if (!active) {
      throw new Error(
        `no model loaded on ${this.baseURL} — load one in LM Studio (RHA follows the loaded model), or set an explicit model override in Provider Vault`,
      );
    }
    return active.id;
  }

  /** SET 22d — wait while paused; Cancel is the only kill switch. */
  private async waitIfPaused(): Promise<void> {
    while (this.isPaused?.()) {
      if (this.isCancelled?.()) throw new Error('cancelled by user');
      if (this.awaitResume) await this.awaitResume();
      else await sleep(500);
    }
  }

  /**
   * Single attempt of the HTTP call, with quiet-threshold host probe.
   * On desync (host idle while we await), aborts and returns 'desync' so the
   * caller can reissue. Never cancels the job.
   * SET 40h — quietProbeMs is per-call (adaptive stage threshold).
   */
  private async attemptFetch(
    body: unknown,
    _reissue: number,
    quietProbeMs: number,
  ): Promise<{ kind: 'ok'; res: Response; retries: number } | { kind: 'desync'; quietMs: number }> {
    const ctrl = new AbortController();
    const started = Date.now();
    let lastQuietMsg = '';
    let desyncTriggered = false;
    const runProbe = () => {
      void (async () => {
        if (ctrl.signal.aborted) return;
        const quietMs = Date.now() - started;
        if (quietMs < quietProbeMs) return;
        let status: HostStatus = 'unreachable';
        try {
          status = await this.probeHost();
        } catch {
          status = 'unreachable';
        }
        const msg = quietProbeMessage(status, quietMs);
        if (msg !== lastQuietMsg) {
          lastQuietMsg = msg;
          this.emit(msg);
        }
        if (status === 'idle') {
          desyncTriggered = true;
          ctrl.abort();
        }
        // busy / unreachable: honest wait; never cancel.
      })();
    };
    const firstProbe = setTimeout(runProbe, quietProbeMs);
    const probeTimer = setInterval(
      runProbe,
      Math.min(30_000, Math.max(500, quietProbeMs)),
    );
    const clearProbes = () => {
      clearTimeout(firstProbe);
      clearInterval(probeTimer);
    };

    try {
      const { res, retries } = await resilientFetch(
        `${this.baseURL}/chat/completions`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
          body: JSON.stringify(body),
        },
        this.onProviderEvent,
        ctrl.signal,
      );
      clearProbes();
      return { kind: 'ok', res, retries };
    } catch (e) {
      clearProbes();
      const quietMs = Date.now() - started;
      if (desyncTriggered || (ctrl.signal.aborted && quietMs >= quietProbeMs)) {
        return { kind: 'desync', quietMs };
      }
      throw e;
    } finally {
      clearProbes();
    }
  }

  private async call<T>(
    op: LlmCallRecord['op'],
    featureId: string | null,
    system: string,
    user: string,
    schema: object,
    defaultPurpose: string,
    opts?: DrawOpts,
  ): Promise<T> {
    await this.waitIfPaused();
    if (this.isCancelled?.()) throw new Error('cancelled by user');

    // SET 40h — inter-call spacing at phase transitions (finalize-race probe).
    if (this.lastOp != null && this.lastOp !== op && !opts?.bypassStageSpacing) {
      this.emit(`stage-transition spacing applied (${this.lastOp}→${op})`);
      await sleep(STAGE_TRANSITION_SPACING_MS);
    }
    this.lastOp = op;

    const temperature = opts?.temperature;
    const purpose = opts?.purpose ?? defaultPurpose;
    const target = this.hangTarget(featureId, purpose);
    const model = await this.modelPromise;
    const quietProbeMs = this.quietProbeMsFor(op);
    const started = Date.now();

    const body = {
      model,
      messages: [
        { role: 'system', content: system },
        { role: 'user', content: user },
      ],
      response_format: {
        type: 'json_schema',
        json_schema: { name: op, strict: true, schema },
      },
      stream: false,
      // Decorrelation for swarm/fresh-take draws (OpenAI-compat accepts 0..2).
      ...(temperature != null ? { temperature } : {}),
    };

    let retries = 0;
    let reissues = 0;
    let res: Response | null = null;

    try {
      for (;;) {
        await this.waitIfPaused();
        if (this.isCancelled?.()) throw new Error('cancelled by user');
        const attempt = await this.attemptFetch(body, reissues, quietProbeMs);
        if (attempt.kind === 'ok') {
          res = attempt.res;
          retries += attempt.retries;
          break;
        }
        // Desync: host idle with call in-flight — reissue identical call ≤2 times.
        reissues += 1;
        if (reissues > MAX_DESYNC_REISSUES) {
          throw new Error(
            `provider desync persisted after ${MAX_DESYNC_REISSUES} reissues (${formatElapsedShort(attempt.quietMs)})`,
          );
        }
        // Message already emitted by probe; continue with identical body.
      }

      if (!res!.ok) {
        const txt = await res!.text().catch(() => '');
        throw new Error(`Local LLM ${res!.status}: ${txt.slice(0, 300)}`);
      }

      const json = await res!.json() as {
        model?: string;
        choices: Array<{ message: { content: string } }>;
        usage?: { prompt_tokens?: number; completion_tokens?: number };
      };

      const content = json.choices?.[0]?.message?.content;
      if (!content) throw new Error('Local LLM returned empty content');

      const ms = Date.now() - started;
      const hangResolved: HangResolved = reissues > 0 ? 'reissue' : 'completed';
      this.maybeEmitHang(op, target, ms, hangResolved);
      // Only healthy completions feed the stage median (avoid hang poisoning).
      if (reissues === 0) this.recordStageDuration(op, ms);

      const inputTokens = json.usage?.prompt_tokens ?? 0;
      const outputTokens = json.usage?.completion_tokens ?? 0;
      const tps = tokensPerSec(inputTokens, outputTokens, ms);

      const deg = observeThroughput(this.degradation, tps);
      if (deg) {
        this.emit(deg);
        if (deg.startsWith('provider degraded:')) {
          this.providerProblems.push(deg);
        }
      }

      const spend = observeTokenSpend(this.ceiling, inputTokens + outputTokens);
      if (spend.message) this.emit(spend.message);
      if (spend.pause) {
        this.onPause?.();
        // Do not throw — next call will waitIfPaused. State stays intact.
      }

      const logLine = formatLlmCallEvent({
        stage: op,
        purpose,
        candidate: opts?.candidate,
        temp: temperature,
        attempt: opts?.attempt,
        attemptLabel: opts?.attemptLabel,
        inputTokens,
        outputTokens,
      });
      this.onUsage?.({
        ts: new Date().toISOString(),
        op,
        featureId,
        model: json.model ?? model,
        ms,
        inputTokens,
        outputTokens,
        cacheReadTokens: 0,
        cacheWriteTokens: 0,
        tokensPerSec: Math.round(tps * 10) / 10,
        retries,
        reissues,
        purpose,
        candidate: opts?.candidate,
        attempt: opts?.attempt,
        attemptLabel: opts?.attemptLabel,
        logLine,
      });
      this.onDebug?.({
        ts: new Date().toISOString(), op, featureId, model: json.model ?? model,
        temperature: temperature ?? null, ms,
        inputTokens, outputTokens,
        system, user, schemaName: op, responseRaw: content,
      });

      return JSON.parse(content) as T;
    } catch (e) {
      const ms = Date.now() - started;
      const msg = e instanceof Error ? e.message : String(e);
      const cancelled =
        this.isCancelled?.() === true
        || /cancelled by user/i.test(msg);
      const hangResolved: HangResolved = cancelled
        ? 'cancelled'
        : (reissues > 0 ? 'reissue' : 'completed');
      this.maybeEmitHang(op, target, ms, hangResolved);
      throw e;
    }
  }

  async enumerateFeatures(prompt: string, opts?: DrawOpts): Promise<FeatureCandidate[]> {
    const out = await this.call<{ features: FeatureCandidate[] }>(
      'encode',
      null,
      FEATURE_ENUM_SYSTEM,
      featureEnumUser(prompt),
      FEATURE_ENUM_SCHEMA,
      'enumerate features',
      opts,
    );
    return out.features;
  }

  async probeFeature(candidate: FeatureCandidate, opts?: DrawOpts): Promise<FeatureProbe> {
    return this.call<FeatureProbe>(
      'encode',
      null,
      FEATURE_PROBE_SYSTEM,
      featureProbeUser(candidate.name, candidate.scope),
      FEATURE_PROBE_SCHEMA,
      `probe "${candidate.name}"`,
      opts,
    );
  }

  async encodeFeatures(prompt: string, stubFeedback?: string, opts?: DrawOpts): Promise<FeatureSpec> {
    const out = await this.call<{ title: string; features: WireFeature[] }>(
      'encode',
      null,
      ENCODE_SYSTEM,
      encodeUser(prompt, stubFeedback),
      FEATURE_SCHEMA,
      stubFeedback ? 'encode features (stub feedback)' : 'encode features',
      opts,
    );
    return { title: out.title, prompt, features: assembleSpecStubs(out.features) };
  }

  async repairFeatures(
    spec: FeatureSpec,
    offendingIds: string[],
    problems: string[],
    opts?: DrawOpts,
  ): Promise<FeatureNode[]> {
    const out = await this.call<{ features: WireFeature[] }>(
      'encode',
      null,
      FEATURE_REPAIR_SYSTEM,
      repairFeaturesUser(spec, offendingIds, problems),
      FEATURE_REPAIR_SCHEMA,
      `repair features [${offendingIds.join(', ')}]`,
      opts,
    );
    return assembleSpecStubs(out.features ?? []);
  }

  async fixStub(
    path: string,
    ctx: { diagnostics: string; callingLines?: string; contractText?: string; checkedIdioms?: string },
    opts?: DrawOpts,
  ): Promise<string> {
    const out = await this.call<{ content: string }>(
      'fixStub',
      null,
      STUB_FIX_SYSTEM,
      stubFixUser(path, ctx),
      STUB_CONTENT_SCHEMA,
      `fix stub ${path}`,
      opts,
    );
    return out.content;
  }

  async generateTests(spec: FeatureSpec, feature: FeatureNode, feedback?: string, opts?: DrawOpts): Promise<{ content: string; parts?: IRParts }> {
    const out = await this.call<{ imports: IRImport[]; cases: IRCaseGen[] }>(
      'ir',
      feature.id,
      IR_SYSTEM,
      irUser(spec, feature, feedback),
      IR_PARTS_SCHEMA,
      `fallback fill ${feature.id}`,
      opts,
    );
    // Deterministic assembly: structure is the harness's job, not the model's.
    return { content: assembleTestFile(feature, out.imports, out.cases), parts: { imports: out.imports, cases: out.cases } };
  }

  async fixTestCases(
    spec: FeatureSpec,
    feature: FeatureNode,
    brokenCases: { name: string; kind: IRCaseGen['kind']; errors: string[] }[],
    importErrors: string[],
    currentImports: IRImport[],
    opts?: DrawOpts,
  ): Promise<{ imports?: IRImport[]; cases: IRCaseGen[] }> {
    const out = await this.call<{ imports?: IRImport[]; cases: IRCaseGen[] }>(
      'ir',
      feature.id,
      IR_CASE_FIX_SYSTEM,
      irCaseFixUser(spec, feature, brokenCases, importErrors, currentImports, opts?.avoid),
      IR_PARTS_SCHEMA,
      `fill ${brokenCases.map(c => `"${c.name}"`).join(', ')}`,
      opts,
    );
    return { imports: out.imports, cases: out.cases };
  }

  async generateImplementation(
    spec: FeatureSpec,
    feature: FeatureNode,
    frozenTest: string,
    dependencyContext: GeneratedFile[],
    opts?: DrawOpts,
  ): Promise<GeneratedFile[]> {
    const out = await this.call<{ files: GeneratedFile[] }>(
      'build',
      feature.id,
      BUILD_SYSTEM,
      buildUser(spec, feature, frozenTest, dependencyContext, opts?.avoid, opts?.context),
      FILES_SCHEMA,
      feature.files.join(', '),
      opts,
    );
    return out.files;
  }

  async repair(input: RepairInput, opts?: DrawOpts): Promise<GeneratedFile[]> {
    const out = await this.call<{ files: GeneratedFile[] }>(
      'repair',
      input.feature.id,
      RHA_SYSTEM,
      rhaUser(input),
      FILES_SCHEMA,
      input.findings[0]?.bugClass ?? 'all',
      opts,
    );
    return out.files;
  }

  /**
   * SET 37f — thin concept draw helper. High-t ideation against the spec page.
   * Returns sanitized prose for the superbuffer concept page (context only).
   */
  async drawConcept(
    specPage: string,
    feature: FeatureNode,
    opts?: DrawOpts,
  ): Promise<string> {
    const out = await this.call<{ concept: string }>(
      'concept',
      feature.id,
      CONCEPT_SYSTEM,
      conceptUser(specPage, feature),
      CONCEPT_SCHEMA,
      `concept ${feature.id}`,
      { ...opts, temperature: opts?.temperature ?? CONCEPT_DRAW_TEMP },
    );
    return sanitizeConceptPage(out.concept ?? '');
  }

  /**
   * SET 68 — unsupervised judgment ruling: ONE fresh decoupled call, t=0,
   * structured output, canonical two-line text back to the caller's parser.
   */
  async judgeRuling(evidencePrompt: string, opts?: DrawOpts): Promise<string> {
    const JUDGE_SYSTEM = [
      'You are ruling on ONE under-determined test expectation. The evidence card is complete;',
      'no other context exists. Choose exactly one action:',
      'amend — the implementation behavior is right; the case should accept it.',
      'direct — the expectation is right but unlicensed; supply the ONE missing intent line.',
      'drop — the case cannot be adjudicated; remove it (coverage reduced, honest).',
      'Return JSON only.',
    ].join(' ');
    const out = await this.call<{ action?: string; intent?: string }>(
      'judge',
      null,
      JUDGE_SYSTEM,
      evidencePrompt,
      JUDGE_SCHEMA,
      'judgment ruling',
      { ...opts, temperature: opts?.temperature ?? 0 },
    );
    return `ACTION: ${out.action ?? ''}\nINTENT: ${out.intent ?? ''}`;
  }

  /**
   * SET 75 — watcher engine: ONE observation per epoch. Static observer —
   * it names patterns, it never instructs. t=0.2 for stable naming.
   */
  async watchEpoch(digestPrompt: string, opts?: DrawOpts): Promise<string> {
    const out = await this.call<{ idioms?: string[]; tropes?: string[]; schemes?: string[]; union?: string }>(
      'watch',
      null,
      WATCH_SYSTEM,
      digestPrompt,
      WATCH_SCHEMA,
      'state of the union',
      { ...opts, temperature: opts?.temperature ?? 0.2, bypassStageSpacing: true },
    );
    return [
      ...(out.idioms ?? []).map(x => `IDIOM: ${x}`),
      ...(out.tropes ?? []).map(x => `TROPE: ${x}`),
      ...(out.schemes ?? []).map(x => `SCHEME: ${x}`),
      out.union ? `UNION: ${out.union}` : '',
    ].filter(Boolean).join('\n');
  }

  /**
   * SET 85 — one observation poll. Narrow schema: a list of {subject, claim}
   * and nothing else, because the question is singular by construction.
   */
  async observe(pollPrompt: string, system: string, opts?: DrawOpts): Promise<string> {
    const out = await this.call<{ items?: { subject?: string; claim?: string }[] }>(
      'watch',
      null,
      system,
      pollPrompt,
      OBSERVE_SCHEMA,
      'observation poll',
      { ...opts, temperature: opts?.temperature ?? 0.2 },
    );
    return (out.items ?? [])
      .filter(i => i?.subject && i?.claim)
      .map(i => `ITEM: ${i.subject} — ${i.claim}`)
      .join('\n');
  }

  /**
   * SET 13 — single raw draw: implementation + the model's own tests.
   * No harness contract is appended by the caller; op is bucketed as `vanilla`.
   */
  async vanillaDraw(prompt: string): Promise<{
    title: string;
    files: GeneratedFile[];
    tests: GeneratedFile[];
  }> {
    const VANILLA_SYSTEM = [
      'You are a coding assistant. Produce a complete TypeScript library from the user prompt.',
      'Return JSON only. Include working implementation files AND your own vitest test files.',
      'Do not omit tests. Prefer small, self-contained modules under src/ and tests/.',
    ].join(' ');
    return this.call(
      'vanilla',
      null,
      VANILLA_SYSTEM,
      prompt,
      VANILLA_SCHEMA,
      'single draw',
    );
  }
}

// ---------------------------------------------------------------------------
// JSON schemas (mirrored from llm.ts — local models need these inline)
// ---------------------------------------------------------------------------

const FEATURE_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['title', 'features'],
  properties: {
    title: { type: 'string' },
    features: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['id', 'name', 'description', 'parentId', 'deps', 'files', 'testFile', 'edgeCases', 'exports', 'envVars', 'stubs'],
        properties: {
          id: { type: 'string' },
          name: { type: 'string' },
          description: { type: 'string' },
          parentId: { anyOf: [{ type: 'string' }, { type: 'null' }] },
          deps: { type: 'array', items: { type: 'string' } },
          files: { type: 'array', items: { type: 'string' } },
          testFile: { type: 'string' },
          edgeCases: { type: 'array', items: { type: 'string' } },
          exports: {
            type: 'array',
            items: {
              type: 'object',
              additionalProperties: false,
              required: ['file', 'symbol'],
              properties: { file: { type: 'string' }, symbol: { type: 'string' } },
            },
          },
          envVars: { type: 'array', items: { type: 'string' } },
          // Signatures, not content — harness-assembled (stubScaffold).
          stubs: STUB_WIRE_SCHEMA,
        },
      },
    },
  },
};

const FEATURE_REPAIR_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['features'],
  properties: {
    features: FEATURE_SCHEMA.properties.features,
  },
};

const IR_PARTS_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['imports', 'cases'],
  properties: {
    imports: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['from', 'symbols'],
        properties: {
          from: { type: 'string' },
          symbols: { type: 'array', items: { type: 'string' } },
        },
      },
    },
    cases: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['name', 'kind', 'body'],
        properties: {
          name: { type: 'string' },
          kind: { type: 'string', enum: ['behavior', 'edge', 'integration'] },
          body: { type: 'string' },
        },
      },
    },
  },
};

const STUB_CONTENT_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['content'],
  properties: { content: { type: 'string' } },
};

const FEATURE_ENUM_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['features'],
  properties: {
    features: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['name', 'scope'],
        properties: { name: { type: 'string' }, scope: { type: 'string' } },
      },
    },
  },
};

const FEATURE_PROBE_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['clear', 'assertion', 'question'],
  properties: {
    clear: { type: 'boolean' },
    assertion: { type: 'string' },
    question: { type: 'string' },
  },
};

const FILES_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['files'],
  properties: {
    files: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['path', 'content'],
        properties: { path: { type: 'string' }, content: { type: 'string' } },
      },
    },
  },
};

/** SET 37f — concept page prose only (never code). */
const CONCEPT_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['concept'],
  properties: {
    concept: { type: 'string' },
  },
};

/** SET 75 — watcher reading: named patterns with digest-grounded evidence. */
const WATCH_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  // SET 83 — the request is sent with `strict: true`, under which EVERY
  // declared property must appear in `required`. Listing only 'union' made
  // the server reject the schema, so every watcher reading threw and the
  // agent silently retired its engine ("reading engine failed"). Empty
  // arrays and empty strings are valid values; optionality is expressed by
  // the value, not by omission from `required`.
  required: ['idioms', 'tropes', 'schemes', 'union'],
  properties: {
    idioms: { type: 'array', items: { type: 'string' } },
    tropes: { type: 'array', items: { type: 'string' } },
    schemes: { type: 'array', items: { type: 'string' } },
    union: { type: 'string' },
  },
};

/** SET 85 — one poll: a list of subject/claim pairs. Strict-mode compliant. */
const OBSERVE_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['items'],
  properties: {
    items: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['subject', 'claim'],
        properties: { subject: { type: 'string' }, claim: { type: 'string' } },
      },
    },
  },
};

/** SET 68 — one judgment ruling: action + optional intent line. */
const JUDGE_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['action', 'intent'],
  properties: {
    action: { type: 'string', enum: ['amend', 'direct', 'drop'] },
    intent: { type: 'string' },
  },
};

const VANILLA_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['title', 'files', 'tests'],
  properties: {
    title: { type: 'string' },
    files: FILES_SCHEMA.properties.files,
    tests: FILES_SCHEMA.properties.files,
  },
};
