/**
 * When bounded repair cannot converge, RHA must escalate properly: stop
 * within the attempt budget, never regenerate, and produce a structured
 * escalation report (in the JobReport and as .supergit/escalation.md).
 */
import { describe, expect, it, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { FeatureNode, FeatureSpec, GeneratedFile, JobState } from '../src/core/types';
import { runBuild, runEncodingAndIR } from '../src/core/pipeline';
import { applyRha } from '../src/core/rha';
import { MockLlm } from '../src/mock/mockLlm';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WS = path.join(ROOT, 'workspaces', `esc-${Date.now().toString(36)}`);

/**
 * Fresh-heal draws for F1 echo the buggy build forever — never converges.
 * (RHA no longer calls repair(); healing is generateImplementation.)
 */
class StubbornLlm extends MockLlm {
  private f1Bug: GeneratedFile[] | null = null;
  override async generateImplementation(
    spec: FeatureSpec,
    feature: FeatureNode,
    frozenTest: string,
    depCtx: GeneratedFile[],
    opts?: { temperature?: number; avoid?: string },
  ): Promise<GeneratedFile[]> {
    const files = await super.generateImplementation(spec, feature, frozenTest, depCtx, opts);
    if (feature.id === 'F1') {
      if (!this.f1Bug) this.f1Bug = files.map(f => ({ ...f }));
      return this.f1Bug.map(f => ({ ...f }));
    }
    return files;
  }
}

afterAll(() => {
  fs.rmSync(WS, { recursive: true, force: true });
});

describe('escalation path', () => {
  it('surfaces unhealed nodes as a structured report instead of thrashing', async () => {
    const llm = new StubbornLlm();
    const job: JobState = {
      id: 'esc', title: '', prompt: 'A URL shortener library.',
      phase: 'created', createdAt: new Date().toISOString(), workspaceDir: WS,
      spec: null, manifest: null, buildResults: [], report: null, promptAssessment: null, irEscalations: [], error: null,
      events: [], llmUsage: [],
    };
    const emit = () => {};
    const { substrate } = await runEncodingAndIR(job, llm, emit);
    await runBuild(job, substrate, llm, emit);
    const report = await applyRha(WS, job.spec!, job.manifest!, substrate, llm, emit);

    // did not converge, did not thrash: exactly the attempt budget on F1
    expect(report.failed).toBeGreaterThan(0);
    const f1Episodes = report.episodes.filter(e => e.featureId === 'F1');
    expect(f1Episodes.length).toBeLessThanOrEqual(3);
    expect(f1Episodes.every(e => e.outcome !== 'healed')).toBe(true);

    // structured escalation entries for everything still red
    const ids = report.escalations.map(e => e.featureId);
    expect(ids).toContain('F1');
    const f1 = report.escalations.find(e => e.featureId === 'F1')!;
    expect(f1.attempts).toBeGreaterThanOrEqual(2);
    expect(f1.bugClass).toBe('test-failure');
    expect(f1.recommendation).toMatch(/did not converge|attempt budget/i);
    expect(f1.ledgerTrail.length).toBeGreaterThan(0);

    // nodes never reached get the honest "never repaired" recommendation
    const untouched = report.escalations.find(e => e.attempts === 0);
    expect(untouched?.recommendation).toMatch(/never repaired/i);

    // the report travels with the artifact
    const md = fs.readFileSync(path.join(WS, '.supergit', 'escalation.md'), 'utf8');
    expect(md).toContain('# Escalation report');
    expect(md).toContain('## F1');
    expect(md).toContain('Ledger trail');

    // and the ledger records the surfacing decision
    expect(substrate.allRows().some(r => r.summary.includes('escalation report'))).toBe(true);
  }, 240_000);
});
