import { createLog, finalizeLogUpdate, getNextLogId, type CapturedLog } from "./store";
import { appendLogEntry, logger } from "./logger";
import { extractRequestMetadata } from "./schemas";
import { registry } from "./formats";
import { findProviderByModel } from "./providers";
import { getClientInfo } from "./socketTracker";
import { formatForPath } from "./formats";
import {
  PROXY_IDENTITY,
  PRESERVE_HEADERS,
  PATH_V1_MESSAGES,
  HEADER_CONTENT_TYPE,
  HEADER_USER_AGENT,
  HEADER_X_PROXY_IDENTITY,
  HEADER_CONTENT_ENCODING,
  HEADER_CONTENT_LENGTH,
  CONTENT_TYPE_EVENT_STREAM,
  STATUS_FORBIDDEN,
  STATUS_BAD_GATEWAY,
} from "./constants";
import { getConfig } from "./config";
import { stripClaudeCodeBillingHeader } from "./claudeCodeStrip";
import { stripOpenAIOrphanToolMessages } from "./openaiOrphanToolStrip";
import { buildFileLogEntry, type FinalizeLogJob } from "./logFinalizer";
import { enqueueFinalizeLogJob } from "./sessionRuntime";
import {
  buildUpstreamUrl,
  describeApiRoute,
  getProxyApiPath,
  injectProviderAuth,
  selectUpstreamBase,
  setUpstreamHost,
} from "./upstream";

/**
 * Strips all custom/non-standard headers from the request and replaces with
 * unified proxy identity. Only preserves standard HTTP headers needed for API calls.
 */
function buildProxyHeaders(originalHeaders: Headers): {
  headers: Headers;
  rawHeaders: Record<string, string>;
} {
  const rawHeaders: Record<string, string> = {};
  const headers = new Headers();

  originalHeaders.forEach((value, key) => {
    rawHeaders[key.toLowerCase()] = value;
  });

  headers.set(HEADER_USER_AGENT, PROXY_IDENTITY);
  headers.set(HEADER_X_PROXY_IDENTITY, PROXY_IDENTITY);

  for (const name of PRESERVE_HEADERS) {
    const value = originalHeaders.get(name);
    if (value !== null) {
      headers.set(name, value);
    }
  }

  return { headers, rawHeaders };
}

type ParsedRequestPath = {
  apiPath: string;
  isMessages: boolean;
  isChatCompletions: boolean;
  normalizedPath: string;
};

function parseRequestPath(req: Request, url: URL): ParsedRequestPath {
  const route = describeApiRoute(getProxyApiPath(url));
  const isPost = req.method === "POST";
  const isChatCompletions = isPost && route.isChatCompletions;
  const isMessages = isPost && (route.endpointPath === PATH_V1_MESSAGES || route.isChatCompletions);

  return {
    apiPath: route.apiPath,
    isMessages,
    isChatCompletions,
    normalizedPath: route.normalizedPath,
  };
}

function errorMessage(err: unknown): string {
  return err instanceof Error ? err.message : String(err);
}

function scheduleLogFinalization(job: FinalizeLogJob): void {
  void enqueueFinalizeLogJob(job).catch((err) => {
    logger.error(
      `[handler] Session finalization task failed for log #${job.log.id}:`,
      errorMessage(err),
    );
  });
}

function handleNonStreamingResponse(
  upstreamRes: Response,
  responseBody: string,
  startTime: number,
  upstreamUrl: string,
  log: CapturedLog,
): Response {
  const elapsedMs = Date.now() - startTime;

  scheduleLogFinalization({
    type: "non-streaming",
    log,
    upstreamUrl,
    elapsedMs,
    responseStatus: upstreamRes.status,
    responseBody,
  });

  const responseHeaders = new Headers(upstreamRes.headers);
  responseHeaders.delete(HEADER_CONTENT_ENCODING);
  responseHeaders.delete(HEADER_CONTENT_LENGTH);

  return new Response(responseBody, {
    status: upstreamRes.status,
    headers: responseHeaders,
  });
}

