/**
 * VirtualLlmService — lifecycle for `kind=virtual` Llm rows.
 *
 * The story end-to-end:
 *   1. mcplocal POSTs `/api/v1/llms/_provider-register` with the providers
 *      it wants to publish. We upsert each into the `Llm` table marked
 *      kind=virtual / status=active and return a stable
 *      `providerSessionId` to the caller.
 *   2. mcplocal opens the SSE channel on `/api/v1/llms/_provider-stream`.
 *      `bindSession()` records the SSE handle in memory keyed by
 *      `providerSessionId`. Disconnect → `disconnect()` flips the rows to
 *      inactive immediately.
 *   3. Heartbeats land on `/api/v1/llms/_provider-heartbeat` and bump
 *      `lastHeartbeatAt`. The 60-s GC sweep moves heartbeat-stale rows to
 *      inactive (catches sessions whose disconnect we missed) and deletes
 *      anything inactive past the 4-h cutoff.
 *   4. At inference time `/api/v1/llms/:name/infer` resolves the row, sees
 *      kind=virtual, and asks `enqueueInferTask()` to relay through the SSE
 *      session. The session pumps the OpenAI body to mcplocal as a `task`
 *      frame and waits for the result POST on
 *      `/api/v1/llms/_provider-task/:taskId/result`.
 *
 * In v1 there's no wake-on-demand (v2) and no LB pool (v4). One open SSE
 * session per `providerSessionId`; one inference at a time per task id.
 */
import type { Llm } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import type { ILlmRepository } from '../repositories/llm.repository.js';
import type { OpenAiChatRequest } from './llm/types.js';
import { NotFoundError } from './mcp-server.service.js';
import type { AgentService } from './agent.service.js';
import type { IInferenceTaskService, InferenceTaskChunk } from './inference-task.service.js';
import { effectivePoolName } from './llm.service.js';

/** A virtual provider's announcement at registration time. */
export interface RegisterProviderInput {
  name: string;
  type: string;
  model: string;
  tier?: string;
  description?: string;
  extraConfig?: Record<string, unknown>;
  /**
   * Optional. Lets the publisher hint that the underlying backend is
   * asleep — mcpd records the row as `hibernating` and will dispatch a
   * `wake` task before any inference. Defaults to `active` (today's
   * behavior). v2 publishers (mcplocal with a configured wake recipe)
   * pass 'hibernating' when `LlmProvider.isAvailable()` returns false at
   * publish time.
   */
  initialStatus?: 'active' | 'hibernating';
  /**
   * v4: optional pool key. When set, this virtual Llm row stacks with
   * any other Llms (public OR virtual from any session) sharing the
   * same value. The chat dispatcher then load-balances across all
   * healthy members. Cluster-wide name uniqueness still applies — the
   * publisher picks a unique `name` (e.g. `vllm-<host>-qwen3`) and
   * shares the `poolName` with siblings.
   */
  poolName?: string;
  /**
   * v7: per-user RBAC scoping. When omitted, virtuals default to
   * 'private' (visible only to the publishing user). Set explicitly
   * to 'public' for org-wide sharing.
   */
  visibility?: 'public' | 'private';
}

export interface RegisterResult {
  providerSessionId: string;
  llms: Llm[];
}

/**
 * In-memory handle for a live SSE session. The route owns the actual
 * Fastify reply object; this interface is what the service expects from
 * it. Decouples the service from Fastify so unit tests can use a stub.
 */
export interface VirtualSessionHandle {
  /** Send a server-sent task frame to the publisher (`event: task`). */
  pushTask(task: VirtualTaskFrame): void;
  /** True iff the underlying SSE response is still writable. */
  readonly alive: boolean;
}

export type VirtualTaskFrame =
  | { kind: 'infer'; taskId: string; llmName: string; request: OpenAiChatRequest; streaming: boolean }
  // v2 wake task lives here so the SSE protocol stays additive.
  | { kind: 'wake'; taskId: string; llmName: string };

/**
 * In-memory wake task. Wake is publisher-control work, not inference —
 * we don't persist wake tasks to the DB because their lifetime is
 * milliseconds and missing a wake on restart just means the next infer
 * fires a fresh wake. Inference tasks live in the durable queue (v5);
 * see InferenceTaskService.
 */
interface InMemoryWakeTask {
  taskId: string;
  sessionId: string;
  llmName: string;
  resolve: (status: number) => void;
  reject: (err: Error) => void;
}

