const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');

let tmpDir;
const savedEnv = {};
const envKeys = ['EVOLVER_REPO_ROOT', 'OPENCLAW_WORKSPACE', 'GEP_ASSETS_DIR', 'MEMORY_DIR', 'EVOLUTION_DIR', 'EVOLVER_SESSION_SCOPE'];

function setupTempEnv() {
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'assetstore-test-'));
  for (const k of envKeys) { savedEnv[k] = process.env[k]; }
  const assetsDir = path.join(tmpDir, 'assets', 'gep');
  fs.mkdirSync(assetsDir, { recursive: true });
  process.env.EVOLVER_REPO_ROOT = tmpDir;
  process.env.GEP_ASSETS_DIR = assetsDir;
  process.env.OPENCLAW_WORKSPACE = tmpDir;
  delete process.env.EVOLVER_SESSION_SCOPE;
}

function teardownTempEnv() {
  for (const k of envKeys) {
    if (savedEnv[k] === undefined) delete process.env[k];
    else process.env[k] = savedEnv[k];
  }
  fs.rmSync(tmpDir, { recursive: true, force: true });
}

function freshRequire() {
  const modPath = require.resolve('../src/gep/assetStore');
  const pathsPath = require.resolve('../src/gep/paths');
  delete require.cache[modPath];
  delete require.cache[pathsPath];
  return require(modPath);
}

function writeJsonl(filePath, objects) {
  fs.writeFileSync(filePath, objects.map(o => JSON.stringify(o)).join('\n') + '\n', 'utf8');
}

describe('readRecentCandidates', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('returns empty array when file does not exist', () => {
    const { readRecentCandidates } = freshRequire();
    assert.deepEqual(readRecentCandidates(), []);
  });

  it('returns empty array for empty file', () => {
    const { candidatesPath, readRecentCandidates } = freshRequire();
    fs.writeFileSync(candidatesPath(), '', 'utf8');
    assert.deepEqual(readRecentCandidates(), []);
  });

  it('reads and parses JSONL entries', () => {
    const { candidatesPath, readRecentCandidates } = freshRequire();
    const items = [
      { type: 'Candidate', id: 'c1', score: 0.8 },
      { type: 'Candidate', id: 'c2', score: 0.9 },
    ];
    writeJsonl(candidatesPath(), items);
    const result = readRecentCandidates(10);
    assert.equal(result.length, 2);
    assert.equal(result[0].id, 'c1');
    assert.equal(result[1].id, 'c2');
  });

  it('respects limit parameter (returns last N)', () => {
    const { candidatesPath, readRecentCandidates } = freshRequire();
    const items = [];
    for (let i = 0; i < 10; i++) {
      items.push({ type: 'Candidate', id: 'c' + i });
    }
    writeJsonl(candidatesPath(), items);
    const result = readRecentCandidates(3);
    assert.equal(result.length, 3);
    assert.equal(result[0].id, 'c7');
    assert.equal(result[1].id, 'c8');
    assert.equal(result[2].id, 'c9');
  });

  it('skips malformed JSON lines gracefully', () => {
    const { candidatesPath, readRecentCandidates } = freshRequire();
    const content = '{"id":"c1"}\n{BROKEN\n{"id":"c2"}\n';
    fs.writeFileSync(candidatesPath(), content, 'utf8');
    const result = readRecentCandidates(10);
    assert.equal(result.length, 2);
    assert.equal(result[0].id, 'c1');
    assert.equal(result[1].id, 'c2');
  });

  it('handles large file (>1MB) by reading tail only', () => {
    const { candidatesPath, readRecentCandidates } = freshRequire();
    const p = candidatesPath();
    const padding = '{"type":"pad","data":"' + 'x'.repeat(500) + '"}\n';
    const padCount = Math.ceil((1024 * 1024 + 100) / padding.length);
    let content = '';
    for (let i = 0; i < padCount; i++) content += padding;
    content += '{"type":"tail","id":"last1"}\n';
    content += '{"type":"tail","id":"last2"}\n';
    fs.writeFileSync(p, content, 'utf8');
    const stat = fs.statSync(p);
    assert.ok(stat.size > 1024 * 1024, 'file should be >1MB for large file path');
    const result = readRecentCandidates(2);
    assert.equal(result.length, 2);
    assert.equal(result[0].id, 'last1');
    assert.equal(result[1].id, 'last2');
  });
});

