/**
 * Project-scoped Streamable HTTP MCP protocol endpoint.
 *
 * Exposes per-project MCP endpoints at /projects/:projectName/mcp so
 * Claude Code can connect to a specific project's servers only.
 *
 * Each project gets its own McpRouter instance (cached with TTL).
 * Sessions are managed per-project.
 */
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import { McpRouter } from '../router.js';
import { ResponsePaginator } from '../llm/pagination.js';
import { refreshProjectUpstreams, fetchProjectLlmConfig } from '../discovery.js';
import { loadProjectLlmOverride } from './config.js';
import type { McpdClient } from './mcpd-client.js';
import type { ProviderRegistry } from '../providers/registry.js';
import type { JsonRpcRequest } from '../types.js';
import type { TrafficCapture } from './traffic.js';
import { LLMProviderAdapter } from '../proxymodel/llm-adapter.js';
import { FileCache } from '../proxymodel/file-cache.js';
import { createDefaultPlugin } from '../proxymodel/plugins/default.js';
import { AuditCollector } from '../audit/collector.js';

interface ProjectCacheEntry {
  router: McpRouter;
  lastRefresh: number;
}

interface SessionEntry {
  transport: StreamableHTTPServerTransport;
  projectName: string;
}

const CACHE_TTL_MS = 60_000; // 60 seconds

