/**
 * test/cycle-abort.test.ts — Verify runCycle respects AbortSignal.
 *
 * Regression test for the 2026-04-24 incident where 98 jobs piled up
 * because autopilot-cycle's handler didn't propagate AbortSignal to
 * runCycle, and runCycle had no signal-checking between phases.
 *
 * Tests the three-layer fix:
 *   1. CycleOpts.signal — runCycle checks signal between phases
 *   2. Handler wiring — autopilot-cycle passes job.signal
 *   3. Worker force-eviction — last resort if handler ignores abort
 *
 * Layer 3 is tested in minions.test.ts (worker-level). This file
 * covers layers 1 and 2 via the cycle interface.
 */

import { describe, test, expect } from 'bun:test';

// We can't easily import runCycle with a real engine for unit tests,
// but we CAN test the checkAborted pattern and CycleOpts contract.

describe('CycleOpts.signal contract (v0.20.5)', () => {
  test('signal field exists on CycleOpts interface', async () => {
    // Type-level test: importing the type should work
    const mod = await import('../src/core/cycle.ts');
    // runCycle exists and is callable
    expect(typeof mod.runCycle).toBe('function');
  });

  test('runCycle accepts signal in opts without error', async () => {
    // Verify runCycle doesn't crash when signal is passed but no engine
    const { runCycle } = await import('../src/core/cycle.ts');
    const abort = new AbortController();

    // Call with null engine + minimal opts — should return a report
    // (phases that need engine will be skipped)
    const report = await runCycle(null, {
      brainDir: '/nonexistent-for-test',
      phases: [], // empty phases = no work
      signal: abort.signal,
    });

    expect(report.schema_version).toBe('1');
    expect(report.status).toBeDefined();
  });

  test('runCycle bails on pre-aborted signal', async () => {
    const { runCycle } = await import('../src/core/cycle.ts');
    const abort = new AbortController();
    abort.abort(new Error('timeout'));

    // With a pre-aborted signal and phases that would run, it should
    // throw or return failed (depending on which phase catches it first)
    try {
      const report = await runCycle(null, {
        brainDir: '/nonexistent-for-test',
        phases: ['lint'], // lint doesn't need engine, would normally run
        signal: abort.signal,
      });
      // If it returns instead of throwing, status should reflect the abort
      expect(['failed', 'partial']).toContain(report.status);
    } catch (err) {
      // checkAborted threw — this is the expected behavior
      expect(err instanceof Error).toBe(true);
      expect((err as Error).message).toContain('aborted');
    }
  });

  test('runCycle bails mid-flight when signal fires between phases', async () => {
    const { runCycle } = await import('../src/core/cycle.ts');
    const abort = new AbortController();

    // Abort after 50ms — should catch between phases
    setTimeout(() => abort.abort(new Error('timeout')), 50);

    try {
      const report = await runCycle(null, {
        brainDir: '/nonexistent-for-test',
        phases: ['lint', 'backlinks', 'orphans'],
        signal: abort.signal,
        yieldBetweenPhases: async () => {
          // Slow yield to give the abort time to fire
          await new Promise(r => setTimeout(r, 100));
        },
      });
      // If it returned cleanly, not all phases should have run
      // (abort should have prevented later phases)
      const completedPhases = report.phases.length;
      expect(completedPhases).toBeLessThan(3);
    } catch (err) {
      // checkAborted threw between phases — expected
      expect(err instanceof Error).toBe(true);
      expect((err as Error).message).toContain('aborted');
    }
  });
});

