import { describe, it, expect, vi } from 'vitest';
import { VirtualLlmService, type VirtualSessionHandle } from '../src/services/virtual-llm.service.js';
import { InferenceTaskService } from '../src/services/inference-task.service.js';
import type { IInferenceTaskService } from '../src/services/inference-task.service.js';
import type { IInferenceTaskRepository } from '../src/repositories/inference-task.repository.js';
import type { ILlmRepository } from '../src/repositories/llm.repository.js';
import type { Llm, InferenceTask, InferenceTaskStatus } from '@prisma/client';

function makeLlm(overrides: Partial<Llm> = {}): Llm {
  return {
    id: `llm-${Math.random().toString(36).slice(2, 8)}`,
    name: 'vllm-local',
    type: 'openai',
    model: 'm',
    url: '',
    tier: 'fast',
    description: '',
    apiKeySecretId: null,
    apiKeySecretKey: null,
    extraConfig: {} as Llm['extraConfig'],
    poolName: null,
    kind: 'virtual',
    providerSessionId: 's-1',
    lastHeartbeatAt: new Date(),
    status: 'active',
    inactiveSince: null,
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

/**
 * Drop-in mock of `InferenceTaskService` backed by a Map. We mirror just
 * enough of the real service's signaling — events for terminal/chunk —
 * so VirtualLlmService's enqueue/result flows behave the same way they
 * do in production. Nothing here talks to Postgres.
 */
function mockTaskService(): IInferenceTaskService {
  // Build a minimal repo for the real InferenceTaskService — that way we
  // exercise the actual event-emitter wakeup logic, just without a DB.
  const rows = new Map<string, InferenceTask>();
  let n = 0;
  const repo: IInferenceTaskRepository = {
    create: vi.fn(async (data) => {
      n += 1;
      const row: InferenceTask = {
        id: `task-${String(n)}`,
        status: 'pending',
        poolName: data.poolName,
        llmName: data.llmName,
        model: data.model,
        tier: data.tier ?? null,
        claimedBy: null,
        requestBody: data.requestBody as InferenceTask['requestBody'],
        responseBody: null,
        errorMessage: null,
        streaming: data.streaming,
        createdAt: new Date(),
        claimedAt: null,
        streamStartedAt: null,
        completedAt: null,
        ownerId: data.ownerId,
        agentId: data.agentId ?? null,
      };
      rows.set(row.id, row);
      return row;
    }),
    findById: vi.fn(async (id) => rows.get(id) ?? null),
    findPendingForPools: vi.fn(async (poolNames: string[]) =>
      [...rows.values()].filter((r) => r.status === 'pending' && poolNames.includes(r.poolName)),
    ),
    findHeldBy: vi.fn(async (claimedBy: string) =>
      [...rows.values()].filter((r) =>
        r.claimedBy === claimedBy
        && (r.status === 'claimed' || r.status === 'running')),
    ),
    list: vi.fn(async () => [...rows.values()]),
    tryClaim: vi.fn(async (id, claimedBy, claimedAt) => {
      const r = rows.get(id);
      if (r === undefined || r.status !== 'pending') return null;
      const next = { ...r, status: 'claimed' as InferenceTaskStatus, claimedBy, claimedAt };
      rows.set(id, next);
      return next;
    }),
    markRunning: vi.fn(async (id, at) => {
      const r = rows.get(id);
      if (r === undefined || (r.status !== 'claimed' && r.status !== 'running')) return null;
      const next = { ...r, status: 'running' as InferenceTaskStatus, streamStartedAt: at };
      rows.set(id, next);
      return next;
    }),
    markCompleted: vi.fn(async (id, body, at) => {
      const r = rows.get(id);
      if (r === undefined || r.status === 'completed' || r.status === 'error' || r.status === 'cancelled') return null;
      const next = { ...r, status: 'completed' as InferenceTaskStatus, responseBody: (body ?? null) as InferenceTask['responseBody'], completedAt: at };
      rows.set(id, next);
      return next;
    }),
    markError: vi.fn(async (id, errorMessage, at) => {
      const r = rows.get(id);
      if (r === undefined || r.status === 'completed' || r.status === 'error' || r.status === 'cancelled') return null;
      const next = { ...r, status: 'error' as InferenceTaskStatus, errorMessage, completedAt: at };
      rows.set(id, next);
      return next;
    }),
    markCancelled: vi.fn(async (id, at) => {
      const r = rows.get(id);
      if (r === undefined || r.status === 'completed' || r.status === 'error' || r.status === 'cancelled') return null;
      const next = { ...r, status: 'cancelled' as InferenceTaskStatus, completedAt: at };
      rows.set(id, next);
      return next;
    }),
    revertToPending: vi.fn(async (id) => {
      const r = rows.get(id);
      if (r === undefined || (r.status !== 'claimed' && r.status !== 'running')) return null;
      const next = { ...r, status: 'pending' as InferenceTaskStatus, claimedBy: null, claimedAt: null, streamStartedAt: null };
      rows.set(id, next);
      return next;
    }),
    findStalePending: vi.fn(async () => []),
    findExpiredTerminal: vi.fn(async () => []),
    deleteMany: vi.fn(async (ids) => {
      let c = 0;
      for (const id of ids) if (rows.delete(id)) c += 1;
      return c;
    }),
  };
  return new InferenceTaskService(repo);
}

function mockRepo(initial: Llm[] = []): ILlmRepository {
  const rows = new Map<string, Llm>(initial.map((l) => [l.id, l]));
  let counter = rows.size;
  return {
    findAll: vi.fn(async () => [...rows.values()]),
    findById: vi.fn(async (id: string) => rows.get(id) ?? null),
    findByName: vi.fn(async (name: string) => {
      for (const l of rows.values()) if (l.name === name) return l;
      return null;
    }),
    findByTier: vi.fn(async () => []),
    findByPoolName: vi.fn(async (poolName: string) => {
      const out: Llm[] = [];
      for (const l of rows.values()) {
        if (l.poolName === poolName) out.push(l);
        else if (l.poolName === null && l.name === poolName) out.push(l);
      }
      return out;
    }),
    findBySessionId: vi.fn(async (sid: string) =>
      [...rows.values()].filter((l) => l.providerSessionId === sid)),
    findStaleVirtuals: vi.fn(async (cutoff: Date) =>
      [...rows.values()].filter((l) =>
        l.kind === 'virtual'
        && l.status === 'active'
        && l.lastHeartbeatAt !== null
        && l.lastHeartbeatAt < cutoff)),
    findExpiredInactives: vi.fn(async (cutoff: Date) =>
      [...rows.values()].filter((l) =>
        l.kind === 'virtual'
        && l.status === 'inactive'
        && l.inactiveSince !== null
        && l.inactiveSince < cutoff)),
    create: vi.fn(async (data) => {
      counter += 1;
      const row = makeLlm({
        id: `llm-${String(counter)}`,
        name: data.name,
        type: data.type,
        model: data.model,
        url: data.url ?? '',
        tier: data.tier ?? 'fast',
        description: data.description ?? '',
        kind: data.kind ?? 'public',
        providerSessionId: data.providerSessionId ?? null,
        status: data.status ?? 'active',
        lastHeartbeatAt: data.lastHeartbeatAt ?? null,
        inactiveSince: data.inactiveSince ?? null,
      });
      rows.set(row.id, row);
      return row;
    }),
    update: vi.fn(async (id, data) => {
      const existing = rows.get(id);
      if (!existing) throw new Error('not found');
      const next: Llm = {
        ...existing,
        ...(data.type !== undefined ? { type: data.type } : {}),
        ...(data.model !== undefined ? { model: data.model } : {}),
        ...(data.tier !== undefined ? { tier: data.tier } : {}),
        ...(data.description !== undefined ? { description: data.description } : {}),
        ...(data.kind !== undefined ? { kind: data.kind } : {}),
        ...(data.providerSessionId !== undefined ? { providerSessionId: data.providerSessionId } : {}),
        ...(data.status !== undefined ? { status: data.status } : {}),
        ...(data.lastHeartbeatAt !== undefined ? { lastHeartbeatAt: data.lastHeartbeatAt } : {}),
        ...(data.inactiveSince !== undefined ? { inactiveSince: data.inactiveSince } : {}),
      };
      rows.set(id, next);
      return next;
    }),
    delete: vi.fn(async (id: string) => { rows.delete(id); }),
  };
}

function fakeSession(): VirtualSessionHandle & { tasks: Array<unknown>; alive: boolean } {
  const tasks: unknown[] = [];
  return {
    tasks,
    alive: true,
    pushTask(t) { tasks.push(t); },
  };
}

describe('VirtualLlmService', () => {
  it('register inserts new virtual rows with active status + sessionId', async () => {
    const repo = mockRepo();
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const { providerSessionId, llms } = await svc.register({
      providerSessionId: null,
      providers: [
        { name: 'vllm-local', type: 'openai', model: 'Qwen/Qwen2.5-7B-Instruct-AWQ', tier: 'fast' },
      ],
    });
    expect(providerSessionId).toMatch(/^[0-9a-f-]{36}$/);
    expect(llms).toHaveLength(1);
    expect(llms[0]!.kind).toBe('virtual');
    expect(llms[0]!.status).toBe('active');
    expect(llms[0]!.providerSessionId).toBe(providerSessionId);
    expect(llms[0]!.lastHeartbeatAt).not.toBeNull();
  });

  it('register reuses the same row on sticky reconnect (same name + sessionId)', async () => {
    const repo = mockRepo();
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const first = await svc.register({
      providerSessionId: 'fixed-id',
      providers: [{ name: 'vllm-local', type: 'openai', model: 'm' }],
    });
    expect(first.llms[0]!.id).toMatch(/^llm-/);
    const firstId = first.llms[0]!.id;

    const second = await svc.register({
      providerSessionId: 'fixed-id',
      providers: [{ name: 'vllm-local', type: 'openai', model: 'm-updated' }],
    });
    expect(second.llms[0]!.id).toBe(firstId);
    expect(second.llms[0]!.model).toBe('m-updated');
  });

  it('register refuses to overwrite a public LLM with the same name', async () => {
    const repo = mockRepo([makeLlm({ name: 'qwen3-thinking', kind: 'public', providerSessionId: null })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    await expect(svc.register({
      providerSessionId: 'sess-x',
      providers: [{ name: 'qwen3-thinking', type: 'openai', model: 'm' }],
    })).rejects.toThrow(/Cannot publish over public/);
  });

  it('register refuses if another active session owns the name', async () => {
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'other', status: 'active' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    await expect(svc.register({
      providerSessionId: 'mine',
      providers: [{ name: 'vllm-local', type: 'openai', model: 'm' }],
    })).rejects.toThrow(/already active under a different session/);
  });

  it('register adopts an inactive virtual row from a different session (sticky reconnect after lapse)', async () => {
    const repo = mockRepo([makeLlm({
      name: 'vllm-local', providerSessionId: 'old-session',
      status: 'inactive', inactiveSince: new Date(),
    })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const { llms } = await svc.register({
      providerSessionId: 'new-session',
      providers: [{ name: 'vllm-local', type: 'openai', model: 'm' }],
    });
    expect(llms[0]!.providerSessionId).toBe('new-session');
    expect(llms[0]!.status).toBe('active');
    expect(llms[0]!.inactiveSince).toBeNull();
  });

  it('heartbeat bumps lastHeartbeatAt + revives an inactive row', async () => {
    const past = new Date(Date.now() - 5_000);
    const repo = mockRepo([makeLlm({
      name: 'vllm-local', providerSessionId: 'sess', status: 'inactive',
      lastHeartbeatAt: past, inactiveSince: past,
    })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    await svc.heartbeat('sess');
    const row = await repo.findByName('vllm-local');
    expect(row?.status).toBe('active');
    expect(row?.inactiveSince).toBeNull();
    expect(row!.lastHeartbeatAt!.getTime()).toBeGreaterThan(past.getTime());
  });

  it('unbindSession flips all owned rows to inactive immediately', async () => {
    const repo = mockRepo([
      makeLlm({ name: 'a', providerSessionId: 'sess' }),
      makeLlm({ name: 'b', providerSessionId: 'sess' }),
      makeLlm({ name: 'c', providerSessionId: 'other' }),
    ]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    svc.bindSession('sess', fakeSession());
    await svc.unbindSession('sess');
    expect((await repo.findByName('a'))?.status).toBe('inactive');
    expect((await repo.findByName('b'))?.status).toBe('inactive');
    expect((await repo.findByName('c'))?.status).toBe('active');
  });

  it('enqueueInferTask pushes a task frame to the SSE session', async () => {
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const session = fakeSession();
    svc.bindSession('sess', session);

    const ref = await svc.enqueueInferTask(
      'vllm-local',
      { model: 'm', messages: [{ role: 'user', content: 'hi' }] },
      false,
    );
    expect(session.tasks).toHaveLength(1);
    const t = session.tasks[0] as { kind: string; taskId: string; llmName: string; streaming: boolean };
    expect(t.kind).toBe('infer');
    expect(t.taskId).toBe(ref.taskId);
    expect(t.llmName).toBe('vllm-local');
    expect(t.streaming).toBe(false);
  });

  it('enqueueInferTask queues the task when no session is bound (durable, drains on bind)', async () => {
    // v5 semantic change: with a durable queue underneath, "no worker
    // up" no longer rejects — the row stays pending and a future
    // bindSession drains it. Caller's HTTP handler awaits on ref.done
    // and bounds itself with INFER_AWAIT_TIMEOUT_MS; from the service's
    // POV the enqueue itself succeeds.
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess' })]);
    const tasks = mockTaskService();
    const svc = new VirtualLlmService(repo, undefined, tasks);
    const ref = await svc.enqueueInferTask('vllm-local', { model: 'm', messages: [] }, false);
    expect(ref.taskId).toMatch(/^task-/);
    // The row exists and is still pending — no claim happened.
    const row = await tasks.findById(ref.taskId);
    expect(row?.status).toBe('pending');
    expect(row?.claimedBy).toBeNull();
  });

  it('enqueueInferTask still queues against an inactive row (pool may have a sibling worker)', async () => {
    // v5 semantic change: status=inactive on a specific Llm doesn't
    // mean the pool is dead — another mcplocal publishing the same
    // poolName might be active. The dispatcher's bindSession drain
    // matches by poolName, so even a "dead" pin queues correctly.
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess', status: 'inactive', inactiveSince: new Date() })]);
    const tasks = mockTaskService();
    const svc = new VirtualLlmService(repo, undefined, tasks);
    const ref = await svc.enqueueInferTask('vllm-local', { model: 'm', messages: [] }, false);
    const row = await tasks.findById(ref.taskId);
    expect(row?.status).toBe('pending');
    // No frame pushed because no session is bound.
    expect(row?.claimedBy).toBeNull();
  });

  it('enqueueInferTask rejects when the LLM is public (not virtual)', async () => {
    const repo = mockRepo([makeLlm({ name: 'qwen3-thinking', kind: 'public', providerSessionId: null })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    await expect(
      svc.enqueueInferTask('qwen3-thinking', { model: 'm', messages: [] }, false),
    ).rejects.toThrow(/not a virtual provider/);
  });

  it('completeTask resolves the pending non-streaming promise', async () => {
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    svc.bindSession('sess', fakeSession());
    const ref = await svc.enqueueInferTask(
      'vllm-local',
      { model: 'm', messages: [{ role: 'user', content: 'hi' }] },
      false,
    );
    expect(svc.completeTask(ref.taskId, { status: 200, body: { ok: true } })).toBe(true);
    await expect(ref.done).resolves.toEqual({ status: 200, body: { ok: true } });
  });

  it('streaming: pushTaskChunk fans chunks to subscribers; done resolves the ref', async () => {
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    svc.bindSession('sess', fakeSession());
    const ref = await svc.enqueueInferTask(
      'vllm-local',
      { model: 'm', messages: [{ role: 'user', content: 'hi' }], stream: true },
      true,
    );
    const got: Array<{ data: string; done?: boolean }> = [];
    ref.onChunk((c) => got.push(c));

    expect(svc.pushTaskChunk(ref.taskId, { data: 'hello' })).toBe(true);
    expect(svc.pushTaskChunk(ref.taskId, { data: ' world' })).toBe(true);
    expect(svc.pushTaskChunk(ref.taskId, { data: '[DONE]', done: true })).toBe(true);

    expect(got.map((c) => c.data)).toEqual(['hello', ' world', '[DONE]']);
    await expect(ref.done).resolves.toMatchObject({ status: 200 });
  });

  it('failTask rejects the pending promise with a clear error', async () => {
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    svc.bindSession('sess', fakeSession());
    const ref = await svc.enqueueInferTask(
      'vllm-local',
      { model: 'm', messages: [{ role: 'user', content: 'hi' }] },
      false,
    );
    expect(svc.failTask(ref.taskId, new Error('upstream blew up'))).toBe(true);
    await expect(ref.done).rejects.toThrow(/upstream blew up/);
  });

  it('unbindSession reverts claimed inference tasks to pending (durable re-queue, not reject)', async () => {
    // v5 semantic change: a worker disconnecting mid-task no longer
    // *rejects* the task. The row goes back to pending so another
    // worker on the same pool can pick it up. The original caller's
    // ref.done keeps waiting up to its 10-min INFER_AWAIT_TIMEOUT_MS;
    // the same caller is what gets the result whichever worker
    // ultimately delivers it.
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess' })]);
    const tasks = mockTaskService();
    const svc = new VirtualLlmService(repo, undefined, tasks);
    svc.bindSession('sess', fakeSession());
    const ref = await svc.enqueueInferTask(
      'vllm-local',
      { model: 'm', messages: [{ role: 'user', content: 'hi' }] },
      false,
    );
    // After enqueue with a session up, the task is claimed.
    let row = await tasks.findById(ref.taskId);
    expect(row?.status).toBe('claimed');
    expect(row?.claimedBy).toBe('sess');

    await svc.unbindSession('sess');

    // After disconnect, claimed/running rows revert to pending — ready
    // for the next worker to drain.
    row = await tasks.findById(ref.taskId);
    expect(row?.status).toBe('pending');
    expect(row?.claimedBy).toBeNull();
  });

  it('gcSweep flips heartbeat-stale active virtuals to inactive', async () => {
    const long = new Date(Date.now() - 5 * 60 * 1000); // 5 min ago — past the 90-s cutoff
    const recent = new Date(Date.now() - 30 * 1000);   // 30 s ago — within the cutoff
    const repo = mockRepo([
      makeLlm({ name: 'stale', providerSessionId: 'a', status: 'active', lastHeartbeatAt: long }),
      makeLlm({ name: 'fresh', providerSessionId: 'b', status: 'active', lastHeartbeatAt: recent }),
    ]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const result = await svc.gcSweep();
    expect(result.markedInactive).toBe(1);
    expect((await repo.findByName('stale'))?.status).toBe('inactive');
    expect((await repo.findByName('fresh'))?.status).toBe('active');
  });

  it('gcSweep deletes virtuals inactive past the 4h retention window', async () => {
    const ancient = new Date(Date.now() - 5 * 60 * 60 * 1000); // 5 h ago
    const fresh = new Date(Date.now() - 1 * 60 * 60 * 1000);   // 1 h ago
    const repo = mockRepo([
      makeLlm({ name: 'old', providerSessionId: 'a', status: 'inactive', inactiveSince: ancient }),
      makeLlm({ name: 'recent', providerSessionId: 'b', status: 'inactive', inactiveSince: fresh }),
      makeLlm({ name: 'public-survivor', providerSessionId: null, kind: 'public' }),
    ]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const result = await svc.gcSweep();
    expect(result.deleted).toBe(1);
    expect(await repo.findByName('old')).toBeNull();
    expect(await repo.findByName('recent')).not.toBeNull();
    expect(await repo.findByName('public-survivor')).not.toBeNull();
  });

  // ── v2: wake-before-infer ──

  it('hibernating: dispatches a wake task first and waits for it to complete before infer', async () => {
    const repo = mockRepo([makeLlm({ name: 'sleeping', providerSessionId: 'sess', status: 'hibernating' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const session = fakeSession();
    svc.bindSession('sess', session);

    // Kick off enqueueInferTask. It blocks on the wake task.
    const inferPromise = svc.enqueueInferTask(
      'sleeping',
      { model: 'm', messages: [{ role: 'user', content: 'hi' }] },
      false,
    );

    // Wait a tick so the wake task gets pushed.
    await new Promise((r) => setTimeout(r, 0));
    expect(session.tasks).toHaveLength(1);
    const wakeTask = session.tasks[0] as { kind: string; taskId: string; llmName: string };
    expect(wakeTask.kind).toBe('wake');
    expect(wakeTask.llmName).toBe('sleeping');

    // Resolve the wake task — service flips the row to active, then
    // pushes the infer task on the same session.
    expect(svc.completeTask(wakeTask.taskId, { status: 200, body: { ok: true } })).toBe(true);
    const ref = await inferPromise;
    expect(session.tasks).toHaveLength(2);
    const inferTask = session.tasks[1] as { kind: string; taskId: string };
    expect(inferTask.kind).toBe('infer');
    expect(inferTask.taskId).toBe(ref.taskId);

    // The row should be active now — concurrent callers won't trigger another wake.
    const row = await repo.findByName('sleeping');
    expect(row?.status).toBe('active');
  });

  it('hibernating: concurrent infer requests share a single wake task', async () => {
    const repo = mockRepo([makeLlm({ name: 'sleeping', providerSessionId: 'sess', status: 'hibernating' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const session = fakeSession();
    svc.bindSession('sess', session);

    // Fire 3 concurrent infer requests against the same hibernating LLM.
    const reqs = [
      svc.enqueueInferTask('sleeping', { model: 'm', messages: [] }, false),
      svc.enqueueInferTask('sleeping', { model: 'm', messages: [] }, false),
      svc.enqueueInferTask('sleeping', { model: 'm', messages: [] }, false),
    ];

    await new Promise((r) => setTimeout(r, 0));
    // Exactly one wake task pushed, regardless of concurrent infers.
    const wakeTasks = (session.tasks as Array<{ kind: string }>).filter((t) => t.kind === 'wake');
    expect(wakeTasks).toHaveLength(1);

    const wakeTaskId = (session.tasks[0] as { taskId: string }).taskId;
    expect(svc.completeTask(wakeTaskId, { status: 200, body: { ok: true } })).toBe(true);

    const refs = await Promise.all(reqs);
    // After wake, all 3 infer tasks pushed — total session tasks = 1 wake + 3 infer.
    const inferTasks = (session.tasks as Array<{ kind: string }>).filter((t) => t.kind === 'infer');
    expect(inferTasks).toHaveLength(3);
    expect(refs.map((r) => r.taskId).sort()).toEqual(refs.map((r) => r.taskId).sort());
  });

  it('hibernating: rejects when the wake task fails', async () => {
    const repo = mockRepo([makeLlm({ name: 'broken', providerSessionId: 'sess', status: 'hibernating' })]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    svc.bindSession('sess', fakeSession());

    const inferPromise = svc.enqueueInferTask(
      'broken',
      { model: 'm', messages: [] },
      false,
    );
    await new Promise((r) => setTimeout(r, 0));

    // v5: wake tasks live in `wakeTasks` (in-memory). Inference tasks
    // moved to the DB-backed queue but wake is publisher-control work
    // that doesn't need durability — we kept the in-memory map for it.
    const taskIds: string[] = [];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    for (const id of (svc as any).wakeTasks.keys()) taskIds.push(id);
    expect(taskIds).toHaveLength(1);
    expect(svc.failTask(taskIds[0]!, new Error('wake recipe failed'))).toBe(true);

    await expect(inferPromise).rejects.toThrow(/wake recipe failed/);

    // Row stayed hibernating — the next request will get another wake try.
    const row = await repo.findByName('broken');
    expect(row?.status).toBe('hibernating');
  });

  it('inactive: queues without firing the wake path — wake only triggers on status=hibernating', async () => {
    // Coverage for the v5 inactive-vs-hibernating distinction.
    // hibernating = "publisher told us the backend is asleep, ask
    // them to wake it"; inactive = "publisher itself is offline".
    // For inactive rows, queueing is the right behavior (wait for a
    // worker on the pool to come online and drain). The wake path
    // must NOT fire — wake is opt-in via the publisher's register
    // payload, not a generic "row is down" recovery.
    //
    // No session bind here: an "inactive" row in production means
    // unbindSession already flipped it after SSE close. Binding a
    // session for the same providerSessionId would be a contradictory
    // setup that the test wouldn't model anything real about.
    const repo = mockRepo([makeLlm({ name: 'gone', providerSessionId: 'sess', status: 'inactive', inactiveSince: new Date() })]);
    const tasks = mockTaskService();
    const svc = new VirtualLlmService(repo, undefined, tasks);

    const ref = await svc.enqueueInferTask('gone', { model: 'm', messages: [] }, false);
    // Task queued in pending; no claim, no frame.
    const row = await tasks.findById(ref.taskId);
    expect(row?.status).toBe('pending');
    expect(row?.claimedBy).toBeNull();
  });

  it('gcSweep is idempotent — running twice in a row is a no-op the second time', async () => {
    const long = new Date(Date.now() - 5 * 60 * 1000);
    const repo = mockRepo([
      makeLlm({ name: 'stale', providerSessionId: 'a', status: 'active', lastHeartbeatAt: long }),
    ]);
    const svc = new VirtualLlmService(repo, undefined, mockTaskService());
    const first = await svc.gcSweep();
    const second = await svc.gcSweep();
    expect(first.markedInactive).toBe(1);
    expect(second.markedInactive).toBe(0);
    expect(second.deleted).toBe(0);
  });

  // ── v5: durable queue + drain-on-bind ──

  it('bindSession drains pending inference tasks owned by the session\'s pool keys', async () => {
    // Two enqueues land while no session is bound. Each row is created
    // with status=pending; no SSE frame goes anywhere. When the worker
    // finally binds, the drain loop claims both and pushes the frames.
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess', poolName: 'qwen-pool' })]);
    const tasks = mockTaskService();
    const svc = new VirtualLlmService(repo, undefined, tasks);
    const ref1 = await svc.enqueueInferTask('vllm-local', { model: 'm', messages: [{ role: 'user', content: 'one' }] }, false);
    const ref2 = await svc.enqueueInferTask('vllm-local', { model: 'm', messages: [{ role: 'user', content: 'two' }] }, false);
    // Both rows are still pending — no worker bound yet.
    expect((await tasks.findById(ref1.taskId))?.status).toBe('pending');
    expect((await tasks.findById(ref2.taskId))?.status).toBe('pending');

    // Worker shows up. Drain runs synchronously enough that we just
    // need to flush the microtask queue before checking SSE frames.
    const session = fakeSession();
    svc.bindSession('sess', session);
    // drainPendingForSession is fired with `void` so let microtasks
    // settle before asserting.
    await new Promise((r) => setTimeout(r, 0));

    expect((session.tasks as Array<{ kind: string; taskId: string }>).map((t) => t.taskId).sort())
      .toEqual([ref1.taskId, ref2.taskId].sort());
    // Rows are now claimed by this session.
    expect((await tasks.findById(ref1.taskId))?.status).toBe('claimed');
    expect((await tasks.findById(ref1.taskId))?.claimedBy).toBe('sess');
  });

  it('drain-on-bind matches the effective pool key, not just llm.name', async () => {
    // The pinned Llm has name=vllm-alice but poolName=qwen-pool.
    // Enqueue against vllm-alice → row.poolName=qwen-pool.
    // Worker binds with a session that owns vllm-alice (same pool key).
    // Drain must surface this row even though poolName != name.
    const repo = mockRepo([makeLlm({ name: 'vllm-alice', providerSessionId: 'sess', poolName: 'qwen-pool' })]);
    const tasks = mockTaskService();
    const svc = new VirtualLlmService(repo, undefined, tasks);
    const ref = await svc.enqueueInferTask('vllm-alice', { model: 'm', messages: [] }, false);
    expect((await tasks.findById(ref.taskId))?.poolName).toBe('qwen-pool');

    const session = fakeSession();
    svc.bindSession('sess', session);
    await new Promise((r) => setTimeout(r, 0));
    expect((session.tasks as Array<{ taskId: string }>).map((t) => t.taskId)).toEqual([ref.taskId]);
  });

  it('completeTask via the result-route updates the DB row + emits the wakeup', async () => {
    // End-to-end through the public surface: enqueue → claim happens
    // because session is bound → worker POSTs result → completeTask
    // routes to InferenceTaskService.complete → ref.done resolves.
    const repo = mockRepo([makeLlm({ name: 'vllm-local', providerSessionId: 'sess' })]);
    const tasks = mockTaskService();
    const svc = new VirtualLlmService(repo, undefined, tasks);
    svc.bindSession('sess', fakeSession());
    const ref = await svc.enqueueInferTask('vllm-local', { model: 'm', messages: [] }, false);

    expect(svc.completeTask(ref.taskId, { status: 200, body: { ok: true } })).toBe(true);
    await expect(ref.done).resolves.toEqual({ status: 200, body: { ok: true } });
    const row = await tasks.findById(ref.taskId);
    expect(row?.status).toBe('completed');
    expect(row?.responseBody).toEqual({ ok: true });
  });
});
