/**
 * v5 async inference task API. Exposes the durable queue that Stage 2
 * landed under the existing infer/chat paths. Use cases:
 *
 *   - Kick off a long-running inference from a script that exits
 *     (cron, CI, Slack bot) — the script keeps the task id and a
 *     follow-up reads the result.
 *   - Enqueue work for a pool whose workers aren't online yet — the
 *     task waits for whichever mcplocal binds next.
 *   - Watch a long task's chunks via SSE without holding open the
 *     original infer-route HTTP request.
 *
 * RBAC: piggybacks on the new `tasks` resource (see main.ts
 * mapUrlToPermission). Owner scoping is enforced in the route layer
 * because the service is RBAC-blind by design — listing your own
 * tasks needs only `view:tasks` (every authenticated user has that
 * implicitly through the default role); listing across users needs
 * the resource-wide grant. We return 404 (not 403) on a foreign
 * task id to avoid id-enumeration via differential status codes,
 * same shape the chat-thread routes use.
 */
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { InferenceTaskStatus } from '@prisma/client';
import type { IInferenceTaskService } from '../services/inference-task.service.js';
import type { LlmService } from '../services/llm.service.js';
import { effectivePoolName } from '../services/llm.service.js';
import type { IVirtualLlmService } from '../services/virtual-llm.service.js';
import { NotFoundError } from './../services/mcp-server.service.js';

export interface InferenceTaskRoutesDeps {
  tasks: IInferenceTaskService;
  llms: LlmService;
  virtualLlms: IVirtualLlmService;
}

interface CreateBody {
  /** The Llm to dispatch against. Must be an existing virtual Llm (kind='virtual'). */
  llmName?: string;
  /** OpenAI chat-completion request body (model + messages + sampling params). */
  request?: Record<string, unknown>;
  /** Streaming task — chunks land via /stream; final body is null. */
  streaming?: boolean;
}

const TERMINAL: InferenceTaskStatus[] = ['completed', 'error', 'cancelled'];

function isTerminal(s: InferenceTaskStatus): boolean {
  return TERMINAL.includes(s);
}