describe('appendCandidateJsonl + readRecentCandidates roundtrip', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('appends and reads back candidates', () => {
    const { appendCandidateJsonl, readRecentCandidates } = freshRequire();
    appendCandidateJsonl({ type: 'Candidate', id: 'rt1', score: 0.5 });
    appendCandidateJsonl({ type: 'Candidate', id: 'rt2', score: 0.7 });
    const result = readRecentCandidates(10);
    assert.equal(result.length, 2);
    assert.equal(result[0].id, 'rt1');
    assert.equal(result[1].id, 'rt2');
  });
});

describe('loadGenes', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('returns default genes when no files exist', () => {
    const { ensureAssetFiles, loadGenes } = freshRequire();
    ensureAssetFiles();
    const genes = loadGenes();
    assert.ok(Array.isArray(genes));
    assert.ok(genes.length >= 2, 'should have at least 2 default genes');
    assert.ok(genes.every(g => g.type === 'Gene'));
  });

  it('deduplicates genes by id (jsonl overrides json)', () => {
    const { genesPath, loadGenes } = freshRequire();
    const jsonContent = {
      version: 1,
      genes: [{ type: 'Gene', id: 'gene_a', category: 'repair', signals_match: ['error'] }],
    };
    fs.writeFileSync(genesPath(), JSON.stringify(jsonContent), 'utf8');
    const jsonlPath = path.join(path.dirname(genesPath()), 'genes.jsonl');
    fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'Gene', id: 'gene_a', category: 'optimize', signals_match: ['perf'] }) + '\n', 'utf8');
    const genes = loadGenes();
    const geneA = genes.find(g => g.id === 'gene_a');
    assert.ok(geneA);
    assert.equal(geneA.category, 'optimize');
  });

  // Bugbot follow-up on PR #25: loadGenes used to pass loaded genes through
  // createGene(), which synthesized default fields (epigenetic_marks,
  // learning_history, anti_patterns, summary, schema_version) on legacy genes
  // that were stored before those fields existed. Since computeAssetId hashes
  // every field except asset_id, those phantom additions invalidated the
  // stored asset_id and broke content-addressable integrity.
  it('preserves on-disk gene shape (does not synthesize default fields that would invalidate asset_id)', () => {
    const { genesPath, loadGenes } = freshRequire();
    const { computeAssetId, verifyAssetId } = require('../src/gep/contentHash');

    // A "legacy" gene with the minimal field set as it existed pre-#25.
    const legacyGene = {
      type: 'Gene',
      id: 'gene_legacy',
      category: 'repair',
      signals_match: ['error'],
      strategy: ['fix it'],
    };
    legacyGene.asset_id = computeAssetId(legacyGene);

    fs.writeFileSync(genesPath(), JSON.stringify({ version: 1, genes: [legacyGene] }), 'utf8');

    const loaded = loadGenes().find(g => g.id === 'gene_legacy');
    assert.ok(loaded, 'gene_legacy should be loaded');
    assert.ok(verifyAssetId(loaded), 'loaded gene asset_id must still verify');
    assert.equal(loaded.epigenetic_marks, undefined, 'must not synthesize epigenetic_marks');
    assert.equal(loaded.learning_history, undefined, 'must not synthesize learning_history');
    assert.equal(loaded.anti_patterns, undefined, 'must not synthesize anti_patterns');
    assert.equal(loaded.schema_version, undefined, 'must not synthesize schema_version');
    assert.equal(loaded.summary, undefined, 'must not synthesize summary');
  });

  // PR #384 wired GeneRoutingHint through to the EvoX agent-core router,
  // but for several months no shipped seed gene populated `routing_hint`
  // — so RouterDecision.reason="gene_hint" never fired in the field. The
  // first reduction in router-lessons-learned-2026-05-18.md §3 flagged
  // this as the audit blocker for cost_tier follow-up. Pin explicit tier
  // hints into the bundled `assets/gep/genes.json` so production installs
  // (which receive it as `genes.seed.json` via build_public.js) start
  // exercising the gene→router path without needing a fresh capsule.
  //
  // Important: this test must exercise the *production seed path*, not the
  // last-resort `getDefaultGenes()` fallback. In production `npm i -g
  // @evomap/evolver` ships the repo-root `assets/gep/genes.json` as
  // `genes.seed.json`, and `ensureGenesSeeded()` copies it into the user's
  // store on first run. The fallback only fires when neither file exists.
  it('seed genes.json populates routing_hint reachable via prod ensureGenesSeeded path', () => {
    const { genesPath, loadGenes } = freshRequire();
    // The production path now keeps user-mutable assets in GEP_ASSETS_DIR
    // while reading starter genes from the bundled assets/gep seed. This
    // forces ensureGenesSeeded() to copy from the package seed into the
    // runtime store, NOT from getDefaultGenes().
    assert.ok(!fs.existsSync(genesPath()), 'precondition: no user genes.json yet');

    const genes = loadGenes();
    const integrity = genes.find(g => g.id === 'gene_tool_integrity');
    assert.ok(integrity, 'gene_tool_integrity must ship in assets/gep/genes.json (prod seed)');
    assert.deepEqual(integrity.routing_hint, { tier: 'cheap', reasoning_level: 'low' });

    const distilled = genes.find(g => g.id === 'gene_distilled_s2g-env-vars');
    assert.ok(distilled, 'gene_distilled_s2g-env-vars must ship in prod seed');
    assert.deepEqual(distilled.routing_hint, { tier: 'cheap', reasoning_level: 'low' });
    const { verifyAssetId } = require('../src/gep/contentHash');
    assert.ok(verifyAssetId(distilled), 'shipped asset_id must verify against current content');

    const optimize = genes.find(g => g.id === 'gene_gep_optimize_tool_usage');
    assert.ok(optimize, 'gene_gep_optimize_tool_usage must ship in prod seed');
    assert.deepEqual(optimize.routing_hint, { tier: 'mid', reasoning_level: 'medium' });

    assert.ok(fs.existsSync(genesPath()), 'ensureGenesSeeded must have copied seed -> genes.json');
  });

  it('copies legacy runtime assets from assets/gep into default .evolver/gep store once', () => {
    teardownTempEnv();
    setupTempEnv();
    delete process.env.GEP_ASSETS_DIR;

    const legacyDir = path.join(tmpDir, 'assets', 'gep');
    fs.mkdirSync(legacyDir, { recursive: true });
    const legacyGenes = { version: 1, genes: [{ type: 'Gene', id: 'legacy_gene' }] };
    fs.writeFileSync(path.join(legacyDir, 'genes.json'), JSON.stringify(legacyGenes), 'utf8');
    fs.writeFileSync(path.join(legacyDir, 'events.jsonl'), JSON.stringify({ id: 'evt_1' }) + '\n', 'utf8');

    const { genesPath, eventsPath, ensureAssetFiles } = freshRequire();
    assert.equal(genesPath(), path.join(tmpDir, '.evolver', 'gep', 'genes.json'));

    ensureAssetFiles();

    assert.deepEqual(JSON.parse(fs.readFileSync(genesPath(), 'utf8')), legacyGenes);
    assert.equal(fs.readFileSync(eventsPath(), 'utf8'), JSON.stringify({ id: 'evt_1' }) + '\n');
    assert.ok(fs.existsSync(path.join(legacyDir, 'genes.json')), 'legacy source is copied, not deleted');
  });

  it('copies scoped legacy runtime assets into scoped default .evolver/gep store', () => {
    teardownTempEnv();
    setupTempEnv();
    delete process.env.GEP_ASSETS_DIR;
    process.env.EVOLVER_SESSION_SCOPE = 'scope-a';

    const unscopedLegacyDir = path.join(tmpDir, 'assets', 'gep');
    const scopedLegacyDir = path.join(unscopedLegacyDir, 'scopes', 'scope-a');
    fs.mkdirSync(scopedLegacyDir, { recursive: true });
    const unscopedGenes = { version: 1, genes: [{ type: 'Gene', id: 'unscoped_gene' }] };
    const scopedGenes = { version: 1, genes: [{ type: 'Gene', id: 'scoped_gene' }] };
    fs.writeFileSync(path.join(unscopedLegacyDir, 'genes.json'), JSON.stringify(unscopedGenes), 'utf8');
    fs.writeFileSync(path.join(scopedLegacyDir, 'genes.json'), JSON.stringify(scopedGenes), 'utf8');

    const { genesPath, ensureAssetFiles } = freshRequire();
    assert.equal(genesPath(), path.join(tmpDir, '.evolver', 'gep', 'scopes', 'scope-a', 'genes.json'));

    ensureAssetFiles();

    assert.deepEqual(JSON.parse(fs.readFileSync(genesPath(), 'utf8')), scopedGenes);
    assert.ok(fs.existsSync(path.join(scopedLegacyDir, 'genes.json')), 'scoped legacy source is copied, not deleted');
  });
});