export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient, providerRegistry?: ProviderRegistry | null, trafficCapture?: TrafficCapture | null): void {
  /** Resolved identity of the mcplocal owner (from credentials). */
  let resolvedUserName: string | null | undefined; // undefined = not yet resolved
  const projectCache = new Map<string, ProjectCacheEntry>();
  const sessions = new Map<string, SessionEntry>();

  /** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */
  async function ensureUserName(): Promise<string | null> {
    if (resolvedUserName !== undefined) return resolvedUserName;
    try {
      const me = await mcpdClient.get<{ name?: string; email?: string }>('/api/v1/auth/me');
      resolvedUserName = me.name ?? me.email ?? null;
    } catch {
      resolvedUserName = null;
    }
    return resolvedUserName;
  }

  async function getOrCreateRouter(projectName: string, authToken?: string): Promise<McpRouter> {
    const existing = projectCache.get(projectName);
    const now = Date.now();

    if (existing && (now - existing.lastRefresh) < CACHE_TTL_MS) {
      return existing.router;
    }

    // Create new router or refresh existing one
    const router = existing?.router ?? new McpRouter();
    await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);

    // Resolve project LLM model: local override → mcpd recommendation → global default
    const localOverride = loadProjectLlmOverride(projectName);
    const mcpdConfig = await fetchProjectLlmConfig(mcpdClient, projectName);
    const resolvedModel = localOverride?.model ?? mcpdConfig.llmModel ?? undefined;

    // If project llmProvider is "none", disable LLM for this project
    const llmDisabled = mcpdConfig.llmProvider === 'none' || localOverride?.provider === 'none';
    const effectiveRegistry = llmDisabled ? null : (providerRegistry ?? null);

    // Configure prompt resources with SA-scoped client for RBAC
    const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
    router.setPromptConfig(saClient, projectName);

    // System prompt fetcher for LLM consumers (uses router's cached fetcher)
    const getSystemPrompt = router.getSystemPromptFn();

    // Wire pagination support with LLM provider and project model override
    router.setPaginator(new ResponsePaginator(effectiveRegistry, {}, resolvedModel, getSystemPrompt));

    // Wire proxymodel pipeline (model resolved lazily from disk for hot-reload)
    const proxyModelName = mcpdConfig.proxyModel ?? 'default';
    const llmAdapter = effectiveRegistry ? new LLMProviderAdapter(effectiveRegistry) : {
      complete: async () => '',
      available: () => false,
    };
    // Build cache namespace: provider--model--proxymodel
    const llmProvider = localOverride?.provider ?? mcpdConfig.llmProvider
      ?? effectiveRegistry?.getTierProviders('fast')[0]
      ?? effectiveRegistry?.getActiveName()
      ?? 'none';
    const llmModel = resolvedModel ?? 'default';
    const cache = new FileCache(`${llmProvider}--${llmModel}--${proxyModelName}`);
    router.setProxyModel(proxyModelName, llmAdapter, cache);

    // Per-server proxymodel overrides (if mcpd provides them)
    if (mcpdConfig.serverOverrides) {
      for (const [serverName, override] of Object.entries(mcpdConfig.serverOverrides)) {
        if (override.proxyModel) {
          router.setServerProxyModel(serverName, override.proxyModel, llmAdapter, cache);
        }
      }
    }

    // Wire audit collector (best-effort, non-blocking)
    // userName is resolved lazily on first session creation — placeholder here
    const auditCollector = new AuditCollector(saClient, projectName);
    router.setAuditCollector(auditCollector);

    // Wire the default plugin (gate + content-pipeline)
    // Derive gating from proxyModel: 'default' and 'gate' include gate behavior
    const isGated = mcpdConfig.gated !== false &&
      (proxyModelName === 'default' || proxyModelName === 'gate');
    const pluginConfig: Parameters<typeof createDefaultPlugin>[0] = {
      gated: isGated,
      providerRegistry: effectiveRegistry,
    };
    if (resolvedModel) pluginConfig.modelOverride = resolvedModel;
    const plugin = createDefaultPlugin(pluginConfig);
    router.setPlugin(plugin);

    // Fetch project instructions and set on router
    try {
      const instructions = await mcpdClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>(
        `/api/v1/projects/${encodeURIComponent(projectName)}/instructions`,
      );
      const parts: string[] = [];
      if (instructions.prompt) {
        parts.push(instructions.prompt);
      }
      if (instructions.servers.length > 0) {
        parts.push('Available MCP servers:');
        for (const s of instructions.servers) {
          parts.push(`- ${s.name}${s.description ? `: ${s.description}` : ''}`);
        }
      }
      if (parts.length > 0) {
        router.setInstructions(parts.join('\n'));
      }
    } catch {
      // Instructions are optional — don't fail if endpoint is unavailable
    }

    // Eagerly start managed LLM providers (e.g., vLLM) so they're warm by first use
    effectiveRegistry?.warmupAll();

    projectCache.set(projectName, { router, lastRefresh: now });
    return router;
  }

  // POST /projects/:projectName/mcp — JSON-RPC requests
  app.post<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => {
    const { projectName } = request.params;
    const sessionId = request.headers['mcp-session-id'] as string | undefined;
    const authToken = (request.headers['authorization'] as string | undefined)?.replace(/^Bearer\s+/i, '');

    if (sessionId && sessions.has(sessionId)) {
      const session = sessions.get(sessionId)!;
      await session.transport.handleRequest(request.raw, reply.raw, request.body);
      reply.hijack();
      return;
    }

    if (sessionId && !sessions.has(sessionId)) {
      reply.code(404).send({ error: 'Session not found' });
      return;
    }

    // New session — get/create project router
    let router: McpRouter;
    try {
      router = await getOrCreateRouter(projectName, authToken);
    } catch (err) {
      reply.code(502).send({ error: `Failed to load project: ${err instanceof Error ? err.message : String(err)}` });
      return;
    }

    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => {
        sessions.set(id, { transport, projectName });
        trafficCapture?.emit({
          timestamp: new Date().toISOString(),
          projectName,
          sessionId: id,
          eventType: 'session_created',
          body: null,
        });

        // Resolve userName from mcplocal owner's credentials (best-effort, non-blocking)
        const collector = router.getAuditCollector();
        if (collector) {
          void ensureUserName().then((name) => {
            if (name) collector.setSessionUserName(id, name);
          });
        }

        // Audit: session_bind
        collector?.emit({
          timestamp: new Date().toISOString(),
          sessionId: id,
          eventKind: 'session_bind',
          source: 'mcplocal',
          verified: true,
          payload: { projectName },
        });
      },
    });

    // Per-request correlationId map for linking client ↔ upstream event pairs.
    // A Map keyed by request ID avoids the race condition where concurrent
    // requests would overwrite a single shared variable.
    const requestCorrelations = new Map<string | number, string>();

    // Wire upstream call tracing into the router
    if (trafficCapture) {
      router.onUpstreamCall = (info) => {
        const sid = transport.sessionId ?? 'unknown';
        // Prefer correlationId passed by router (fan-out discovery), fall back to request ID lookup
        const reqId = (info.request as { id?: string | number }).id;
        const corrId = info.correlationId ?? (reqId != null ? requestCorrelations.get(reqId) : undefined);
        trafficCapture.emit({
          timestamp: new Date().toISOString(),
          projectName,
          sessionId: sid,
          eventType: 'upstream_request',
          method: info.method,
          upstreamName: info.upstream,
          body: info.request,
          correlationId: corrId,
        });
        trafficCapture.emit({
          timestamp: new Date().toISOString(),
          projectName,
          sessionId: sid,
          eventType: 'upstream_response',
          method: info.method,
          upstreamName: info.upstream,
          body: info.response,
          durationMs: info.durationMs,
          correlationId: corrId,
        });
      };
    }

    transport.onmessage = async (message: JSONRPCMessage) => {
      if ('method' in message && 'id' in message) {
        const requestId = message.id as string | number;
        const sid = transport.sessionId ?? 'unknown';
        const method = (message as { method?: string }).method;
        const correlationId = `${sid}:${requestId}`;
        requestCorrelations.set(requestId, correlationId);

        // Capture client request
        trafficCapture?.emit({
          timestamp: new Date().toISOString(),
          projectName,
          sessionId: sid,
          eventType: 'client_request',
          method,
          body: message,
          correlationId,
        });

        const ctx = transport.sessionId ? { sessionId: transport.sessionId, correlationId } : { correlationId };
        const response = await router.route(message as unknown as JsonRpcRequest, ctx);

        // Forward queued notifications BEFORE the response — the response send
        // closes the POST SSE stream, so notifications must go first.
        // relatedRequestId routes them onto the same SSE stream as the response.
        if (transport.sessionId) {
          for (const n of router.consumeNotifications(transport.sessionId)) {
            trafficCapture?.emit({
              timestamp: new Date().toISOString(),
              projectName,
              sessionId: sid,
              eventType: 'client_notification',
              method: (n as { method?: string }).method,
              body: n,
              correlationId,
            });
            await transport.send(n as unknown as JSONRPCMessage, { relatedRequestId: requestId });
          }
        }

        // Capture client response
        trafficCapture?.emit({
          timestamp: new Date().toISOString(),
          projectName,
          sessionId: sid,
          eventType: 'client_response',
          method,
          body: response,
          correlationId,
        });

        requestCorrelations.delete(requestId);
        await transport.send(response as unknown as JSONRPCMessage);
      }
    };

    transport.onclose = () => {
      const id = transport.sessionId;
      if (id) {
        trafficCapture?.emit({
          timestamp: new Date().toISOString(),
          projectName,
          sessionId: id,
          eventType: 'session_closed',
          body: null,
        });
        sessions.delete(id);
        router.cleanupSession(id);
      }
    };

    await transport.handleRequest(request.raw, reply.raw, request.body);
    reply.hijack();
  });

  // GET /projects/:projectName/mcp — SSE stream
  app.get<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => {
    const sessionId = request.headers['mcp-session-id'] as string | undefined;

    if (!sessionId || !sessions.has(sessionId)) {
      reply.code(400).send({ error: 'Invalid or missing session ID' });
      return;
    }

    const session = sessions.get(sessionId)!;
    await session.transport.handleRequest(request.raw, reply.raw);
    reply.hijack();
  });

  // DELETE /projects/:projectName/mcp — Session cleanup
  app.delete<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => {
    const sessionId = request.headers['mcp-session-id'] as string | undefined;

    if (!sessionId || !sessions.has(sessionId)) {
      reply.code(400).send({ error: 'Invalid or missing session ID' });
      return;
    }

    const session = sessions.get(sessionId)!;
    await session.transport.handleRequest(request.raw, reply.raw);
    sessions.delete(sessionId);
    reply.hijack();
  });

  // GET /projects/:projectName/override — current proxyModel config + server list
  app.get<{ Params: { projectName: string } }>('/projects/:projectName/override', async (request, reply) => {
    const { projectName } = request.params;
    const entry = projectCache.get(projectName);
    if (!entry) {
      reply.code(404).send({ error: `Project '${projectName}' not loaded` });
      return;
    }

    const info = entry.router.getProxyModelInfo();
    const servers = entry.router.getUpstreamNames();
    reply.send({
      proxyModel: info.projectDefault,
      serverOverrides: info.serverOverrides,
      servers,
    });
  });

  // PUT /projects/:projectName/override — ephemeral runtime override
  app.put<{
    Params: { projectName: string };
    Body: { proxyModel?: string; serverName?: string; serverProxyModel?: string };
  }>('/projects/:projectName/override', async (request, reply) => {
    const { projectName } = request.params;
    const { proxyModel, serverName, serverProxyModel } = request.body ?? {};
    const entry = projectCache.get(projectName);
    if (!entry) {
      reply.code(404).send({ error: `Project '${projectName}' not loaded` });
      return;
    }

    const llmAdapter = providerRegistry
      ? new LLMProviderAdapter(providerRegistry)
      : { complete: async () => '', available: () => false };
    const cache = new FileCache('dynamic');

    if (serverName && serverProxyModel) {
      entry.router.setServerProxyModel(serverName, serverProxyModel, llmAdapter, cache);
    } else if (proxyModel) {
      entry.router.setProxyModel(proxyModel, llmAdapter, cache);
    } else {
      reply.code(400).send({ error: 'Provide proxyModel or (serverName + serverProxyModel)' });
      return;
    }

    const info = entry.router.getProxyModelInfo();
    reply.send({
      proxyModel: info.projectDefault,
      serverOverrides: info.serverOverrides,
    });
  });
}
