import type { ChatResult, InternalDocReference, RecommendedAction } from "@pi-chat/shared";

export type PiChatInput = {
  message: string;
  model: string;
  docs: InternalDocReference[];
  availableActions: RecommendedAction[];
  history?: Array<{
    role: "user" | "assistant" | "system" | "tool";
    content: string;
  }>;
  skillPrompts?: string[];
  // Optional per-session system prompt override. Inserted right after the
  // base identity prompt and before skills, so it can extend the persona
  // without contradicting the strict rules in the base prompt.
  systemPrompt?: string | null;
};

export interface PiAdapter {
  chat(input: PiChatInput): Promise<PiChatResult>;
}

export type PiChatError =
  | { kind: "json_parse"; reason: string; rawContent: string }
  | { kind: "schema"; reason: string; rawContent: string }
  | { kind: "no_content"; message: string };

export type PiChatUsage = {
  promptTokens?: number;
  completionTokens?: number;
  totalTokens?: number;
  cachedTokens?: number;
  durationMs?: number;
};

export type PiChatResult =
  | { ok: true; result: ChatResult; usage?: PiChatUsage }
  | { ok: false; error: PiChatError; fallback: ChatResult; usage?: PiChatUsage };

export type OpenAICompatiblePiAdapterOptions = {
  baseUrl: string;
  apiKey: string;
  defaultModel: string;
  timeoutMs?: number;
  maxTokens?: number;
};

type OpenAIChatResponse = {
  choices?: Array<{
    message?: {
      content?: string;
    };
  }>;
  usage?: {
    prompt_tokens?: number;
    completion_tokens?: number;
    total_tokens?: number;
    prompt_tokens_details?: { cached_tokens?: number };
  };
};

const score = (value: unknown, fallback: number) => {
  if (typeof value !== "number" || Number.isNaN(value)) return fallback;
  return Math.max(0, Math.min(1, value));
};

// Extract an answer string from a parsed JSON object regardless of which key
// the model picked. Tries common names first, then falls back to the longest
// string-valued key. Returns null if parsed isn't an object or has no useful
// string field.
const extractAnswer = (parsed: unknown): string | null => {
  if (!parsed || typeof parsed !== "object") return null;
  const obj = parsed as Record<string, unknown>;
  const preferredKeys = ["answer", "response", "output", "text", "content", "message", "result"];
  for (const key of preferredKeys) {
    const value = obj[key];
    if (typeof value === "string" && value.trim().length > 0) return value;
  }
  let longest: { value: string; len: number } | null = null;
  for (const value of Object.values(obj)) {
    if (typeof value === "string") {
      const trimmed = value.trim();
      if (trimmed.length === 0) continue;
      // Skip keys we already treat as arrays elsewhere (handled separately).
      if (value === obj.recommended_actions || value === obj.internal_docs) continue;
      if (!longest || trimmed.length > longest.len) longest = { value: trimmed, len: trimmed.length };
    }
  }
  return longest?.value ?? null;
};

const normalizeResult = (
  parsed: unknown,
  fallbackAnswer: string,
  docs: InternalDocReference[],
  availableActions: RecommendedAction[],
): ChatResult => {
  const docsById = new Map(docs.map((doc) => [doc.id, doc]));
  const actionsById = new Map(availableActions.map((action) => [action.id, action]));
  const obj = (parsed && typeof parsed === "object" ? parsed : {}) as Record<string, unknown>;

  return {
    answer: extractAnswer(parsed) ?? fallbackAnswer,
    recommended_actions: Array.isArray(obj.recommended_actions)
      ? (obj.recommended_actions as unknown[]).flatMap((action) => {
          // Loose mode: accept either an object with id, or a bare string id.
          if (typeof action === "string") {
            if (!actionsById.has(action)) return [];
            const configured = actionsById.get(action)!;
            return [{
              type: configured.type ?? ("webhook" as const),
              id: action,
              confidence: 0,
              reason: configured.reason ?? action,
              requires_confirmation: configured.requires_confirmation ?? true,
            }];
          }
          if (!action || typeof action !== "object") return [];

          const candidate = action as Partial<RecommendedAction>;
          if (!candidate.id || !actionsById.has(candidate.id)) return [];

          const configured = actionsById.get(candidate.id)!;
          return [{
            type: "webhook" as const,
            id: candidate.id,
            confidence: score(candidate.confidence, 0),
            reason: String(candidate.reason ?? configured.reason ?? "Suggested action"),
            requires_confirmation: candidate.requires_confirmation ?? configured.requires_confirmation ?? true,
          }];
        })
      : [],
    internal_docs: Array.isArray(obj.internal_docs)
      ? (obj.internal_docs as unknown[]).flatMap((doc) => {
          // Loose mode: accept either an object with id, or a bare string id.
          if (typeof doc === "string") {
            if (!docsById.has(doc)) return [];
            const indexed = docsById.get(doc)!;
            return [{ id: doc, title: indexed.title, source: indexed.source, relevance: 0 }];
          }
          if (!doc || typeof doc !== "object") return [];

          const candidate = doc as Partial<InternalDocReference>;
          if (!candidate.id || !docsById.has(candidate.id)) return [];

          const indexed = docsById.get(candidate.id)!;
          return [{
            id: candidate.id,
            title: String(candidate.title ?? indexed.title),
            source: String(candidate.source ?? indexed.source),
            relevance: score(candidate.relevance, indexed.relevance),
          }];
        })
      : docs,
  };
};