describe('readAllEvents', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('returns empty array when file does not exist', () => {
    const { readAllEvents } = freshRequire();
    assert.deepEqual(readAllEvents(), []);
  });

  it('parses JSONL events and skips malformed lines', () => {
    const { eventsPath, readAllEvents } = freshRequire();
    const content = [
      JSON.stringify({ type: 'EvolutionEvent', id: 'evt_1', intent: 'repair' }),
      'NOT_JSON',
      JSON.stringify({ type: 'EvolutionEvent', id: 'evt_2', intent: 'innovate' }),
    ].join('\n') + '\n';
    fs.writeFileSync(eventsPath(), content, 'utf8');
    const events = readAllEvents();
    assert.equal(events.length, 2);
    assert.equal(events[0].id, 'evt_1');
    assert.equal(events[1].id, 'evt_2');
  });

  // Regression for issue #30 (H6): readAllEvents previously read the whole
  // events.jsonl into memory unconditionally. On long-running daemons the
  // file accumulates dozens of MB, causing heap spikes per
  // computeCapsuleSuccessStreak call. Now bounded by
  // EVOLVER_EVENTS_FULL_READ_MAX_BYTES with a tail-read fallback.
  it('handles oversized file via tail-read and recovers recent events', () => {
    const { eventsPath, readAllEvents } = freshRequire();
    const p = eventsPath();
    // Pick a cap small enough to force tail-read but a tail chunk large enough
    // to start mid-file (readPos > 0), exercising the partial-line discard path.
    process.env.EVOLVER_EVENTS_FULL_READ_MAX_BYTES = '512';
    try {
      const padding = JSON.stringify({ type: 'EvolutionEvent', id: 'pad', data: 'x'.repeat(200) }) + '\n';
      const padCount = 30;
      let content = '';
      for (let i = 0; i < padCount; i++) content += padding;
      content += JSON.stringify({ type: 'EvolutionEvent', id: 'recent_1', intent: 'repair' }) + '\n';
      content += JSON.stringify({ type: 'EvolutionEvent', id: 'recent_2', intent: 'optimize' }) + '\n';
      fs.writeFileSync(p, content, 'utf8');
      // > 2MB tail chunk default ensures readPos = stat.size - chunkSize stays
      // at 0 here; the inner branch is exercised by the next test below.
      assert.ok(fs.statSync(p).size > 512, 'fixture must exceed cap');

      const events = readAllEvents();
      const ids = events.map(e => e && e.id).filter(Boolean);
      assert.ok(ids.includes('recent_1'), 'tail read should surface recent_1');
      assert.ok(ids.includes('recent_2'), 'tail read should surface recent_2');
    } finally {
      delete process.env.EVOLVER_EVENTS_FULL_READ_MAX_BYTES;
    }
  });

  // True mid-file tail read: readPos > 0, first chunk line MUST be discarded
  // because it is almost certainly a partial JSON record. We force this with
  // a tail size smaller than the file, leaving prefix bytes outside the chunk.
  it('discards a partial first line only when readPos > 0 (true mid-file tail)', () => {
    const { eventsPath, readAllEvents } = freshRequire();
    const p = eventsPath();
    process.env.EVOLVER_EVENTS_FULL_READ_MAX_BYTES = '256';
    process.env.EVOLVER_EVENTS_TAIL_READ_BYTES = '512';
    try {
      const longLineBody = 'x'.repeat(400);
      const events = [
        { type: 'EvolutionEvent', id: 'event_a', body: longLineBody },
        { type: 'EvolutionEvent', id: 'event_b' },
        { type: 'EvolutionEvent', id: 'event_c' },
      ];
      fs.writeFileSync(p, events.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8');
      const stat = fs.statSync(p);
      assert.ok(stat.size > 512, 'file must exceed tail-read chunk for readPos > 0');

      const recovered = readAllEvents();
      const ids = recovered.map(e => e && e.id).filter(Boolean);
      // event_a sits in the dropped-prefix region; event_b / event_c survive.
      assert.ok(!ids.includes('event_a'), 'partial first line must be discarded');
      assert.ok(ids.includes('event_b'), 'second event must survive');
      assert.ok(ids.includes('event_c'), 'last event must survive');
    } finally {
      delete process.env.EVOLVER_EVENTS_FULL_READ_MAX_BYTES;
      delete process.env.EVOLVER_EVENTS_TAIL_READ_BYTES;
    }
  });

  // Regression for Bugbot finding on PR #31: when the tail chunk covers the
  // whole file (readPos === 0), the first line is NOT partial -- it is the
  // start of the file -- and must NOT be discarded. The earlier version
  // unconditionally dropped lines[0], silently losing a complete event.
  it('does not drop the first event when tail chunk starts at offset 0', () => {
    const { eventsPath, readAllEvents } = freshRequire();
    const p = eventsPath();
    // cap < file size triggers tail path; default tail chunk (2MB) > file size
    // makes readPos === 0, so no partial line should be dropped.
    process.env.EVOLVER_EVENTS_FULL_READ_MAX_BYTES = '128';
    try {
      const lines = [
        JSON.stringify({ type: 'EvolutionEvent', id: 'first_event', intent: 'repair' }),
        JSON.stringify({ type: 'EvolutionEvent', id: 'middle_event', intent: 'optimize' }),
        JSON.stringify({ type: 'EvolutionEvent', id: 'last_event', intent: 'innovate' }),
      ];
      fs.writeFileSync(p, lines.join('\n') + '\n', 'utf8');
      const stat = fs.statSync(p);
      assert.ok(stat.size > 128, 'fixture must exceed cap');
      assert.ok(stat.size < 2 * 1024 * 1024, 'fixture must fit in a single tail chunk');

      const events = readAllEvents();
      const ids = events.map(e => e && e.id).filter(Boolean);
      assert.equal(events.length, 3, 'all 3 events recovered, first must not be dropped');
      assert.deepEqual(ids, ['first_event', 'middle_event', 'last_event']);
    } finally {
      delete process.env.EVOLVER_EVENTS_FULL_READ_MAX_BYTES;
    }
  });
});

