/**
 * SET 40h — adaptive per-stage desync threshold, stage-transition spacing,
 * hang evidence admission into report Evidence.
 */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { LocalLlmClient } from '../src/core/localLlm';
import {
  adaptiveQuietProbeMs,
  ENCODE_QUIET_FLOOR_MS,
  formatHangEvidenceLine,
  STAGE_QUIET_FLOOR_MS,
  stageQuietProbeFloor,
} from '../src/core/providerWatch';
import {
  collectReportEvidence,
  formatHangEvidenceLine as formatHangFromEvidence,
  isReportEvidenceLine,
} from '../src/tier1/reportEvidence';

afterEach(() => {
  vi.unstubAllGlobals();
  vi.restoreAllMocks();
});

function okResponse(content: unknown): Response {
  return new Response(JSON.stringify({
    model: 'test-model',
    choices: [{ message: { content: JSON.stringify(content) } }],
    usage: { prompt_tokens: 10, completion_tokens: 5 },
  }), { status: 200 });
}

function stubFetchByOp(): void {
  vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
    const body = JSON.parse(String(init?.body ?? '{}')) as {
      response_format?: { json_schema?: { name?: string } };
    };
    const op = body.response_format?.json_schema?.name;
    if (op === 'vanilla') {
      return okResponse({ title: 'T', files: [], tests: [] });
    }
    if (op === 'fixStub') {
      return okResponse({ content: 'export {}' });
    }
    return okResponse({});
  }));
}

describe('SET 40h adaptive quiet probe', () => {
  it('floors: encode 90s, other stages 45s', () => {
    expect(stageQuietProbeFloor('encode')).toBe(ENCODE_QUIET_FLOOR_MS);
    expect(stageQuietProbeFloor('build')).toBe(STAGE_QUIET_FLOOR_MS);
    expect(adaptiveQuietProbeMs('build', null)).toBe(45_000);
    expect(adaptiveQuietProbeMs('encode', null)).toBe(90_000);
  });

  it('with short stage median, build ≈45s not 90s; encode stays ≥90s', () => {
    // median 12s → 3× = 36s → max(45s, 36s) = 45s
    expect(adaptiveQuietProbeMs('build', 12_000)).toBe(45_000);
    expect(adaptiveQuietProbeMs('encode', 12_000)).toBe(90_000);

    const llm = new LocalLlmClient(undefined, undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      seedStageDurations: {
        build: [10_000, 12_000, 14_000],
        encode: [10_000, 12_000, 14_000],
      },
    });
    expect(llm.quietProbeMsFor('build')).toBe(45_000);
    expect(llm.quietProbeMsFor('encode')).toBe(90_000);
  });

  it('scales above floor when stage median is high', () => {
    // median 30s → 3× = 90s
    expect(adaptiveQuietProbeMs('build', 30_000)).toBe(90_000);
    const llm = new LocalLlmClient(undefined, undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      seedStageDurations: { build: [20_000, 30_000, 40_000] },
    });
    expect(llm.quietProbeMsFor('build')).toBe(90_000);
  });

  it('before warm-up uses stage floor', () => {
    const llm = new LocalLlmClient(undefined, undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
    });
    expect(llm.quietProbeMsFor('build')).toBe(45_000);
    expect(llm.quietProbeMsFor('ir')).toBe(45_000);
    expect(llm.quietProbeMsFor('encode')).toBe(90_000);
  });

  it('explicit quietProbeMs override disables adaptive', () => {
    const llm = new LocalLlmClient(undefined, undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      quietProbeMs: 40,
      seedStageDurations: { build: [12_000, 12_000, 12_000] },
    });
    expect(llm.quietProbeMsFor('build')).toBe(40);
    expect(llm.quietProbeMsFor('encode')).toBe(40);
  });
});

describe('SET 40h stage-transition spacing', () => {
  it('consecutive calls with different op → spacing applied event', async () => {
    const events: string[] = [];
    stubFetchByOp();

    const llm = new LocalLlmClient(undefined, undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      quietProbeMs: 60_000,
      probeHost: async () => 'busy',
      onProviderEvent: m => events.push(m),
    });

    await llm.vanillaDraw('a');
    expect(events.some(e => /stage-transition spacing applied/.test(e))).toBe(false);

    await llm.fixStub('src/x.ts', { diagnostics: 'err' });
    expect(events.some(e => e === 'stage-transition spacing applied (vanilla→fixStub)')).toBe(true);

    const before = events.filter(e => /stage-transition/.test(e)).length;
    await llm.fixStub('src/y.ts', { diagnostics: 'err' });
    expect(events.filter(e => /stage-transition/.test(e)).length).toBe(before);
  }, 15_000);
});

describe('SET 40h hang evidence', () => {
  it('formatHangEvidenceLine matches admission vocabulary', () => {
    const line = formatHangEvidenceLine('build', 'src/auth.ts', 45_000, 'reissue');
    expect(line).toBe('hang: build:src/auth.ts stalled 45s (resolved: reissue)');
    expect(formatHangFromEvidence('encode', 'F1', 90_200, 'cancelled')).toBe(
      'hang: encode:F1 stalled 90s (resolved: cancelled)',
    );
    expect(isReportEvidenceLine(line)).toBe(true);
  });

  it('hang: lines appear in collectReportEvidence.hangLines / lines', () => {
    const hang = 'hang: build:F8 stalled 45s (resolved: reissue)';
    const collected = collectReportEvidence([
      { message: 'attribution-miss: program crashed' },
      { message: hang },
      { message: 'hang: build:src/auth.ts stalled 81s (resolved: cancelled)' },
    ]);
    expect(collected.hangLines).toEqual([
      hang,
      'hang: build:src/auth.ts stalled 81s (resolved: cancelled)',
    ]);
    expect(collected.lines).toContain(hang);
    expect(collected.lines.some(l => l.startsWith('hang:'))).toBe(true);
  });

  it('emits hang after stall > 3× stage median (resolved: completed)', async () => {
    const events: string[] = [];
    vi.stubGlobal('fetch', vi.fn(async () => {
      await new Promise(r => setTimeout(r, 350));
      return okResponse({ title: 'T', files: [], tests: [] });
    }));

    const llm = new LocalLlmClient(undefined, undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      quietProbeMs: 60_000,
      probeHost: async () => 'busy',
      // median 100ms → 3× = 300ms; 350ms stall trips hang
      seedStageDurations: { vanilla: [100, 100, 100] },
      onProviderEvent: m => events.push(m),
    });

    await llm.vanillaDraw('slow');
    expect(events.some(e => /^hang: vanilla:/.test(e) && /resolved: completed/.test(e))).toBe(true);
  }, 15_000);

  it('desync reissue hang uses resolved: reissue', async () => {
    const events: string[] = [];
    let fetches = 0;
    vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
      fetches += 1;
      if (fetches === 1) {
        await new Promise<never>((_resolve, reject) => {
          const s = init?.signal;
          if (!s) return;
          if (s.aborted) {
            reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
            return;
          }
          s.addEventListener('abort', () => {
            reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
          }, { once: true });
        });
      }
      return okResponse({ title: 'T', files: [], tests: [] });
    }));

    const llm = new LocalLlmClient(undefined, undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      quietProbeMs: 40,
      probeHost: async () => 'idle',
      seedStageDurations: { vanilla: [5, 5, 5] },
      onProviderEvent: m => events.push(m),
    });

    await llm.vanillaDraw('desync');
    expect(fetches).toBe(2);
    expect(events.some(e => /^hang: vanilla:/.test(e) && /resolved: reissue/.test(e))).toBe(true);
  }, 15_000);
});