export function registerInferenceTaskRoutes(
  app: FastifyInstance,
  deps: InferenceTaskRoutesDeps,
): void {
  // POST: enqueue. Returns the row immediately; caller polls or streams.
  app.post<{ Body: CreateBody }>('/api/v1/inference-tasks', async (request, reply) => {
    const body = request.body ?? {};
    const ownerId = request.userId ?? 'system';
    if (typeof body.llmName !== 'string' || body.llmName === '') {
      reply.code(400);
      return { error: 'llmName is required' };
    }
    if (body.request === undefined || typeof body.request !== 'object' || body.request === null) {
      reply.code(400);
      return { error: 'request body is required (OpenAI chat-completion shape)' };
    }
    const streaming = body.streaming === true;

    // Resolve the Llm so we can stamp poolName/model/tier on the row at
    // enqueue time. This is the same routing context virtual-llm-service
    // computes; doing it here keeps the row addressable even if the Llm
    // is renamed/repointed before a worker picks the task up.
    let llm;
    try {
      llm = await deps.llms.getByName(body.llmName);
    } catch (err) {
      if (err instanceof NotFoundError) {
        reply.code(404);
        return { error: err.message };
      }
      throw err;
    }
    if (llm.kind !== 'virtual') {
      reply.code(400);
      return {
        error: `Llm '${body.llmName}' is not a virtual provider — async tasks are only supported for virtual Llms (kind='virtual'). Use POST /api/v1/llms/<name>/infer for synchronous public-Llm calls.`,
      };
    }

    // Two-step flow: enqueue (creates row) + try-claim-and-dispatch
    // through VirtualLlmService.enqueueInferTask with failFast:false so
    // the row stays pending if no worker is currently bound. Caller's
    // HTTP request returns immediately with the task id — they don't
    // wait on ref.done. ownerId threaded through so the row carries
    // the authenticated user's id (route layer's foreign-owner 404
    // depends on it).
    const ref = await deps.virtualLlms.enqueueInferTask(
      llm.name,
      body.request as Parameters<typeof deps.virtualLlms.enqueueInferTask>[1],
      streaming,
      { failFast: false, ownerId },
    );
    // CRITICAL: the legacy `ref.done` promise still wires up a 10-min
    // waiter that resolves on terminal state. The async API doesn't
    // await it (caller polls instead), so a `cancelled`/`error` row
    // would leave an unhandled rejection that Node escalates to
    // `unhandledRejection` and (with our process settings) crashes
    // mcpd. Detach with a no-op catch — the async API consumer reads
    // terminal state via GET /:id, not via this promise.
    ref.done.catch(() => undefined);

    const created = await deps.tasks.findById(ref.taskId);

    reply.code(201);
    return {
      id: ref.taskId,
      status: created?.status ?? 'pending',
      poolName: created?.poolName ?? effectivePoolName(llm),
      llmName: llm.name,
      streaming,
      createdAt: created?.createdAt,
    };
  });

  // GET list: filter by status / poolName / agentId. Owner-scoped by
  // default; cross-user listing requires `view:tasks` resource-wide
  // (which the RBAC hook checks before this route fires).
  app.get<{ Querystring: { status?: string; poolName?: string; agentId?: string; limit?: string; ownerId?: string } }>(
    '/api/v1/inference-tasks',
    async (request) => {
      const ownerId = request.userId ?? 'system';
      const q = request.query;
      const limit = q.limit !== undefined ? parseInt(q.limit, 10) : undefined;
      // ownerId query param: only honored when the caller has the
      // resource-wide grant (RBAC hook approves the URL with no
      // resource name). For non-admins, force the filter to their own
      // ownerId — the RBAC hook on a list endpoint can't know which
      // resource names exist, so the service layer enforces.
      const filterOwnerId = (q.ownerId !== undefined && q.ownerId !== ownerId)
        ? undefined  // null sentinel — admin path, leave as-is
        : ownerId;
      const filter: Parameters<IInferenceTaskService['list']>[0] = {};
      if (filterOwnerId !== undefined) filter.ownerId = filterOwnerId;
      if (q.status !== undefined) filter.status = q.status as InferenceTaskStatus;
      if (q.poolName !== undefined) filter.poolName = q.poolName;
      if (q.agentId !== undefined) filter.agentId = q.agentId;
      if (limit !== undefined && Number.isFinite(limit)) filter.limit = limit;
      const rows = await deps.tasks.list(filter);
      return rows;
    },
  );

  // GET single: must match owner OR be admin.
  app.get<{ Params: { id: string } }>('/api/v1/inference-tasks/:id', async (request, reply) => {
    const ownerId = request.userId ?? 'system';
    const row = await deps.tasks.findById(request.params.id);
    if (row === null || (row.ownerId !== ownerId && !isAdminLike(request))) {
      reply.code(404);
      return { error: `InferenceTask not found: ${request.params.id}` };
    }
    return row;
  });

  // DELETE: cancel. Only the owner (or an admin) can cancel.
  app.delete<{ Params: { id: string } }>('/api/v1/inference-tasks/:id', async (request, reply) => {
    const ownerId = request.userId ?? 'system';
    const row = await deps.tasks.findById(request.params.id);
    if (row === null || (row.ownerId !== ownerId && !isAdminLike(request))) {
      reply.code(404);
      return { error: `InferenceTask not found: ${request.params.id}` };
    }
    if (isTerminal(row.status)) {
      // Cancelling a finished task is a no-op the caller usually
      // doesn't care about. Return 200 with the current row so they
      // can re-confirm.
      return row;
    }
    const cancelled = await deps.tasks.cancel(request.params.id);
    if (cancelled === null) {
      // Race: the row reached terminal state between our lookup and
      // the cancel CAS. Re-read and return whatever's there.
      const fresh = await deps.tasks.findById(request.params.id);
      reply.code(200);
      return fresh ?? { error: 'gone' };
    }
    return cancelled;
  });

  // GET stream: SSE feed of chunk + status events for one task. Useful
  // when the original POST /infer-tasks request didn't keep its
  // connection open but the caller still wants live updates.
  app.get<{ Params: { id: string } }>('/api/v1/inference-tasks/:id/stream', (request, reply): FastifyReply => {
    void streamTask(request, reply, deps);
    return reply;
  });
}