const safeJsonParse = (
  value: string,
  docs: InternalDocReference[],
  availableActions: RecommendedAction[],
): { ok: true; result: ChatResult } | { ok: false; error: PiChatError } => {
  if (!value || value.trim().length === 0) {
    return {
      ok: false,
      error: { kind: "no_content", message: "LLM returned an empty message." },
    };
  }

  const start = value.indexOf("{");
  const end = value.lastIndexOf("}");
  if (start === -1 || end === -1 || end <= start) {
    return {
      ok: false,
      error: {
        kind: "json_parse",
        reason: "no JSON object delimiters found in response",
        rawContent: value.slice(0, 500),
      },
    };
  }

  let parsed: unknown = null;
  const slice = value.slice(start, end + 1);
  try {
    parsed = JSON.parse(slice);
  } catch {
    // Truncated JSON is a real failure mode for local models (vLLM /
    // ollama often cut output mid-array when max_tokens conflicts with
    // their actual context budget). Try to salvage the answer field via
    // regex before giving up.
    const salvaged = salvageAnswer(slice);
    if (salvaged !== null) {
      return {
        ok: true,
        result: {
          answer: salvaged,
          recommended_actions: [],
          internal_docs: docs,
        },
      };
    }
    return {
      ok: false,
      error: {
        kind: "json_parse",
        reason: "JSON.parse failed on truncated or malformed output",
        rawContent: value.slice(0, 500),
      },
    };
  }

  if (parsed === null || typeof parsed !== "object") {
    return {
      ok: false,
      error: {
        kind: "schema",
        reason: "parsed value is not an object",
        rawContent: value.slice(0, 500),
      },
    };
  }

  return { ok: true, result: normalizeResult(parsed, value, docs, availableActions) };
};