describe('upsertCapsule / upsertGene validation (issue #30 H1)', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('persists a well-formed Capsule without warning', () => {
    const { upsertCapsule, loadCapsules } = freshRequire();
    const warnings = [];
    const origWarn = console.warn;
    console.warn = (...a) => { warnings.push(a.join(' ')); };
    try {
      upsertCapsule({
        type: 'Capsule',
        id: 'cap_ok',
        outcome: { status: 'success', score: 0.9 },
        trigger: ['log_error'],
        execution_trace: [{ step: 'run', ok: true }],
      });
    } finally {
      console.warn = origWarn;
    }
    assert.equal(warnings.filter(w => w.includes('schema validation warning')).length, 0);
    const loaded = loadCapsules();
    assert.ok(loaded.find(c => c.id === 'cap_ok'), 'capsule should be persisted');
  });

  it('emits a warning but still persists a malformed Capsule (warn-only contract)', () => {
    const { upsertCapsule, loadCapsules } = freshRequire();
    const warnings = [];
    const origWarn = console.warn;
    console.warn = (...a) => { warnings.push(a.join(' ')); };
    try {
      upsertCapsule({
        type: 'Capsule',
        id: 'cap_bad',
        outcome: { status: 'unknown_status', score: 0.5 },
        trigger: ['log_error'],
        execution_trace: [],
      });
    } finally {
      console.warn = origWarn;
    }
    assert.ok(
      warnings.some(w => w.includes('Capsule schema validation warning')),
      'should warn about invalid outcome.status',
    );
    const loaded = loadCapsules();
    assert.ok(loaded.find(c => c.id === 'cap_bad'), 'persistence must not be blocked by validator');
  });

  it('emits a warning but still persists a malformed Gene', () => {
    const { upsertGene, loadGenes } = freshRequire();
    const warnings = [];
    const origWarn = console.warn;
    console.warn = (...a) => { warnings.push(a.join(' ')); };
    try {
      upsertGene({
        type: 'Gene',
        id: 'gene_bad_category',
        category: 'not_a_category',
        signals_match: ['log_error'],
        strategy: ['fix'],
      });
    } finally {
      console.warn = origWarn;
    }
    assert.ok(
      warnings.some(w => w.includes('Gene schema validation warning')),
      'should warn about invalid category',
    );
    const loaded = loadGenes();
    assert.ok(loaded.find(g => g.id === 'gene_bad_category'), 'persistence must not be blocked by validator');
  });
});

