/**
 * CLIProxyAPI Executor — routes requests to a local CLIProxyAPI instance.
 *
 * Always uses the OpenAI-compatible /v1/chat/completions endpoint. CLIProxyAPI
 * internally detects Claude models and routes them through its Claude executor
 * with full emulation (CCH signing, billing header, system prompt, uTLS,
 * multi-account rotation, device profile learning, etc.).
 *
 * The UI toggle (cliproxyapiMode in providerSpecificData) controls WHETHER
 * to use CLIProxyAPI as the backend, not the wire format. Response format
 * is always OpenAI-compatible, so chatCore's SSE parsing works unchanged.
 *
 * Activation:
 *   1. Per-provider upstream_proxy_config (mode=cliproxyapi or fallback)
 *   2. Per-connection cliproxyapiMode toggle in providerSpecificData (UI)
 */

import {
  BaseExecutor,
  mergeUpstreamExtraHeaders,
  mergeAbortSignals,
  type ProviderCredentials,
} from "./base.ts";
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts";
import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts";

const DEFAULT_PORT = 8317;
const DEFAULT_HOST = "127.0.0.1";
const HEALTH_CHECK_TIMEOUT_MS = 5000;

// Anthropic's reserved tool-name namespace: ^mcp_[^_].* triggers their
// server-side MCP connector billing gate, returning a misleading
// "out of extra usage" 400. Two-underscore (mcp__X) and capitalized
// (Mcp_X) variants pass cleanly.
const MCP_RESERVED_PREFIX_RE = /^mcp_(?=[^_])/;

function rewriteMcpToolName(name: string): string | null {
  if (typeof name !== "string" || !MCP_RESERVED_PREFIX_RE.test(name)) return null;
  return "M" + name.slice(1); // mcp_call → Mcp_call
}

/**
 * Rewrite ^mcp_[^_] tool names on a body destined for Anthropic's
 * /v1/messages. Returns a reverse map (rewritten → original) that the SSE
 * response stream uses to restore the client's original names on tool_use
 * blocks coming back.
 *
 * Non-mutating: replaces nested array elements with cloned objects rather
 * than mutating in place, so the caller's input body is not affected. The
 * outer `body` reference itself is the cloned `transformed` object from
 * `transformRequest` — we mutate its top-level `tools`, `messages`, and
 * `tool_choice` properties to point at the new clones.
 */
function applyMcpToolNameRewrite(body: Record<string, unknown>): Map<string, string> {
  const reverseMap = new Map<string, string>();
  const remember = (original: string, rewritten: string) => {
    reverseMap.set(rewritten, original);
  };

  const tools = body.tools;
  if (Array.isArray(tools)) {
    body.tools = tools.map((tool) => {
      if (!tool || typeof tool !== "object") return tool;
      const t = tool as Record<string, unknown>;
      const original = typeof t.name === "string" ? t.name : "";
      const rewritten = rewriteMcpToolName(original);
      if (rewritten) {
        remember(original, rewritten);
        return { ...t, name: rewritten };
      }
      return tool;
    });
  }

  const messages = body.messages;
  if (Array.isArray(messages)) {
    body.messages = messages.map((msg) => {
      if (!msg || typeof msg !== "object") return msg;
      const m = msg as Record<string, unknown>;
      const content = m.content;
      if (!Array.isArray(content)) return msg;
      let mutated = false;
      const newContent = content.map((block) => {
        if (!block || typeof block !== "object") return block;
        const b = block as Record<string, unknown>;
        if (b.type !== "tool_use") return block;
        const original = typeof b.name === "string" ? b.name : "";
        const rewritten = rewriteMcpToolName(original);
        if (rewritten) {
          mutated = true;
          remember(original, rewritten);
          return { ...b, name: rewritten };
        }
        return block;
      });
      return mutated ? { ...m, content: newContent } : msg;
    });
  }

  const toolChoice = body.tool_choice;
  if (toolChoice && typeof toolChoice === "object") {
    const tc = toolChoice as Record<string, unknown>;
    if (tc.type === "tool" && typeof tc.name === "string") {
      const rewritten = rewriteMcpToolName(tc.name);
      if (rewritten) {
        const original = tc.name;
        body.tool_choice = { ...tc, name: rewritten };
        remember(original, rewritten);
      }
    }
  }

  return reverseMap;
}

// Cached URL from settings (loaded once, invalidated on settings change via clearCliproxyapiUrlCache)
let _cachedSettingsUrl: { url: string; ts: number } | null = null;
const URL_CACHE_TTL_MS = 60_000;