// Best-effort regex extraction of the answer field from truncated JSON.
// Matches the first key named like "answer" (with optional quote-prefix
// chars from quirky local models) and captures its string value.
const salvageAnswer = (slice: string): string | null => {
  // Try the canonical key first, then common variants seen from local models.
  const candidates = ['answer', 'response', 'output', 'text', 'content', 'message', 'result'];
  for (const key of candidates) {
    const re = new RegExp(`["']?${key}["']?\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
    const m = slice.match(re);
    if (m && m[1] && m[1].trim().length > 0) {
      return m[1].replace(/\\"/g, '"').replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\\\/g, "\\");
    }
  }
  return null;
};

export const createOpenAICompatiblePiAdapter = (options: OpenAICompatiblePiAdapterOptions): PiAdapter => ({
  async chat(input) {
    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), options.timeoutMs ?? 30_000);

    const response = await fetch(`${options.baseUrl.replace(/\/$/, "")}/chat/completions`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${options.apiKey}`,
        "content-type": "application/json",
      },
      signal: abortController.signal,
      body: JSON.stringify({
        model: input.model || options.defaultModel,
        // Always pin max_tokens to avoid hitting the cap mid-JSON and
        // emitting truncated output. Real providers honor this; servers
        // that cap harder will return a finish_reason of "length" but
        // we'll still get usable text.
        max_tokens: options.maxTokens ?? 4096,
        // Request a single JSON response. Some OpenAI-compatible servers
        // (e.g. local proxies like mr-auto) default to SSE streaming, and
        // `response.json()` below would blow up on the chunked stream.
        // Real providers like MiniMax accept and ignore this flag.
        stream: false,
        // Force JSON output for all OpenAI-compatible backends. Local
        // foundation models (qwen-local behind llm.rikrdo.com) often
        // ignore the "ALWAYS respond with JSON" system instruction and
        // default to natural language — this flag tells the server to
        // constrain the output to a JSON object. Real providers accept
        // and honor it.
        response_format: { type: "json_object" },
        messages: [
          {
            role: "system",
            content:
              "You are SIC (Super Incident Commander), an internal incident management assistant. " +
              "STRICT RULES: " +
              "1) Your reply MUST be a single JSON object (no markdown, no prose wrapper) with EXACTLY three keys: " +
              "   - \"answer\" (string): the response to the user, may include markdown for tables/lists/code. " +
              "   - \"recommended_actions\" (array): each item MUST be an object with keys id (string, present in available_actions), confidence (number 0..1), reason (string), and \"type\": \"webhook\". " +
              "   - \"internal_docs\" (array): each item MUST be an object with keys id, title, source, relevance (number 0..1). " +
              "   Example shape: {\"answer\": \"...\", \"recommended_actions\": [{\"id\":\"vpn-diagnostic\",\"type\":\"webhook\",\"confidence\":0.8,\"reason\":\"matches VPN symptoms\"}], \"internal_docs\": [{\"id\":\"runbooks:vpn\",\"title\":\"VPN Runbook\",\"source\":\"runbooks/vpn.md\",\"relevance\":0.9}]}. " +
              "2) recommended_actions may only include ids present in available_actions; never execute actions and never invent ids. The backend executes with confirmation. " +
              "3) DO NOT invent company names, owners, integrations, customers, or external facts. If the user asks something not backed by internal_docs, available_actions, or the history, say explicitly that you do not have that information. " +
              "4) When asked who you are or what company you belong to, only state that you are SIC (Super Incident Commander), an internal assistant; do not assume an owning company. " +
              "5) Whenever an available_action is contextually relevant to the user's request OR the user asks which actions exist, include its id in recommended_actions so the user can see and execute it from the right panel. The right panel renders ONLY items present in recommended_actions, so omitting them hides them.",
          },
          ...(input.skillPrompts ?? []).map((prompt) => ({
            role: "system" as const,
            content: prompt,
          })),
          ...(input.systemPrompt && input.systemPrompt.trim().length > 0
            ? [{ role: "system" as const, content: input.systemPrompt.trim() }]
            : []),
          {
            role: "system",
            content: JSON.stringify({
              internal_docs: input.docs,
              available_actions: input.availableActions,
            }),
          },
          ...(input.history ?? []).map((message) => ({
            role: message.role === "tool" ? "assistant" : message.role,
            content: message.content,
          })),
          { role: "user", content: input.message },
        ],
        temperature: 0.2,
      }),
    }).finally(() => clearTimeout(timeout));

    if (!response.ok) {
      throw new Error(`llm_request_failed:${response.status}`);
    }

    const data = (await response.json()) as OpenAIChatResponse;
    const content = data.choices?.[0]?.message?.content ?? "";
    const parsed = safeJsonParse(content, input.docs, input.availableActions);

    const usage = extractUsage(data.usage);

    if (parsed.ok) {
      return { ok: true, result: parsed.result, usage };
    }

    return {
      ok: false,
      error: parsed.error,
      fallback: {
        answer: content,
        recommended_actions: [],
        internal_docs: input.docs,
      },
      usage,
    };
  },
});

const extractUsage = (raw: OpenAIChatResponse["usage"]): PiChatUsage | undefined => {
  if (!raw || typeof raw !== "object") return undefined;
  const promptTokens = typeof raw.prompt_tokens === "number" ? raw.prompt_tokens : undefined;
  const completionTokens = typeof raw.completion_tokens === "number" ? raw.completion_tokens : undefined;
  const totalTokens = typeof raw.total_tokens === "number" ? raw.total_tokens : undefined;
  const cachedTokens =
    typeof raw.prompt_tokens_details?.cached_tokens === "number"
      ? raw.prompt_tokens_details.cached_tokens
      : undefined;
  if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined && cachedTokens === undefined) {
    return undefined;
  }
  return { promptTokens, completionTokens, totalTokens, cachedTokens };
};
