import type http from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { Database } from '../db/client.js';
import { DaemonClient } from '../hooks/client.js';
import { createMycoTools } from '../tools/index.js';
import {
  ForeignGroveError,
  isCallerTenancy,
  requestContextFromHttpHeaders,
  tryResolveRequestContextForVault,
  UnauthorizedRequestContextError,
  type MycoRequestContext,
} from '../grove/request-context.js';
import { createMcpProtocolServer } from './server.js';
import type { Logger } from '../daemon/logger.js';

export type StreamableMcpHttpHandler = (
  req: http.IncomingMessage,
  res: http.ServerResponse,
) => Promise<void>;

export interface StreamableMcpHttpHandlerOptions {
  client?: DaemonClient;
  /** Reuse the daemon's runtime cache so tool calls don't open per-call DB handles. */
  resolveDatabase?: (databasePath: string) => Database;
  /**
   * Optional structured logger. When provided, the protocol server logs
   * each /mcp tool dispatch (start, success, error) — closes the
   * observability gap from issue #288.
   */
  logger?: Logger;
}

/**
 * Build the JSON-RPC `legacy_vault` wire envelope. This is the HTTP-layer
 * translation of the shared tools-runtime tenancy policy: when no
 * caller-supplied (project/Grove) tenancy is available, the request must not
 * silently default to the bootstrap-anchor vault. Surfaced as a structured
 * 503 with a `legacy_vault` discriminator so MCP clients render a friendly
 * "this project hasn't been auto-registered yet" message instead of the
 * opaque `tool_call_failed` JSON-RPC error they'd otherwise get from the
 * shared runtime rejecting the call inside dispatch.
 */
function legacyVaultBody(message: string, vaultDir: string): string {
  return JSON.stringify({
    jsonrpc: '2.0',
    error: {
      code: -32004,
      message,
      data: { code: 'legacy_vault', vault_dir: vaultDir },
    },
    id: null,
  });
}

/**
 * Resolve the request context for this MCP-HTTP call, or translate a
 * non-caller / pre-Grove state into the `legacy_vault` wire error.
 *
 * The shared tools runtime (`createMycoTools`) is the single source of the
 * tenancy policy: it rejects any context whose `tenancySource` is not
 * `'caller'`. This pre-flight applies the *same* predicate at the transport
 * boundary so the rejection becomes a clean structured 503 rather than an
 * in-protocol `tool_call_failed` raised mid-dispatch. We do not re-implement
 * the policy — we read the resolved context's `tenancySource` (the value the
 * runtime checks) and translate it to the wire.
 *
 * A pre-Grove vault (no `project.toml` Grove id, no context headers) makes
 * `requestContextFromHttpHeaders` throw; `tryResolveRequestContextForVault`
 * gives us the friendly reason to return instead.
 */
function resolveRequestContextOrLegacy(
  req: http.IncomingMessage,
  vaultDir: string,
): { ok: true; requestContext: MycoRequestContext } | { ok: false; body: string } {
  let requestContext: MycoRequestContext;
  try {
    // G4: when the daemon has minted a bearer token (via env), enforce
    // the same context-switch gate the daemon's main HTTP server uses.
    requestContext = requestContextFromHttpHeaders(req.headers, vaultDir, {
      expectedAuthToken: process.env.MYCO_DAEMON_AUTH ?? null,
      // Inbound daemon resolution: a tool call must never resolve to (and
      // then open) a Grove served by the other daemon variant.
      enforceGroveOwnership: true,
    });
  } catch (err) {
    // Preserve the auth-gate contract: a context-switch without the
    // daemon-issued bearer token is an authorization failure, not a
    // legacy-vault soft-fail. Re-throw so the caller's existing handling
    // applies.
    if (err instanceof UnauthorizedRequestContextError) throw err;
    // Same for ownership: a Grove served by the other daemon variant is a
    // 403 foreign_grove refusal, never the misleading legacy_vault 503.
    if (err instanceof ForeignGroveError) throw err;
    // Pre-Grove vault: the resolver threw because there's no Grove project
    // id to bind to. Surface the soft-fail reason rather than a 500.
    const result = tryResolveRequestContextForVault(vaultDir);
    const reason = result.kind === 'legacy'
      ? result.reason
      : `No authorized project tenancy for vault ${vaultDir}.`;
    return { ok: false, body: legacyVaultBody(reason, vaultDir) };
  }

  if (!isCallerTenancy(requestContext)) {
    // The request resolved to a synthesized (anchor-derived) tenancy — the
    // shared runtime would reject this at dispatch. Translate to the
    // legacy_vault wire contract up front.
    return {
      ok: false,
      body: legacyVaultBody(
        'This Myco project has no caller-supplied tenancy (it has not been auto-registered yet). '
        + 'Open the dashboard and commit Myco config to this project from the Symbionts page.',
        vaultDir,
      ),
    };
  }

  return { ok: true, requestContext };
}

export function createStreamableMcpHttpHandler(
  vaultDir: string,
  options: StreamableMcpHttpHandlerOptions = {},
): StreamableMcpHttpHandler {
  const client = options.client ?? new DaemonClient(vaultDir);
  return async (req, res) => {
    let resolved: ReturnType<typeof resolveRequestContextOrLegacy>;
    try {
      resolved = resolveRequestContextOrLegacy(req, vaultDir);
    } catch (err) {
      // A context-switch without the daemon-issued bearer token is an
      // authorization failure. Translate it to the same 401
      // `unauthorized_context_switch` contract the main HTTP server path
      // returns (daemon/server.ts) so MCP callers get a clear 401 instead
      // of the generic raw-route -32603/500 that an uncaught throw becomes.
      if (err instanceof UnauthorizedRequestContextError) {
        res.statusCode = 401;
        res.setHeader('Content-Type', 'application/json');
        res.end(JSON.stringify({ error: 'unauthorized_context_switch', message: err.message }));
        return;
      }
      // Grove served by the other daemon variant: surface the same 403
      // `foreign_grove` contract the main HTTP path returns so MCP callers
      // see the claim hint instead of a generic -32603/500.
      if (err instanceof ForeignGroveError) {
        res.statusCode = 403;
        res.setHeader('Content-Type', 'application/json');
        res.end(JSON.stringify({
          error: 'foreign_grove',
          message: err.message,
          grove_id: err.groveId,
          served_by: err.servedBy,
        }));
        return;
      }
      throw err;
    }
    if (!resolved.ok) {
      res.statusCode = 503;
      res.setHeader('Content-Type', 'application/json');
      res.end(resolved.body);
      return;
    }
    const { requestContext } = resolved;
    const tools = createMycoTools(vaultDir, client, {
      requestContext,
      resolveDatabase: options.resolveDatabase,
    });
    const server = createMcpProtocolServer(tools, {
      logger: options.logger,
      sessionId: requestContext.sessionId ?? null,
    });
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    });

    res.on('close', () => {
      void transport.close();
      void server.close();
    });

    await server.connect(transport);
    await transport.handleRequest(req, res);
  };
}