const HEARTBEAT_TIMEOUT_MS = 90_000;
const INACTIVE_RETENTION_MS = 4 * 60 * 60 * 1000; // 4 h
/**
 * v5: how long enqueueInferTask waits for the worker's terminal POST
 * before bailing. 10 minutes is generous for thinking models that
 * spend 30-90s warming up. The TASK itself stays queued past this —
 * only the in-flight HTTP handler gives up; an async-API consumer can
 * still poll the row and pick up the result later.
 */
const INFER_AWAIT_TIMEOUT_MS = 10 * 60 * 1000;

export interface EnqueueInferOptions {
  /**
   * v5: if true, throw 503 immediately when no live SSE session exists
   * for the row's session at enqueue time, instead of queueing the
   * task and waiting for a worker to show up. Used by:
   *   - the direct `/api/v1/llms/<name>/infer` route (caller asked for
   *     THIS specific Llm; no pool fanout at this layer)
   *   - chat.service's pool failover loop (it iterates candidates and
   *     needs each candidate's failure to surface fast)
   * The async `POST /api/v1/inference-tasks` API leaves this false so
   * callers explicitly opting into the durable queue get the queue.
   * Default: false (durable, queues + waits up to INFER_AWAIT_TIMEOUT_MS).
   */
  failFast?: boolean;
  /**
   * v5: caller's user id, threaded directly into the new task row's
   * `ownerId`. When omitted falls back to the constructor-injected
   * `resolveOwner()` (default: 'system'). The async API route passes
   * the authenticated user explicitly so owner-scoped lookups work;
   * legacy callers that don't care (chat.service, direct infer) leave
   * it undefined.
   */
  ownerId?: string;
}

export interface IVirtualLlmService {
  register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise<RegisterResult>;
  heartbeat(providerSessionId: string): Promise<void>;
  bindSession(providerSessionId: string, handle: VirtualSessionHandle): void;
  unbindSession(providerSessionId: string): Promise<void>;
  enqueueInferTask(llmName: string, request: OpenAiChatRequest, streaming: boolean, options?: EnqueueInferOptions): Promise<PendingTaskRef>;
  completeTask(taskId: string, result: { status: number; body: unknown }): boolean;
  pushTaskChunk(taskId: string, chunk: { data: string; done?: boolean }): boolean;
  failTask(taskId: string, error: Error): boolean;
  gcSweep(now?: Date): Promise<{ markedInactive: number; deleted: number }>;
}

/** Returned to the route handler so it can await the result. */
export interface PendingTaskRef {
  taskId: string;
  /** Resolves for non-streaming tasks. Streaming tasks reject this — use the chunk callback path. */
  done: Promise<{ status: number; body: unknown }>;
  /** Streaming-only: subscribe to chunks. Returns an unsubscribe fn. */
  onChunk(cb: (chunk: { data: string; done?: boolean }) => void): () => void;
}

export class VirtualLlmService implements IVirtualLlmService {
  private readonly sessions = new Map<string, VirtualSessionHandle>();
  /**
   * Wake tasks live here, in-memory. The result POST resolves them by
   * id. Inference tasks have moved to the durable queue (v5); see
   * IInferenceTaskService.
   */
  private readonly wakeTasks = new Map<string, InMemoryWakeTask>();
  /**
   * Dedupe concurrent wake requests for the same Llm. The first request
   * starts the wake; later requests for the same name await the same
   * promise. Cleared as soon as the wake settles (success or failure).
   */
  private readonly wakeInFlight = new Map<string, Promise<void>>();

  constructor(
    private readonly repo: ILlmRepository,
    /**
     * Optional. v3 wires AgentService here so the lifecycle cascades:
     * heartbeat → bump owned agents; disconnect → mark agents inactive;
     * gcSweep → sweep agents first, then delete pinned-to-Llm cascade
     * before deleting the Llm itself (Agent.llmId is Restrict).
     */
    private readonly agents?: AgentService,
    /**
     * v5: durable inference task queue. Optional so older test wirings
     * (and the non-virtual chat path) compile without it; when absent,
     * enqueueInferTask falls back to a clear "task queue not wired"
     * error rather than silently regressing to in-memory.
     */
    private readonly tasks?: IInferenceTaskService,
    /**
     * v5: caller's user id, threaded into newly-created task rows for
     * RBAC + observability. Optional — older wirings that don't have a
     * request context attribute tasks to 'system'.
     */
    private readonly resolveOwner: () => string = () => 'system',
  ) {}