// Regression for issue #103: solidify mutates a Gene's epigenetic_marks /
// learning_history in place and writes it back through upsertGene. Prior
// to the fix, ensureSchemaFields only computed asset_id when missing, so
// the stored Gene kept the pre-mutation hash and broke content addressing.
describe('upsertGene asset_id freshness (issue #103)', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('recomputes asset_id when epigenetic_marks change in place', () => {
    const { upsertGene, loadGenes } = freshRequire();
    const { computeAssetId, verifyAssetId } = require('../src/gep/contentHash');

    const gene = {
      type: 'Gene',
      schema_version: '1.6.0',
      id: 'gene_epi_mut',
      category: 'repair',
      signals_match: ['log_error'],
      strategy: ['try fix', 'rerun tests'],
      validation: ['node -e "1"'],
      constraints: { max_files: 10, forbidden_paths: ['.git', 'node_modules'] },
      epigenetic_marks: [],
    };
    upsertGene(gene);
    const original = loadGenes().find(g => g.id === 'gene_epi_mut');
    assert.ok(original, 'gene should persist');
    assert.ok(verifyAssetId(original), 'first write must verify');
    const firstId = original.asset_id;

    // Simulate solidify's in-place epigenetic mark mutation.
    original.epigenetic_marks.push({
      context: 'linux/x64/v22.0.0',
      boost: 0.1,
      reason: 'success_in_environment',
      created_at: '2026-05-22T00:00:00.000Z',
    });
    upsertGene(original);

    const reloaded = loadGenes().find(g => g.id === 'gene_epi_mut');
    assert.ok(reloaded, 'gene should still be persisted after mutation');
    assert.notEqual(reloaded.asset_id, firstId, 'asset_id must change when content changes');
    assert.ok(verifyAssetId(reloaded), 'mutated gene must round-trip through verifyAssetId');
    assert.equal(reloaded.asset_id, computeAssetId(reloaded));
  });
});