export function clearCliproxyapiUrlCache() {
  _cachedSettingsUrl = null;
}

// Pre-load settings URL at module init so the sync path has a cache hit.
// This runs once when the executor module is first imported.
(async () => {
  try {
    const { getSettings } = await import("@/lib/db/settings");
    const settings = await getSettings();
    if (typeof settings.cliproxyapi_url === "string" && settings.cliproxyapi_url.trim()) {
      _cachedSettingsUrl = { url: settings.cliproxyapi_url.trim(), ts: Date.now() };
    }
  } catch { /* env vars will be used as fallback */ }
})();

/**
 * Resolve CLIProxyAPI base URL. Priority:
 *   1. Settings table `cliproxyapi_url` (set via UI)
 *   2. Environment variables CLIPROXYAPI_HOST / CLIPROXYAPI_PORT
 *   3. Defaults (127.0.0.1:8317)
 */
async function resolveCliproxyapiBaseUrl(): Promise<string> {
  // Check settings cache first
  if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
    return _cachedSettingsUrl.url;
  }

  try {
    const { getSettings } = await import("@/lib/db/settings");
    const settings = await getSettings();
    if (typeof settings.cliproxyapi_url === "string" && settings.cliproxyapi_url.trim()) {
      const url = settings.cliproxyapi_url.trim();
      _cachedSettingsUrl = { url, ts: Date.now() };
      return url;
    }
  } catch { /* fall through to env vars */ }

  const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
  const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10);
  const url = `http://${host}:${port}`;
  _cachedSettingsUrl = { url, ts: Date.now() };
  return url;
}

// Sync wrapper for backward compatibility (health checks, tests)
function resolveCliproxyapiBaseUrlSync(): string {
  if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
    return _cachedSettingsUrl.url;
  }
  const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
  const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10);
  return `http://${host}:${port}`;
}

export { resolveCliproxyapiBaseUrl };

/**
 * Check if a connection has CLIProxyAPI deep mode enabled via UI toggle.
 * Used by chatCore's resolveExecutorWithProxy to decide routing.
 */
export function isCliproxyapiDeepModeEnabled(
  providerSpecificData?: Record<string, unknown> | null
): boolean {
  return providerSpecificData?.cliproxyapiMode === "claude-native";
}

export class CliproxyapiExecutor extends BaseExecutor {
  private readonly upstreamBaseUrl: string;

  constructor(baseUrl?: string) {
    const effectiveBase = baseUrl ?? resolveCliproxyapiBaseUrlSync();
    super("cliproxyapi", {
      id: "cliproxyapi",
      baseUrl: effectiveBase + "/v1/chat/completions",
      headers: { "Content-Type": "application/json" },
    });
    this.upstreamBaseUrl = effectiveBase;
  }

  buildUrl(
    _model: string,
    _stream: boolean,
    _urlIndex = 0,
    _credentials: ProviderCredentials | null = null
  ): string {
    // Default endpoint when called without body context (kept for back-compat).
    // execute() picks the right endpoint from the body shape; see selectEndpoint().
    return `${this.upstreamBaseUrl}/v1/chat/completions`;
  }

  /**
   * Returns true when the body matches the Anthropic Messages wire shape.
   *
   * chatCore detects target=claude when the request comes from a Claude-source
   * client (`/v1/messages`, Anthropic-version header, claude/* model). In that
   * case no openai translation is applied and the executor sees the original
   * Anthropic body: top-level `system` as an array of content blocks, and
   * `messages[].content` as arrays. Routing those bodies to CPA's
   * /v1/chat/completions causes CPA to emit OpenAI-style SSE chunks, which
   * Anthropic SDK clients (Capy, claude-cli, etc.) cannot parse — the result
   * looks like a 200 server-side with "0 chunks received" client-side.
   *
   * CPA exposes /v1/messages natively (claude executor with uTLS spoof,
   * billing header, CCH signing, etc.) and emits proper Anthropic SSE:
   * `event: message_start`, `content_block_delta`, etc.
   */
  private isAnthropicShape(body: unknown): boolean {
    if (!body || typeof body !== "object") return false;
    const b = body as Record<string, unknown>;
    // Strong signal: top-level `system` field is unique to the Anthropic
    // Messages API. OpenAI Chat Completions encodes system as a role:"system"
    // entry inside messages[], not at body level. Accept both string and
    // array-of-content-blocks forms (Anthropic supports both per the docs).
    if (b.system !== undefined) return true;
    // Strong signal: top-level `thinking` field is Anthropic-only. OpenAI
    // uses `reasoning` / `reasoning_effort`. Even adaptive/raw Capy shapes
    // emit thinking, so this catches minimal Capy bodies (string content,
    // no system block) that would otherwise miss the messages[0].content
    // array check below.
    if (b.thinking !== undefined) return true;
    // Strong signal: top-level `metadata.user_id` is the CC wire-image
    // identifier; OpenAI request bodies don't carry it.
    if (
      b.metadata &&
      typeof b.metadata === "object" &&
      (b.metadata as Record<string, unknown>).user_id !== undefined
    )
      return true;
    // Strong signal: messages[0].content is an array of Anthropic content blocks
    const msgs = b.messages;
    if (Array.isArray(msgs) && msgs.length > 0) {
      const first = msgs[0] as Record<string, unknown>;
      if (Array.isArray(first?.content)) return true;
    }
    return false;
  }

