/**
 * Routes for the virtual-LLM control plane (`kind=virtual` Llm rows).
 *
 *   POST  /api/v1/llms/_provider-register       — register/refresh, returns sessionId
 *   GET   /api/v1/llms/_provider-stream         — SSE channel; mcpd → mcplocal task fan-out
 *   POST  /api/v1/llms/_provider-heartbeat      — keep-alive (every 30 s from mcplocal)
 *   POST  /api/v1/llms/_provider-task/:id/result — mcplocal pushes result/chunks back
 *
 * RBAC: these all live under `/api/v1/llms/...` so the existing
 * `mapUrlToPermission` in main.ts maps them to the `llms` resource —
 * POST = create:llms, GET = view:llms. That's appropriate: publishing
 * a virtual LLM is morally the same as creating one.
 *
 * Inference for virtual rows still lands on `/api/v1/llms/:name/infer`
 * (unchanged URL); that route gains a `kind=virtual` branch in this stage
 * and delegates here via VirtualLlmService.
 */
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { VirtualLlmService, VirtualSessionHandle, VirtualTaskFrame } from '../services/virtual-llm.service.js';
import type { AgentService, VirtualAgentInput } from '../services/agent.service.js';

const SSE_PING_MS = 20_000;
const PROVIDER_SESSION_HEADER = 'x-mcpctl-provider-session';

export function registerVirtualLlmRoutes(
  app: FastifyInstance,
  service: VirtualLlmService,
  /**
   * Optional. v3 wires AgentService here so the register endpoint can
   * also accept an `agents` array alongside `providers` and atomic-publish
   * both. Absent (older test wirings): the route still works for Llm-only
   * publishers, agents in the payload are ignored with a warning.
   */
  agentService?: AgentService,
): void {
  app.post<{ Body: { providerSessionId?: string; providers?: unknown[]; agents?: unknown[] } }>(
    '/api/v1/llms/_provider-register',
    async (request, reply) => {
      const body = (request.body ?? {});
      const providers = Array.isArray(body.providers) ? body.providers : null;
      if (providers === null || providers.length === 0) {
        reply.code(400);
        return { error: '`providers` array is required and must be non-empty' };
      }
      const agentsInput = Array.isArray(body.agents) ? body.agents : null;

      try {
        // v7: ownerId from the authenticated request lands on every
        // newly-created virtual Llm row (sticky reconnects update the
        // existing row's ownerId too — same publisher, same session).
        const ownerId = request.userId ?? 'system';
        const result = await service.register({
          providerSessionId: body.providerSessionId ?? null,
          providers: providers.map(coerceProviderInput),
          ownerId,
        });
        // v3: atomically publish virtual agents tied to the same session.
        // If the caller didn't include an agents array, skip silently.
        let agents: unknown[] = [];
        if (agentsInput !== null && agentsInput.length > 0) {
          if (agentService === undefined) {
            app.log.warn('virtual-llm register received `agents` but AgentService is not wired');
          } else {
            agents = await agentService.registerVirtualAgents(
              result.providerSessionId,
              agentsInput.map(coerceAgentInput),
              request.userId ?? 'system',
            );
          }
        }
        reply.code(201);
        return { ...result, agents };
      } catch (err) {
        const status = (err as { statusCode?: number }).statusCode ?? 500;
        reply.code(status);
        return { error: (err as Error).message };
      }
    },
  );

  app.get('/api/v1/llms/_provider-stream', (request, reply): FastifyReply => {
    const sessionHeader = request.headers[PROVIDER_SESSION_HEADER];
    const sessionId = typeof sessionHeader === 'string' ? sessionHeader : null;
    if (sessionId === null || sessionId === '') {
      reply.code(400);
      void reply.send({ error: `${PROVIDER_SESSION_HEADER} header is required (call /_provider-register first)` });
      return reply;
    }

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

    const handle: VirtualSessionHandle = {
      pushTask(task: VirtualTaskFrame): void {
        if (reply.raw.destroyed || reply.raw.writableEnded) return;
        reply.raw.write(`event: task\ndata: ${JSON.stringify(task)}\n\n`);
      },
      get alive(): boolean {
        return !reply.raw.destroyed && !reply.raw.writableEnded;
      },
    };

    service.bindSession(sessionId, handle);
    reply.raw.write(`event: hello\ndata: ${JSON.stringify({ sessionId })}\n\n`);

    // Keep-alive comment lines so proxies (Cilium, k8s ingress) don't time
    // out an idle SSE connection.
    const pingTimer = setInterval(() => {
      if (reply.raw.destroyed || reply.raw.writableEnded) return;
      reply.raw.write(`: ping\n\n`);
    }, SSE_PING_MS);

    request.raw.on('close', () => {
      clearInterval(pingTimer);
      service.unbindSession(sessionId).catch((err: unknown) => {
        app.log.warn({ err, sessionId }, 'unbindSession failed');
      });
    });

    return reply;
  });

  app.post<{ Body: { providerSessionId?: string } }>(
    '/api/v1/llms/_provider-heartbeat',
    async (request, reply) => {
      const sessionId = request.body?.providerSessionId;
      if (typeof sessionId !== 'string' || sessionId === '') {
        reply.code(400);
        return { error: 'providerSessionId required' };
      }
      await service.heartbeat(sessionId);
      return { ok: true };
    },
  );

  app.post<{
    Params: { taskId: string };
    Body: {
      status?: number;
      body?: unknown;
      chunk?: { data: string; done?: boolean };
      error?: string;
    };
  }>(
    '/api/v1/llms/_provider-task/:taskId/result',
    async (request, reply) => {
      const { taskId } = request.params;
      const body = request.body ?? {};

      if (typeof body.error === 'string' && body.error !== '') {
        const ok = service.failTask(taskId, new Error(body.error));
        return { ok };
      }
      if (body.chunk !== undefined && typeof body.chunk.data === 'string') {
        const ok = service.pushTaskChunk(taskId, body.chunk);
        return { ok };
      }
      if (typeof body.status === 'number') {
        const ok = service.completeTask(taskId, { status: body.status, body: body.body });
        return { ok };
      }

      reply.code(400);
      return { error: 'body must contain one of: { error }, { chunk: { data, done? } }, { status, body }' };
    },
  );
}