describe('getLastEventId', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('returns null when no events file', () => {
    const { getLastEventId } = freshRequire();
    assert.equal(getLastEventId(), null);
  });

  it('returns id of the last event', () => {
    const { eventsPath, getLastEventId } = freshRequire();
    writeJsonl(eventsPath(), [
      { type: 'EvolutionEvent', id: 'evt_first' },
      { type: 'EvolutionEvent', id: 'evt_last' },
    ]);
    assert.equal(getLastEventId(), 'evt_last');
  });

  // Regression: PR #34 introduced a 4KB tail-read that silently returned null
  // when the final event line exceeded the chunk (validation_report embeds up
  // to ~8KB stdout+stderr per command). Without the fix this assertion fails
  // and breaks the parent/child event chain.
  it('handles a final event larger than the initial tail chunk (>64KB)', () => {
    const { eventsPath, getLastEventId } = freshRequire();
    const bigStdout = 'A'.repeat(4000);
    const bigStderr = 'B'.repeat(4000);
    const commands = [];
    for (let i = 0; i < 16; i++) {
      commands.push({ command: `cmd_${i}`, ok: true, stdout: bigStdout, stderr: bigStderr });
    }
    writeJsonl(eventsPath(), [
      { type: 'EvolutionEvent', id: 'evt_first' },
      {
        type: 'EvolutionEvent',
        id: 'evt_huge_last',
        meta: { validation_report: { type: 'ValidationReport', commands } },
      },
    ]);
    const stat = fs.statSync(eventsPath());
    assert.ok(stat.size > 64 * 1024, 'fixture must exceed the initial 64KB chunk');
    assert.equal(getLastEventId(), 'evt_huge_last');
  });

  it('returns the only line when the entire file is a single oversized event', () => {
    const { eventsPath, getLastEventId } = freshRequire();
    const huge = 'X'.repeat(80 * 1024);
    writeJsonl(eventsPath(), [
      { type: 'EvolutionEvent', id: 'evt_only', payload: huge },
    ]);
    assert.equal(getLastEventId(), 'evt_only');
  });
});