  async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise<RegisterResult> {
    const sessionId = input.providerSessionId ?? randomUUID();
    const now = new Date();
    const llms: Llm[] = [];

    for (const p of input.providers) {
      const initialStatus = p.initialStatus ?? 'active';
      const existing = await this.repo.findByName(p.name);
      if (existing === null) {
        const created = await this.repo.create({
          name: p.name,
          type: p.type,
          model: p.model,
          tier: p.tier ?? 'fast',
          description: p.description ?? '',
          ...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}),
          ...(p.poolName !== undefined ? { poolName: p.poolName } : {}),
          // v7: virtuals default to private ownership. The publisher
          // (passed through from the route's authenticated userId)
          // owns the row; mcplocal's defaulting + the publisher's
          // explicit override land here.
          ownerId: input.ownerId ?? null,
          visibility: p.visibility ?? 'private',
          kind: 'virtual',
          providerSessionId: sessionId,
          status: initialStatus,
          lastHeartbeatAt: now,
          inactiveSince: null,
        });
        llms.push(created);
        continue;
      }

      // Existing row. Only allowed to (re-)register over a virtual row owned
      // by the same session, OR an inactive virtual whose session went away
      // (sticky reconnect). Refuse to overwrite a public row or someone
      // else's active virtual.
      if (existing.kind === 'public') {
        throw Object.assign(
          new Error(`Cannot publish over public LLM: ${p.name}`),
          { statusCode: 409 },
        );
      }
      if (existing.providerSessionId !== sessionId && existing.status === 'active') {
        throw Object.assign(
          new Error(`Virtual LLM '${p.name}' is already active under a different session`),
          { statusCode: 409 },
        );
      }