  private selectEndpoint(body: unknown): string {
    return this.isAnthropicShape(body) ? "/v1/messages" : "/v1/chat/completions";
  }

  buildHeaders(credentials: ProviderCredentials | null, stream = true): Record<string, string> {
    const key = credentials?.apiKey || credentials?.accessToken;

    const headers: Record<string, string> = {
      "Content-Type": "application/json",
    };

    if (key) {
      headers["Authorization"] = `Bearer ${key}`;
    }
    if (stream) {
      headers["Accept"] = "text/event-stream";
    }

    return headers;
  }

  transformRequest(
    model: string,
    body: unknown,
    _stream: boolean,
    _credentials: ProviderCredentials | null
  ): unknown {
    if (!body || typeof body !== "object") return body;

    const transformed = { ...(body as Record<string, unknown>) };
    if (transformed.model !== model) {
      transformed.model = model;
    }

    // For Anthropic-shape bodies routed to CPA's /v1/messages, strip the
    // Capy/Anthropic-SDK premium extras that Anthropic gates with
    // "Extra usage is required" / "out of extra usage" (400). CPA does its
    // own Claude Code wire-image cloak (CCH, billing header, uTLS, metadata
    // user_id, system sentinel) downstream — but it forwards client extras
    // like output_config.effort=xhigh which trigger the extras-billing gate.
    //
    // Mirrors the runtime "Patch I2/I4" effect previously applied via patch.mjs.
    // Strips are no-op when fields are absent (OpenAI-shape passthrough).
    if (this.isAnthropicShape(transformed)) {
      delete transformed.output_config;
      delete transformed.context_management;
      delete transformed.client_info;
      delete transformed.prompt_cache_key;
      delete transformed.safety_identifier;
      delete transformed.metadata;

      // Conditional thinking strip: preserve Anthropic-valid shapes
      // ({type:"enabled"|"disabled", budget_tokens:N}) that applyThinkingBudget
      // already normalized. Strip non-Anthropic shapes (e.g. Capy's
      // {type:"adaptive", display:"summarized"}) which trigger Anthropic 400
      // "Extra usage required" / "out of extra usage". The `display` field is
      // a Capy-specific hint Anthropic doesn't accept.
      const thinking = transformed.thinking;
      if (thinking && typeof thinking === "object") {
        const t = thinking as Record<string, unknown>;
        const validType = t.type === "enabled" || t.type === "disabled";
        const hasValidBudget = typeof t.budget_tokens === "number" && t.budget_tokens >= 0;
        const hasInvalidExtras = "display" in t;
        if (!validType || !hasValidBudget || hasInvalidExtras) {
          delete transformed.thinking;
        }
      }

      // Rewrite tool names matching Anthropic's reserved ^mcp_[^_] namespace.
      // Anthropic returns "out of extra usage" / "Extra usage required" 400
      // when a client-declared tool name collides with their server-side MCP
      // connector tools. Bisected character-by-character against the real
      // Anthropic API via CPA (uTLS spoof, Claude OAuth):
      //   mcp_call, mcp_query, mcp_x, mcp_test  → 400 (gate hit)
      //   Mcp_call, _mcp_call, mcp__call, mcp-call, mcpcall, my_mcp_call → 200
      // The "Mcp_" capitalization is the smallest stable rewrite that
      // preserves readability. The reverse map below is propagated to
      // chatCore via body._toolNameMap, which the SSE passthrough stream
      // uses (utils/stream.ts:restoreClaudePassthroughToolUseName) to
      // rewrite tool_use.name back to the client's original namespace on
      // the response side. Capy sees mcp_call back in tool_use blocks.
      // Sanitize invalid tool input_schemas (truncation placeholders such as
      // `enum: "[MaxDepth]"`, or index-keyed objects where arrays are required)
      // that Anthropic rejects with `tools.N.custom.input_schema: JSON schema is
      // invalid` — surfaced as the same misleading "out of extra usage" 400.
      if (Array.isArray(transformed.tools)) {
        transformed.tools = sanitizeClaudeToolSchemas(transformed.tools) as unknown[];
      }

      // Cloak third-party / blacklisted tool names (e.g. `mixture_of_agents`, or
      // a large enough set of recognizable snake_case agent tools) that Anthropic
      // fingerprints and refuses with the same placeholder. The `mcp_*` reserved
      // namespace is deferred to applyMcpToolNameRewrite below (its bisected
      // `Mcp_X` form) so the two reverse maps stay disjoint and single-hop.
      const cloakMap = cloakThirdPartyToolNames(transformed, {
        skip: (name) => MCP_RESERVED_PREFIX_RE.test(name),
      });

      const mcpMap = applyMcpToolNameRewrite(transformed);

      const toolNameMap = new Map<string, string>(cloakMap);
      for (const [alias, original] of mcpMap) {
        toolNameMap.set(alias, original);
      }
      if (toolNameMap.size > 0) {
        // Non-enumerable: chatCore reads this for response-side tool-name
        // restoration; the wire body must never carry it (also stripped in execute()).
        Object.defineProperty(transformed, "_toolNameMap", {
          value: toolNameMap,
          enumerable: false,
          configurable: true,
          writable: true,
        });
      }
    }

    return transformed;
  }