describe('readRecentFailedCapsules', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('returns empty array when file does not exist', () => {
    const { readRecentFailedCapsules } = freshRequire();
    assert.deepEqual(readRecentFailedCapsules(), []);
  });

  it('respects limit parameter', () => {
    const { failedCapsulesPath, readRecentFailedCapsules } = freshRequire();
    const list = [];
    for (let i = 0; i < 10; i++) list.push({ type: 'Capsule', id: 'fc' + i, outcome: { status: 'failed' } });
    fs.writeFileSync(failedCapsulesPath(), JSON.stringify({ version: 1, failed_capsules: list }), 'utf8');
    const result = readRecentFailedCapsules(3);
    assert.equal(result.length, 3);
    assert.equal(result[0].id, 'fc7');
  });
});

describe('appendFailedCapsule', () => {
  beforeEach(setupTempEnv);
  afterEach(teardownTempEnv);

  it('appends a capsule to the failed_capsules store', () => {
    const { appendFailedCapsule, readRecentFailedCapsules } = freshRequire();
    appendFailedCapsule({ type: 'Capsule', id: 'fc1', outcome: { status: 'failed' } });
    const result = readRecentFailedCapsules(10);
    assert.equal(result.length, 1);
    assert.equal(result[0].id, 'fc1');
  });

  it('ignores non-object input', () => {
    const { appendFailedCapsule, readRecentFailedCapsules } = freshRequire();
    appendFailedCapsule(null);
    appendFailedCapsule('nope');
    assert.deepEqual(readRecentFailedCapsules(10), []);
  });

  // The store is a bounded FIFO: it grows to FAILED_CAPSULES_MAX (200) then
  // trims to FAILED_CAPSULES_TRIM_TO (100) in one batch — a deliberate sawtooth,
  // NOT a hard 100-element cap. This pins the exact post-trim window so a future
  // "simplify to a hard cap" or off-by-one refactor can't silently change how
  // much failure history a production node retains.
  it('trims in a 200->100 sawtooth, not a hard cap', () => {
    const { appendFailedCapsule, readRecentFailedCapsules } = freshRequire();
    for (let i = 1; i <= 250; i++) {
      appendFailedCapsule({ type: 'Capsule', id: 'fc' + i, outcome: { status: 'failed' } });
    }
    const result = readRecentFailedCapsules(1000);
    // After 250 appends: the 200th append makes length 201 -> trim to last 100
    // (fc101..fc200); appends 201..250 push to 150 without re-trimming (never
    // re-crosses 200), leaving fc102..fc250 — see derivation in the source.
    assert.equal(result.length, 149);
    assert.equal(result[0].id, 'fc102');
    assert.equal(result[result.length - 1].id, 'fc250');
  });

  it('does not trim at exactly the max boundary', () => {
    const { appendFailedCapsule, readRecentFailedCapsules } = freshRequire();
    for (let i = 1; i <= 200; i++) {
      appendFailedCapsule({ type: 'Capsule', id: 'fc' + i, outcome: { status: 'failed' } });
    }
    const result = readRecentFailedCapsules(1000);
    assert.equal(result.length, 200);
    assert.equal(result[0].id, 'fc1');
  });
});