function handleStreamingResponse(
  upstreamRes: Response,
  req: Request,
  startTime: number,
  upstreamUrl: string,
  log: CapturedLog,
): Response {
  log.streaming = true;
  log.responseStatus = upstreamRes.status;

  const chunks: string[] = [];
  const decoder = new TextDecoder();

  const transform = new TransformStream<Uint8Array, Uint8Array>({
    transform(chunk, controller) {
      controller.enqueue(chunk);
      chunks.push(decoder.decode(chunk, { stream: true }));
    },
    flush() {
      const full = chunks.join("");
      const elapsedMs = Date.now() - startTime;

      scheduleLogFinalization({
        type: "streaming",
        log,
        upstreamUrl,
        elapsedMs,
        responseStatus: upstreamRes.status,
        rawStream: full,
      });
    },
  });

  if (upstreamRes.body === null) {
    return new Response("No response body", { status: STATUS_BAD_GATEWAY });
  }

  const loggedStream = upstreamRes.body.pipeThrough(transform);

  req.signal?.addEventListener("abort", () => {
    if (log.responseText === null) {
      logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
      const elapsedMs = Date.now() - startTime;
      const full = chunks.join("");
      const hasChunks = chunks.length > 0;

      scheduleLogFinalization({
        type: "stream-abort",
        log,
        upstreamUrl,
        elapsedMs,
        rawStream: full,
        hasChunks,
      });
    }
  });

  const responseHeaders = new Headers(upstreamRes.headers);
  responseHeaders.delete(HEADER_CONTENT_ENCODING);
  responseHeaders.delete(HEADER_CONTENT_LENGTH);

  return new Response(loggedStream, {
    status: upstreamRes.status,
    headers: responseHeaders,
  });
}