  async execute(input: {
    model: string;
    body: unknown;
    stream: boolean;
    credentials: ProviderCredentials;
    signal?: AbortSignal | null;
    log?: any;
    upstreamExtraHeaders?: Record<string, string> | null;
  }) {
    // Resolve URL dynamically so settings table cliproxyapi_url is respected.
    // Uses 60s cache to avoid DB reads on every request.
    const baseUrl = await resolveCliproxyapiBaseUrl();
    const endpoint = this.selectEndpoint(input.body);
    const url = `${baseUrl}${endpoint}`;
    const shape = endpoint === "/v1/messages" ? "anthropic" : "openai";
    const headers = this.buildHeaders(input.credentials, input.stream);
    const transformedBody = this.transformRequest(
      input.model,
      input.body,
      input.stream,
      input.credentials
    );
    mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);

    const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
    const combinedSignal = input.signal
      ? mergeAbortSignals(input.signal, timeoutSignal)
      : timeoutSignal;

    input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`);

    // _toolNameMap is an in-memory channel to chatCore for response-side
    // tool name restoration; never send it over the wire.
    const wireBody =
      transformedBody && typeof transformedBody === "object"
        ? JSON.stringify(transformedBody, (key, value) =>
            key === "_toolNameMap" ? undefined : value
          )
        : JSON.stringify(transformedBody);

    const response = await fetch(url, {
      method: "POST",
      headers,
      body: wireBody,
      signal: combinedSignal,
    });

    if (response.status === HTTP_STATUS.RATE_LIMITED) {
      input.log?.warn?.("CPA", `CLIProxyAPI rate limited: ${response.status}`);
    }

    return { response, url, headers, transformedBody };
  }

  /**
   * Health check — verifies CLIProxyAPI is reachable.
   *
   * CPA 6.x doesn't expose a /health endpoint; previously we hit /health
   * and got 404, which made the dashboard report "CLIProxyAPI not
   * detected" even when the service was up and successfully serving
   * /v1/messages. Probe /v1/models instead (returns 200 with the
   * advertised model list), which is the closest thing CPA has to a
   * liveness probe and works on every CPA version we've tested.
   */
  async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
    const start = Date.now();
    try {
      const baseUrl = await resolveCliproxyapiBaseUrl();
      const res = await fetch(`${baseUrl}/v1/models`, {
        signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
      });
      return {
        ok: res.ok,
        latencyMs: Date.now() - start,
        ...(!res.ok ? { error: `HTTP ${res.status}` } : {}),
      };
    } catch (err) {
      return {
        ok: false,
        latencyMs: Date.now() - start,
        error: err instanceof Error ? err.message : String(err),
      };
    }
  }
}

export default CliproxyapiExecutor;