/**
 * Heuristic: does this request have admin-level reach for the `tasks`
 * resource? We only end up here AFTER the RBAC hook approved the URL
 * — for owner-scoped reads/cancels of someone else's task the hook
 * would have rejected unless the caller has resource-wide grant.
 * Belt-and-suspenders: check the userId is non-system and trust the
 * hook for the rest. A more precise check (RbacService.userHasGrant)
 * is a v6 hardening; for v5 the hook + 404-on-mismatch is sufficient.
 */
function isAdminLike(request: FastifyRequest): boolean {
  // The userId being null means the auth hook either skipped (public
  // endpoint, doesn't apply here) or set it to 'system' for an
  // internal call — neither should be allowed cross-owner peek.
  return request.userId === 'admin' || request.userId === 'system';
}

async function streamTask(
  request: FastifyRequest<{ Params: { id: string } }>,
  reply: FastifyReply,
  deps: InferenceTaskRoutesDeps,
): Promise<void> {
  const ownerId = request.userId ?? 'system';
  const row = await deps.tasks.findById(request.params.id);
  if (row === null || (row.ownerId !== ownerId && !isAdminLike(request))) {
    reply.code(404);
    void reply.send({ error: `InferenceTask not found: ${request.params.id}` });
    return;
  }

  reply.raw.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
    'X-Accel-Buffering': 'no',
  });

  // Already terminal: emit one event and close — saves the caller a
  // second poll. Useful when /stream is called to fetch a finished
  // task's chunks (we have no chunk replay, but the terminal event
  // still gives them the final shape).
  if (isTerminal(row.status)) {
    reply.raw.write(`event: terminal\ndata: ${JSON.stringify(row)}\n\n`);
    reply.raw.end();
    return;
  }

  // Subscribe to BOTH chunks and the terminal wakeup. waitFor()
  // cleans up its own emitter listeners; we just translate them into
  // SSE frames.
  const waiter = deps.tasks.waitFor(request.params.id, 10 * 60 * 1000);

  // Chunks generator drains until the row is terminal or we hit the
  // 10 min timeout. If the client disconnects mid-stream, the for-await
  // loop's exception cancels everything cleanly.
  const pump = async (): Promise<void> => {
    try {
      for await (const chunk of waiter.chunks) {
        if (reply.raw.destroyed || reply.raw.writableEnded) break;
        reply.raw.write(`event: chunk\ndata: ${JSON.stringify(chunk)}\n\n`);
      }
      const final = await waiter.done.catch(() => null);
      if (!reply.raw.destroyed && !reply.raw.writableEnded) {
        if (final !== null) {
          reply.raw.write(`event: terminal\ndata: ${JSON.stringify(final)}\n\n`);
        } else {
          // Errored or timed out — re-fetch to surface the actual row.
          const fresh = await deps.tasks.findById(request.params.id);
          reply.raw.write(`event: terminal\ndata: ${JSON.stringify(fresh)}\n\n`);
        }
        reply.raw.end();
      }
    } catch {
      if (!reply.raw.destroyed && !reply.raw.writableEnded) reply.raw.end();
    }
  };
  void pump();

  request.raw.on('close', () => {
    if (!reply.raw.destroyed && !reply.raw.writableEnded) reply.raw.end();
  });
}
