/**
 * SET 22 — provider desync, degradation, token ceiling, watchdog non-cancel.
 */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { inferHostStatus, parseNativeV0, parseNativeV1 } from '../src/core/lmsModels';
import { LocalLlmClient } from '../src/core/localLlm';
import type { LlmCallRecord } from '../src/core/types';
import {
  connectionLostMessage,
  createDegradationState,
  createTokenCeilingState,
  observeThroughput,
  observeTokenSpend,
  quietProbeMessage,
  recoveredAfterMessage,
  watchdogMayCancel,
} from '../src/core/providerWatch';

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

describe('SET 22a host status inference', () => {
  it('idle/ready with loaded model → idle (Andy’s live probe shape)', () => {
    const models = parseNativeV0({
      data: [
        { id: 'qwen/x', type: 'llm', state: 'loaded', status: 'ready' },
      ],
    })!;
    expect(inferHostStatus(models)).toBe('idle');
  });

  it('busy/generating → busy', () => {
    const models = parseNativeV1({
      models: [{
        type: 'llm',
        key: 'qwen/x',
        loaded_instances: [{ id: 'i1', status: 'generating' }],
      }],
    })!;
    expect(inferHostStatus(models)).toBe('busy');
  });
});

describe('SET 22a desync reissue', () => {
  it('mock idle + hung call → desync event + exactly one reissue with identical body', async () => {
    const events: string[] = [];
    const bodies: unknown[] = [];
    let fetches = 0;

    vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
      fetches += 1;
      bodies.push(JSON.parse(String(init?.body ?? '{}')));
      if (fetches === 1) {
        // Hang until aborted (desync reissue).
        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 new Response(JSON.stringify({
        model: 'test-model',
        choices: [{ message: { content: JSON.stringify({
          title: 'T', files: [{ path: 'a.ts', content: 'export {}' }], tests: [],
        }) } }],
        usage: { prompt_tokens: 10, completion_tokens: 5 },
      }), { status: 200 });
    }));

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

    const out = await llm.vanillaDraw('build a thing');
    expect(out.title).toBe('T');
    expect(fetches).toBe(2);
    expect(bodies).toHaveLength(2);
    expect(bodies[0]).toEqual(bodies[1]); // identical reissue
    expect(events.some(e => /provider desync: host idle with call in-flight/.test(e))).toBe(true);
    expect(usage[0]?.reissues).toBe(1);
    expect(watchdogMayCancel()).toBe(false);
  }, 15_000);

  it('mock busy → patience event, zero reissues', async () => {
    const events: string[] = [];
    let fetches = 0;
    let released = false;

    vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
      fetches += 1;
      // Stay pending past the quiet threshold, then succeed (host stays busy).
      await new Promise<void>(resolve => setTimeout(resolve, 80));
      released = true;
      return new Response(JSON.stringify({
        model: 'test-model',
        choices: [{ message: { content: JSON.stringify({
          title: 'T', files: [], tests: [],
        }) } }],
        usage: { prompt_tokens: 10, completion_tokens: 5 },
      }), { status: 200 });
    }));

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

    await llm.vanillaDraw('build a thing');
    expect(released).toBe(true);
    expect(fetches).toBe(1);
    expect(events.some(e => /still generating \(probed host: busy/.test(e))).toBe(true);
    expect(events.some(e => /desync/.test(e))).toBe(false);
    expect(usage[0]?.reissues ?? 0).toBe(0);
  }, 15_000);
});