describe('autopilot-cycle handler contract (v0.20.5)', () => {
  test('handler registration passes signal to runCycle', async () => {
    // Verify the handler code in jobs.ts includes job.signal
    const fs = await import('fs');
    const jobsSource = fs.readFileSync(
      new URL('../src/commands/jobs.ts', import.meta.url),
      'utf8',
    );

    // The autopilot-cycle handler MUST pass signal to runCycle.
    // Source-level regression guard.
    //
    // The slice window was bumped to 6000 in v0.39 — the v0.38 wave added
    // source_id validation + archive recheck + pull-flag threading at the
    // top of the handler, which pushed the runCycle({signal:...}) call past
    // the original 2000-char ceiling. The intent of the guard is unchanged:
    // "the autopilot-cycle handler passes job.signal to runCycle." The
    // window just needs to be wide enough to span any reasonable handler.
    const handlerStart = jobsSource.indexOf("registerBuiltinJob(worker, engine, 'autopilot-cycle'");
    expect(handlerStart).toBeGreaterThan(-1);
    const handlerBlock = jobsSource.slice(handlerStart, handlerStart + 6000);

    expect(handlerBlock).toContain('signal: job.signal');
  });

  test('worker.ts has force-eviction safety net after timeout', async () => {
    // Verify the worker code includes the grace timer
    const fs = await import('fs');
    const workerSource = fs.readFileSync(
      new URL('../src/core/minions/worker.ts', import.meta.url),
      'utf8',
    );

    // Must have the force-eviction pattern
    expect(workerSource).toContain('Force-evicting from inFlight');
    expect(workerSource).toContain('graceTimer');
    expect(workerSource).toContain('handler ignored abort signal');
  });

  test('cycle.ts has checkAborted calls between phases', async () => {
    // Verify the cycle code checks abort between every phase
    const fs = await import('fs');
    const cycleSource = fs.readFileSync(
      new URL('../src/core/cycle.ts', import.meta.url),
      'utf8',
    );

    // Count checkAborted calls in the runCycle function body
    const runCycleBody = cycleSource.slice(
      cycleSource.indexOf('export async function runCycle'),
    );
    const checkCalls = (runCycleBody.match(/checkAborted\(opts\.signal\)/g) || []).length;

    // Should have at least 6 (one per phase)
    expect(checkCalls).toBeGreaterThanOrEqual(6);
  });
});

describe('#1972 — complete cooperative-abort coverage', () => {
  test('aborted signal makes runCycle report partial + reason "aborted" (terminal guard)', async () => {
    // phases:[] means no between-phase checkAborted fires, so execution reaches
    // the TERMINAL guard (Codex #9). With a pre-aborted signal it must NOT
    // report success — status 'partial', reason 'aborted'.
    const { runCycle } = await import('../src/core/cycle.ts');
    const abort = new AbortController();
    abort.abort(new Error('timeout'));
    const report = await runCycle(null, {
      brainDir: '/nonexistent-for-test',
      phases: [],
      signal: abort.signal,
    });
    expect(report.status).toBe('partial');
    expect(report.reason).toBe('aborted');
  });

  test('cycle.ts threads opts.signal into every long phase + guards the success stamp', async () => {
    const fs = await import('fs');
    const src = fs.readFileSync(new URL('../src/core/cycle.ts', import.meta.url), 'utf8');
    const body = src.slice(src.indexOf('export async function runCycle'));
    // Each long phase receives the signal.
    expect(body).toContain('runPhaseExtract(engine, brainDir, dryRun, syncPagesAffected, opts.signal, cycleSourceId)');
    expect(body).toMatch(/runPhaseExtractFacts\([^)]*opts\.signal\)/);
    expect(body).toContain('signal: opts.signal'); // consolidate opts
    expect(body).toContain('runPhaseLint(brainDir, dryRun, engine, opts.signal)');
    // Reaper runs at cycle start.
    expect(body).toContain('reapDeadHolderLocks(engine)');
    // Terminal guard: the success stamp is gated on !aborted, and the report
    // carries reason 'aborted'.
    expect(body).toContain('!aborted');
    expect(body).toContain("reason: 'aborted'");
    // Phase-duration force-evict attribution log (T11).
    expect(body).toContain('FORCE_EVICT_DEADLINE_MS');
  });

  test('every long phase core checks isAborted in its batch loop', async () => {
    const fs = await import('fs');
    const read = (p: string) => fs.readFileSync(new URL(p, import.meta.url), 'utf8');
    expect(read('../src/commands/extract.ts')).toContain('if (isAborted(signal)) return;');
    expect(read('../src/core/cycle/extract-facts.ts')).toContain('if (isAborted(opts.signal)) break;');
    expect(read('../src/core/cycle/phases/consolidate.ts')).toContain('if (isAborted(opts.signal)) break;');
    expect(read('../src/core/cycle/phantom-redirect.ts')).toContain('if (isAborted(signal)) break;');
    expect(read('../src/commands/lint.ts')).toContain('if (isAborted(opts.signal)) break;');
  });
});