/** Narrow an unknown agents array element into the service's input shape (v3). */
function coerceAgentInput(raw: unknown): VirtualAgentInput {
  if (raw === null || typeof raw !== 'object') {
    throw Object.assign(new Error('agent entry must be an object'), { statusCode: 400 });
  }
  const o = raw as Record<string, unknown>;
  const name = o['name'];
  const llmName = o['llmName'];
  if (typeof name !== 'string' || typeof llmName !== 'string') {
    throw Object.assign(
      new Error('agent entry requires string `name` and `llmName`'),
      { statusCode: 400 },
    );
  }
  const out: VirtualAgentInput = { name, llmName };
  if (typeof o['description'] === 'string') out.description = o['description'];
  if (typeof o['systemPrompt'] === 'string') out.systemPrompt = o['systemPrompt'];
  if (typeof o['project'] === 'string') out.project = o['project'];
  if (o['defaultParams'] !== null && typeof o['defaultParams'] === 'object') {
    out.defaultParams = o['defaultParams'] as Record<string, unknown>;
  }
  if (o['extras'] !== null && typeof o['extras'] === 'object') {
    out.extras = o['extras'] as Record<string, unknown>;
  }
  // v7: optional visibility scope (defaults to 'private' on first publish).
  if (o['visibility'] === 'public' || o['visibility'] === 'private') {
    out.visibility = o['visibility'];
  }
  return out;
}

/** Narrow an unknown providers array element into the service's input shape. */
function coerceProviderInput(raw: unknown): {
  name: string;
  type: string;
  model: string;
  tier?: string;
  description?: string;
  extraConfig?: Record<string, unknown>;
  initialStatus?: 'active' | 'hibernating';
  poolName?: string;
  visibility?: 'public' | 'private';
} {
  if (raw === null || typeof raw !== 'object') {
    throw Object.assign(new Error('provider entry must be an object'), { statusCode: 400 });
  }
  const o = raw as Record<string, unknown>;
  const name = o['name'];
  const type = o['type'];
  const model = o['model'];
  if (typeof name !== 'string' || typeof type !== 'string' || typeof model !== 'string') {
    throw Object.assign(
      new Error('provider entry requires string `name`, `type`, `model`'),
      { statusCode: 400 },
    );
  }
  const out: ReturnType<typeof coerceProviderInput> = { name, type, model };
  if (typeof o['tier'] === 'string') out.tier = o['tier'];
  if (typeof o['description'] === 'string') out.description = o['description'];
  if (o['extraConfig'] !== null && typeof o['extraConfig'] === 'object') {
    out.extraConfig = o['extraConfig'] as Record<string, unknown>;
  }
  // Only accept the two values v2 actually defines. Anything else falls
  // through to the service default (active) — matches v1 publishers that
  // don't know about this field.
  if (o['initialStatus'] === 'active' || o['initialStatus'] === 'hibernating') {
    out.initialStatus = o['initialStatus'];
  }
  // v4: optional pool key. Validation matches CreateLlmSchema.poolName so
  // a publisher can't slip an uppercase or whitespace name past the
  // virtual-publish path that the public CRUD path would reject.
  if (typeof o['poolName'] === 'string' && /^[a-z0-9-]+$/.test(o['poolName']) && o['poolName'].length >= 1 && o['poolName'].length <= 100) {
    out.poolName = o['poolName'];
  }
  // v7: visibility. Only 'public' or 'private'; unknown values fall
  // through to the service default ('private' for virtuals).
  if (o['visibility'] === 'public' || o['visibility'] === 'private') {
    out.visibility = o['visibility'];
  }
  return out;
}