describe('SET 22b retry messaging', () => {
  it('formats connection lost / recovered messages', () => {
    expect(connectionLostMessage(1, 4)).toBe('provider connection lost — retry 1/4');
    expect(recoveredAfterMessage(90_000)).toBe('provider recovered after 1m 30s');
  });

  it('transport retry emits events (no silent branch)', async () => {
    const events: string[] = [];
    let n = 0;
    vi.stubGlobal('fetch', vi.fn(async () => {
      n += 1;
      if (n === 1) throw new TypeError('fetch failed');
      return new Response(JSON.stringify({
        model: 'test-model',
        choices: [{ message: { content: JSON.stringify({
          title: 'T', files: [], tests: [],
        }) } }],
        usage: { prompt_tokens: 10, completion_tokens: 5 },
      }), { status: 200 });
    }));

    const usage: LlmCallRecord[] = [];
    const llm = new LocalLlmClient(u => usage.push(u), undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      quietProbeMs: 60_000,
      probeHost: async () => 'busy',
      onProviderEvent: m => events.push(m),
    });
    await llm.vanillaDraw('x');
    expect(events.some(e => /provider connection lost — retry 1\//.test(e))).toBe(true);
    expect(events.some(e => /provider recovered after/.test(e))).toBe(true);
    expect(usage[0]?.retries).toBe(1);
  }, 15_000);
});

describe('SET 22c degradation', () => {
  it('throughput cliff → degraded once until recovery', () => {
    const st = createDegradationState();
    // Warm-up at ~100 tok/s
    for (let i = 0; i < 5; i++) expect(observeThroughput(st, 100)).toBeNull();
    // Cliff — last-3 median drops below 25% once we have two low samples
    // (…, 100, 10, 10) → median 10.
    expect(observeThroughput(st, 10)).toBeNull();
    const deg = observeThroughput(st, 10);
    expect(deg).toMatch(/provider degraded: .* tok\/s vs .* baseline/);
    // Same cliff — no duplicate
    expect(observeThroughput(st, 8)).toBeNull();
    // Recover above 60%: last-3 median must clear 60 (…, 8, 70, 70).
    expect(observeThroughput(st, 70)).toBeNull();
    const rec = observeThroughput(st, 70);
    expect(rec).toMatch(/provider recovered:/);
  });
});

describe('SET 22d token ceiling pause', () => {
  it('pause fixture → issuance stops, state persists, resume works', async () => {
    let paused = false;
    const events: string[] = [];
    const usage: LlmCallRecord[] = [];
    let calls = 0;

    vi.stubGlobal('fetch', vi.fn(async () => {
      calls += 1;
      return new Response(JSON.stringify({
        model: 'test-model',
        choices: [{ message: { content: JSON.stringify({
          title: 'T', files: [], tests: [],
        }) } }],
        usage: { prompt_tokens: 1000, completion_tokens: 1000 },
      }), { status: 200 });
    }));

    const llm = new LocalLlmClient(u => usage.push(u), undefined, {
      baseURL: 'http://localhost:9999/v1',
      model: 'test-model',
      quietProbeMs: 60_000,
      tokenHighWaterEstimate: 500, // ceiling = 1500
      tokenCeilingMultiplier: 3,
      isPaused: () => paused,
      onPause: () => { paused = true; },
      awaitResume: async () => {
        // Auto-resume after a tick so the second call can proceed in this test.
        await new Promise(r => setTimeout(r, 20));
        paused = false;
      },
      onProviderEvent: m => events.push(m),
    });

    await llm.vanillaDraw('a');
    expect(events.some(e => /run paused: runaway token burn/.test(e))).toBe(true);
    expect(paused || events.some(e => /run paused/.test(e))).toBe(true);

    // Second call must wait on pause gate then proceed (resume clears).
    paused = true; // simulate still paused entering next call
    const p = llm.vanillaDraw('b');
    await p;
    expect(calls).toBe(2);
    expect(usage).toHaveLength(2);
  }, 15_000);

  it('observeTokenSpend freezes estimate and trips pause without cancel', () => {
    const st = createTokenCeilingState({ estimate: 100, multiplier: 3 });
    // 100*3=300 ceiling
    expect(observeTokenSpend(st, 200).pause).toBe(false);
    const trip = observeTokenSpend(st, 150);
    expect(trip.pause).toBe(true);
    expect(trip.message).toMatch(/run paused: runaway token burn/);
    expect(watchdogMayCancel()).toBe(false);
  });
});

describe('SET 22 quiet probe copy', () => {
  it('never claims slow-model without a probe', () => {
    expect(quietProbeMessage('busy', 120_000)).toMatch(/still generating \(probed host: busy/);
    expect(quietProbeMessage('idle', 120_000)).toMatch(/provider desync: host idle/);
    expect(quietProbeMessage('unreachable', 120_000)).toMatch(/provider unreachable/);
    expect(quietProbeMessage('busy', 120_000)).not.toMatch(/slow model/);
  });
});