export async function handleProxy(req: Request): Promise<Response> {
  const url = new URL(req.url);
  const parsed = parseRequestPath(req, url);

  // Read body only after cheap path checks
  let requestBody: string | null = null;
  if (req.body && req.method !== "GET" && req.method !== "HEAD") {
    requestBody = await req.text();
  }

  // Opt-in: strip Claude Code's synthetic billing-header text block from the
  // system prompt. Only runs for Anthropic-format requests (those whose path
  // resolves to /v1/messages). The flag is read once per request from the
  // runtime config, so a PATCH to /api/config takes effect on the next
  // request without a restart.
  //
  // Important: the original `requestBody` must be preserved so the log stores
  // the unmodified request (the "Raw Request" tab needs the true bytes the
  // client sent). The rewritten body is only used for the upstream fetch.
  let bodyToForward = requestBody;
  if (getConfig().stripClaudeCodeBillingHeader && requestBody !== null && parsed.isMessages) {
    const stripped = stripClaudeCodeBillingHeader(requestBody);
    if (stripped.removed > 0) {
      logger.info(
        `[handler] Stripped ${stripped.removed} Claude Code billing-header block(s) from system prompt`,
      );
      bodyToForward = stripped.body;
    }
  }

  // Always-on protocol guard: OpenAI Chat Completions requires every
  // `role: "tool"` message to reference a `tool_call_id` present in a
  // preceding assistant message's `tool_calls`. Buggy clients sometimes
  // record a tool result without the matching assistant tool_call, which
  // makes the upstream reject the request with a 400 ("tool result's tool
  // id(call_xxx) not found"). Drop the orphan messages so the proxy can
  // forward a valid request; the original body is still kept in the log.
  if (bodyToForward !== null && parsed.isChatCompletions) {
    const stripped = stripOpenAIOrphanToolMessages(bodyToForward);
    if (stripped.removed > 0) {
      logger.warn(
        `[handler] Dropped ${stripped.removed} orphan OpenAI tool message(s) with tool_call_id(s) ${JSON.stringify(stripped.orphanIds)} — the client sent a tool result with no matching assistant.tool_calls`,
      );
      bodyToForward = stripped.body;
    }
  }

  // Parse the request body exactly once. The handler needs the model for
  // provider routing and the session id for the log; `createLog` would
  // otherwise re-parse the same body to extract the same fields.
  const { model, sessionId } = extractRequestMetadata(requestBody, req.headers);

  // Find provider config using already-extracted model (not calling extractModelFromBody again)
  const matchedProviderConfig = model !== null ? findProviderByModel(model) : null;
  const route = describeApiRoute(parsed.apiPath);
  const upstreamBase = selectUpstreamBase(route, matchedProviderConfig);
  const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
  const startTime = Date.now();

  const { headers: upstreamHeaders, rawHeaders } = buildProxyHeaders(req.headers);
  setUpstreamHost(upstreamHeaders, upstreamBase);
  injectProviderAuth(upstreamHeaders, matchedProviderConfig);

  const provider = model !== null ? registry.findProvider(model) : null;

  // Only proxy requests matching a registered provider
  if (model === null || provider === null) {
    logger.warn(`[handler] Unsupported provider: model=${model}`);
    return new Response("Forbidden: unsupported provider", { status: STATUS_FORBIDDEN });
  }

  // Pick the format handler from the request path. provider.format is only a
  // hint used as a fallback when no anthropicBaseUrl/openaiBaseUrl is set, and
  // does NOT describe what the upstream actually returns: a provider configured
  // with format="openai" may still expose an Anthropic-compatible endpoint
  // (e.g. MiniMax's /anthropic path), and the response shape always follows
  // the URL path the client hit, not the provider.format label.
  const formatHandler = formatForPath(parsed.apiPath);
  if (formatHandler === null) {
    return new Response("Forbidden: unsupported format", { status: STATUS_FORBIDDEN });
  }

  // Acquire client info and the next log id in parallel. On Windows
  // `getClientInfo` shells out to PowerShell and is the single slowest
  // thing on the hot path; serializing it before `createLog` paid the
  // full PowerShell round-trip on top of everything else before
  // `fetch` is even issued.
  const [clientInfo, preAcquiredId] = await Promise.all([getClientInfo(req), getNextLogId()]);
  const upstreamHeadersObj: Record<string, string> = {};
  upstreamHeaders.forEach((value, key) => {
    upstreamHeadersObj[key.toLowerCase()] = value;
  });
  const log = await createLog(
    req.method,
    parsed.apiPath,
    requestBody,
    req.headers,
    clientInfo,
    rawHeaders,
    upstreamHeadersObj,
    formatHandler.format,
    model,
    sessionId,
    preAcquiredId,
  );

  let upstreamRes: Response;
  try {
    upstreamRes = await fetch(upstreamUrl, {
      method: req.method,
      headers: upstreamHeaders,
      body: bodyToForward,
      signal: req.signal,
    });
  } catch (err) {
    log.elapsedMs = Date.now() - startTime;
    // Check if it was a client abort (not a proxy error)
    if (err instanceof Error && err.name === "AbortError") {
      logger.info(`[handler] Client aborted: ${req.method} ${parsed.apiPath}`);
      log.responseStatus = 499; // Client Closed Request (non-standard but descriptive)
      log.responseText = "Client aborted";
      appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
      finalizeLogUpdate(log);
      return new Response("Client aborted", { status: 499 });
    }
    logger.error(`[handler] Proxy error: ${req.method} ${parsed.apiPath}`, String(err));
    log.responseStatus = STATUS_BAD_GATEWAY;
    log.responseText = String(err);
    appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: String(err) });
    finalizeLogUpdate(log);
    return new Response(`Proxy error: ${err}`, { status: STATUS_BAD_GATEWAY });
  }

  const isStream =
    upstreamRes.headers.get(HEADER_CONTENT_TYPE)?.includes(CONTENT_TYPE_EVENT_STREAM) ?? false;

  if (!isStream) {
    const responseBody = await upstreamRes.text();
    return handleNonStreamingResponse(upstreamRes, responseBody, startTime, upstreamUrl, log);
  }

  return handleStreamingResponse(upstreamRes, req, startTime, upstreamUrl, log);
}