      const updated = await this.repo.update(existing.id, {
        type: p.type,
        model: p.model,
        ...(p.tier !== undefined ? { tier: p.tier } : {}),
        ...(p.description !== undefined ? { description: p.description } : {}),
        ...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}),
        ...(p.poolName !== undefined ? { poolName: p.poolName } : {}),
        // v7: only update visibility on sticky reconnect when the
        // publisher explicitly sent it — operators may have flipped a
        // virtual to public manually via `mcpctl edit llm`, and we
        // don't want a routine reconnect to clobber that.
        ...(p.visibility !== undefined ? { visibility: p.visibility } : {}),
        kind: 'virtual',
        providerSessionId: sessionId,
        status: initialStatus,
        lastHeartbeatAt: now,
        inactiveSince: null,
      });
      llms.push(updated);
    }

    return { providerSessionId: sessionId, llms };
  }

  async heartbeat(providerSessionId: string): Promise<void> {
    const owned = await this.repo.findBySessionId(providerSessionId);
    const now = new Date();
    for (const row of owned) {
      // Bump lastHeartbeatAt; if the row was already inactive (e.g. due to a
      // network blip that lapsed the SSE), revive it.
      await this.repo.update(row.id, {
        lastHeartbeatAt: now,
        ...(row.status === 'inactive'
          ? { status: 'active', inactiveSince: null }
          : {}),
      });
    }
    // Cascade to virtual agents owned by the same session — same heartbeat
    // covers them. No-op if AgentService isn't wired (older test configs).
    if (this.agents !== undefined) {
      await this.agents.heartbeatVirtualAgents(providerSessionId);
    }
  }

  bindSession(providerSessionId: string, handle: VirtualSessionHandle): void {
    // Replace any prior handle for this session — keeps "last writer wins"
    // simple. The old SSE will have been closed by the publisher anyway.
    this.sessions.set(providerSessionId, handle);
    // v5: drain queued inference tasks targeting any pool this session
    // owns. Fire-and-forget: the bind() route handler completes the SSE
    // handshake first; tasks land on the channel right after.
    if (this.tasks !== undefined) {
      void this.drainPendingForSession(providerSessionId, handle);
    }
  }

  async unbindSession(providerSessionId: string): Promise<void> {
    this.sessions.delete(providerSessionId);
    // Flip every Llm owned by that session to inactive immediately.
    const owned = await this.repo.findBySessionId(providerSessionId);
    const now = new Date();
    for (const row of owned) {
      if (row.status === 'active') {
        await this.repo.update(row.id, { status: 'inactive', inactiveSince: now });
      }
    }
    // Cascade to virtual agents owned by the same session.
    if (this.agents !== undefined) {
      await this.agents.markVirtualAgentsInactiveBySession(providerSessionId);
    }
    // v5: revert claimed/running inference tasks back to pending so
    // another worker on the same pool can pick them up. If no other
    // worker is up, they stay queued for whenever one shows up.
    if (this.tasks !== undefined) {
      await this.tasks.revertHeldBy(providerSessionId);
    }
    // Reject any in-flight wake tasks for this session — those don't
    // benefit from re-queue since the publisher itself went away.
    for (const t of this.wakeTasks.values()) {
      if (t.sessionId === providerSessionId) {
        this.wakeTasks.delete(t.taskId);
        t.reject(new Error('publisher disconnected'));
      }
    }
  }

  /**
   * v5: when a worker binds its SSE channel, find every pending
   * InferenceTask targeting a pool key this session owns, claim each,
   * and push the frame down the channel. If two workers in the same
   * pool race here, the repo's CAS on tryClaim ensures each task is
   * pushed to exactly one of them.
   */
  private async drainPendingForSession(
    providerSessionId: string,
    handle: VirtualSessionHandle,
  ): Promise<void> {
    if (this.tasks === undefined) return;
    const owned = await this.repo.findBySessionId(providerSessionId);
    if (owned.length === 0) return;
    const poolKeys = Array.from(new Set(owned.map((row) => effectivePoolName(row))));
    const pending = await this.tasks.findPendingForPools(poolKeys);
    for (const task of pending) {
      // Cap drain per bind to avoid pushing a giant backlog into a
      // brand-new SSE channel all at once. Hardcoded for now; a
      // per-session capacity hint is a v6 concern.
      if (!handle.alive) break;
      const claimed = await this.tasks.tryClaim(task.id, providerSessionId);
      if (claimed === null) continue; // raced; another worker got it
      try {
        handle.pushTask({
          kind: 'infer',
          taskId: claimed.id,
          llmName: claimed.llmName,
          request: claimed.requestBody as unknown as OpenAiChatRequest,
          streaming: claimed.streaming,
        });
      } catch (err) {
        // SSE write failed mid-drain — revert the claim so a
        // healthier worker can pick it up.
        await this.tasks.revertHeldBy(providerSessionId);
        // eslint-disable-next-line no-console
        console.warn(`drainPendingForSession: pushTask failed for ${claimed.id}: ${(err as Error).message}`);
        break;
      }
    }
  }

  async enqueueInferTask(
    llmName: string,
    request: OpenAiChatRequest,
    streaming: boolean,
    options: EnqueueInferOptions = {},
  ): Promise<PendingTaskRef> {
    if (this.tasks === undefined) {
      throw new Error('InferenceTaskService not wired into VirtualLlmService');
    }
    const llm = await this.repo.findByName(llmName);
    if (llm === null) throw new NotFoundError(`Llm not found: ${llmName}`);
    if (llm.kind !== 'virtual' || llm.providerSessionId === null) {
      throw Object.assign(
        new Error(`Llm '${llmName}' is not a virtual provider`),
        { statusCode: 500 },
      );
    }

    // failFast callers (chat.service pool failover, direct infer route)
    // get the v1-v4 semantic: row inactive OR no live session = 503,
    // immediately. The chat dispatcher then iterates the next pool
    // candidate. Without failFast, both cases queue durably and a
    // future bindSession drains.
    if (options.failFast === true) {
      if (llm.status === 'inactive') {
        throw Object.assign(
          new Error(`Virtual Llm '${llmName}' is inactive; publisher offline`),
          { statusCode: 503 },
        );
      }
      const handle = this.sessions.get(llm.providerSessionId);
      if (handle === undefined || !handle.alive) {
        throw Object.assign(
          new Error(`Virtual Llm '${llmName}' has no live SSE session; publisher offline`),
          { statusCode: 503 },
        );
      }
    }

    // ── Wake-on-demand (v2) ──
    // Status=hibernating means the publisher told us at register time
    // that the backend is asleep. Fire a wake task and wait for the
    // publisher to confirm readiness before persisting the inference
    // task. Concurrent infers for the same Llm share a single wake
    // promise. We hold the wake INSIDE this method (not after enqueue)
    // so we don't queue an inference task that we then can't dispatch.
    if (llm.status === 'hibernating') {
      const handle = this.sessions.get(llm.providerSessionId);
      if (handle === undefined || !handle.alive) {
        throw Object.assign(
          new Error(`Virtual Llm '${llmName}' has no live SSE session; cannot wake`),
          { statusCode: 503 },
        );
      }
      await this.ensureAwake(llm.id, llm.name, llm.providerSessionId, handle);
    }

    // ── v5: persist the task BEFORE attempting dispatch ──
    // Even if no worker is up, the row stays pending and a future
    // bindSession will drain it. Caller's HTTP timeout still bounds
    // the wait, but the *task* survives.
    const created = await this.tasks.enqueue({
      poolName: effectivePoolName(llm),
      llmName,
      model: llm.model,
      tier: llm.tier,
      requestBody: request as unknown as Record<string, unknown>,
      streaming,
      ownerId: options.ownerId ?? this.resolveOwner(),
    });

    // Try to claim + dispatch immediately if a session is up. If not,
    // the row stays pending for drainPendingForSession to pick up.
    const handle = this.sessions.get(llm.providerSessionId);
    if (handle !== undefined && handle.alive) {
      const claimed = await this.tasks.tryClaim(created.id, llm.providerSessionId);
      if (claimed !== null) {
        handle.pushTask({
          kind: 'infer',
          taskId: claimed.id,
          llmName,
          request,
          streaming,
        });
      }
      // tryClaim can return null only if another concurrent enqueue
      // (or the GC sweep) already moved the row off pending — leave
      // it; drainPendingForSession will reconcile.
    }

    // Wrap the task service's waitFor into the legacy PendingTaskRef
    // shape so existing callers (chat.service, llm-infer route)
    // don't need to change. The chunk callback bridges
    // InferenceTaskService.events into the on-the-fly subscriber set.
    const tasks = this.tasks;
    const taskId = created.id;
    const subscribers = new Set<(chunk: InferenceTaskChunk) => void>();
    // Single bridge listener on the service's emitter; fans out to all
    // subscribers locally so we don't pile up listeners on a hot key.
    let bridgeUnsub: (() => void) | null = null;
    const ensureBridge = (): void => {
      if (bridgeUnsub !== null) return;
      const onChunk = (chunk: InferenceTaskChunk): void => {
        for (const cb of subscribers) cb(chunk);
      };
      // Subscribe via the public pushChunk path: the service emits on
      // its own EventEmitter; we attach a listener here. Use the
      // service's events through a side channel — exposed below as
      // subscribeChunks for clarity.
      bridgeUnsub = tasks.subscribeChunksUnsafe(taskId, onChunk);
    };

    const done = (async (): Promise<{ status: number; body: unknown }> => {
      // waitFor's `done` rejects on cancel/error/timeout. For non-
      // streaming tasks the responseBody IS the body; for streaming
      // the body is null and chunks have already been piped through.
      const waiter = tasks.waitFor(taskId, INFER_AWAIT_TIMEOUT_MS);
      // If streaming, we must drain the chunks generator concurrently
      // so chunks aren't dropped just because no one's subscribed yet.
      // The real consumer subscribes via onChunk(); their cb fires
      // synchronously inside the bridge.
      if (streaming) ensureBridge();
      const finalRow = await waiter.done;
      // Status code: legacy callers expect 200 on success; the worker
      // sends its own status via the result POST and we forward it.
      // Today, success POSTs come with status=200 and the row's
      // responseBody is { status, body }. v5 stores the *body* directly
      // on the row; we synthesize a 200 here to match the legacy shape.
      return { status: 200, body: finalRow.responseBody };
    })();

    return {
      taskId,
      done,
      onChunk(cb): () => void {
        subscribers.add(cb);
        ensureBridge();
        return (): void => {
          subscribers.delete(cb);
          if (subscribers.size === 0 && bridgeUnsub !== null) {
            bridgeUnsub();
            bridgeUnsub = null;
          }
        };
      },
    };
  }

  /**
   * Drive the publisher to wake the backend. Concurrent callers for the
   * same Llm name share the in-flight promise — we only ever ask the
   * publisher once. Throws on timeout or recipe failure; on success the
   * row is flipped to active and subsequent infer calls proceed.
   */
  private async ensureAwake(
    llmId: string,
    llmName: string,
    sessionId: string,
    handle: VirtualSessionHandle,
  ): Promise<void> {
    const existing = this.wakeInFlight.get(llmName);
    if (existing !== undefined) {
      await existing;
      return;
    }
    const promise = this.runWake(llmId, llmName, sessionId, handle);
    this.wakeInFlight.set(llmName, promise);
    try {
      await promise;
    } finally {
      this.wakeInFlight.delete(llmName);
    }
  }

  private async runWake(
    llmId: string,
    llmName: string,
    sessionId: string,
    handle: VirtualSessionHandle,
  ): Promise<void> {
    const taskId = randomUUID();
    let resolveDone!: () => void;
    let rejectDone!: (err: Error) => void;
    const done = new Promise<void>((resolve, reject) => {
      resolveDone = resolve;
      rejectDone = reject;
    });

    const wake: InMemoryWakeTask = {
      taskId,
      sessionId,
      llmName,
      resolve: (status) => {
        if (status >= 200 && status < 300) resolveDone();
        else rejectDone(new Error(`wake task returned status ${String(status)}`));
      },
      reject: rejectDone,
    };
    this.wakeTasks.set(taskId, wake);

    handle.pushTask({ kind: 'wake', taskId, llmName });

    await done;

    // Flip the row to active so subsequent infer calls go through the
    // normal active path. The publisher's own heartbeat will keep the
    // row alive from this point.
    await this.repo.update(llmId, {
      status: 'active',
      lastHeartbeatAt: new Date(),
      inactiveSince: null,
    });
  }

  completeTask(taskId: string, result: { status: number; body: unknown }): boolean {
    // Wake tasks: in-memory map. Resolve and bail.
    const wake = this.wakeTasks.get(taskId);
    if (wake !== undefined) {
      this.wakeTasks.delete(taskId);
      wake.resolve(result.status);
      return true;
    }
    // Inference tasks: durable queue. Persist body + flip terminal +
    // emit wakeup. Fire-and-forget the DB write; the result POST
    // returns 200 either way (the caller's HTTP handler is what cares
    // about the eventual emit, not the worker).
    if (this.tasks !== undefined) {
      void this.tasks.complete(taskId, result.body as Record<string, unknown> | null);
      return true;
    }
    return false;
  }

  pushTaskChunk(taskId: string, chunk: { data: string; done?: boolean }): boolean {
    // Wake tasks never receive chunks — they're non-streaming control
    // messages. Don't even check the wake map; if the id isn't an
    // inference task, just drop the chunk.
    if (this.tasks === undefined) return false;
    // First chunk for a claimed task → flip claimed → running. Idempotent
    // and fire-and-forget; if the row is already running, the CAS in the
    // repo just no-ops.
    void this.tasks.markRunning(taskId);
    this.tasks.pushChunk(taskId, chunk);
    if (chunk.done === true) {
      // Streaming completion: persist with null body + flip terminal so
      // the waiter unblocks. The actual content was already streamed
      // through the chunks channel; nothing to store.
      void this.tasks.complete(taskId, null);
    }
    return true;
  }

  failTask(taskId: string, error: Error): boolean {
    const wake = this.wakeTasks.get(taskId);
    if (wake !== undefined) {
      this.wakeTasks.delete(taskId);
      wake.reject(error);
      return true;
    }
    if (this.tasks !== undefined) {
      void this.tasks.fail(taskId, error.message);
      return true;
    }
    return false;
  }

  async gcSweep(now: Date = new Date()): Promise<{ markedInactive: number; deleted: number }> {
    let markedInactive = 0;
    let deleted = 0;

    // v3: sweep virtual agents FIRST so any Llm-pinned agent that's
    // about to be cascaded (because its Llm is also expiring) is gone
    // before we attempt to delete the Llm. Agent.llmId is Restrict and
    // would otherwise block.
    if (this.agents !== undefined) {
      const agentSweep = await this.agents.gcSweepVirtualAgents(now);
      markedInactive += agentSweep.markedInactive;
      deleted += agentSweep.deleted;
    }

    const heartbeatCutoff = new Date(now.getTime() - HEARTBEAT_TIMEOUT_MS);
    const stale = await this.repo.findStaleVirtuals(heartbeatCutoff);
    for (const row of stale) {
      await this.repo.update(row.id, { status: 'inactive', inactiveSince: now });
      markedInactive += 1;
    }

    const deletionCutoff = new Date(now.getTime() - INACTIVE_RETENTION_MS);
    const expired = await this.repo.findExpiredInactives(deletionCutoff);
    for (const row of expired) {
      // Final defensive cascade: drop any virtual agents still pinned
      // to this Llm (e.g. their lastHeartbeatAt happens to lag the
      // Llm's by a few seconds and they didn't make this round's
      // 4-h cutoff). Without this we'd hit a Restrict FK error.
      if (this.agents !== undefined) {
        await this.agents.deleteVirtualAgentsForLlm(row.id);
      }
      await this.repo.delete(row.id);
      deleted += 1;
    }

    return { markedInactive, deleted };
  }
}
