// Provider adapter table + the single LLM call path. Replaces the eight
// hand-rolled per-provider functions (callX / callXWithPrompt families) that
// previously lived in the background worker. Behavior-preserving: URLs,
// headers, request bodies, default models, and error strings match the old
// implementations — pinned by tests/unit/providers.test.ts.

import type { LexAIConfig, LexAIResponse, PromptParams } from './types';

export async function fetchWithTimeout(
  url: string,
  options: RequestInit,
  timeoutMs = 30000,
): Promise<Response> {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(id);
  }
}

// ─── System prompts ───────────────────────────────────────────────────────────

// Prompt Builder parameters → extra instructions for the prompt-engineer
// action. 'Auto' (or absence) adds nothing — the base prompt already tells the
// engineer to decide these from the input.
function promptParamModifiers(params?: PromptParams): string {
  if (!params) return '';
  const parts: string[] = [];

  const styleInstructions: Record<string, string> = {
    Instructional: ' Compose the engineered prompt as clear, direct instructions.',
    'Role-play': ' Compose the engineered prompt as a role-play scenario the model should stay in character for.',
    'Step-by-step': ' The engineered prompt should ask the model to work through the task step by step before giving its final answer.',
    'Few-shot': ' Include one or two short input→output examples (few-shot) in the engineered prompt.',
    Structured: ' Organize the engineered prompt into labeled sections (Context, Task, Constraints, Output format).',
  };
  if (params.promptStyle && styleInstructions[params.promptStyle]) {
    parts.push(styleInstructions[params.promptStyle]);
  }

  if (params.persona === 'None') {
    parts.push(' Do not assign a persona or role in the engineered prompt.');
  } else if (params.persona && params.persona !== 'Auto') {
    parts.push(` The engineered prompt must assign the model the persona of ${params.persona.trim()}, including the skills and expertise that persona implies.`);
  }

  if (params.format && params.format !== 'Auto') {
    parts.push(` The engineered prompt must require the final output as ${params.format.toLowerCase()}.`);
  }

  return parts.join('');
}

export function getSystemPrompt(action: string, style?: string, promptParams?: PromptParams): string {
  // Normalize 'fix' (used by context menu and popup) to 'grammar'
  const normalizedAction = action === 'fix' ? 'grammar' : action;
  const prompts: Record<string, string> = {
    grammar:
      'You are a professional grammar editor. Fix all grammar, spelling, and punctuation errors in the provided text. ' +
      'Preserve the original meaning and tone as closely as possible. ' +
      'Return ONLY the corrected text — no explanations, no preamble.',
    rephrase:
      'You are a skilled writing assistant. Rephrase the provided text to make it clearer, more engaging, and more professional. ' +
      'Keep the same meaning and approximate length. ' +
      'Return ONLY the rephrased text — no explanations.',
    shorten:
      'You are a concise editor. Shorten the provided text by at least 30% while preserving the core message. ' +
      'Remove filler words, redundant phrases, and unnecessary detail. ' +
      'Return ONLY the shortened text.',
    expand:
      'You are an experienced writer. Expand the provided text with more detail, context, and supporting points. ' +
      'Make it richer and more informative while staying on topic. ' +
      'Return ONLY the expanded text.',
    explain:
      'You are a helpful teacher. Explain the following text in simple, easy-to-understand language. ' +
      'Break down complex terms, jargon, or concepts so anyone can understand. ' +
      'Be concise but clear. Return only the explanation, no extra commentary.',
    prompt:
      'You are an expert prompt engineer. Transform the provided text into one well-crafted prompt that will get the best possible result from an AI model. ' +
      'First infer what the user is trying to achieve — the text may be a rough idea, a question, or a description of the output they want. ' +
      'Then compose the prompt using only the components that goal actually needs: a persona/role and relevant skills when expertise helps, essential context, a clear task statement, constraints, and the desired output format or structure. ' +
      'Make the prompt specific, self-contained, and unambiguous, and phrase it as instructions addressed to an AI model. ' +
      'Return ONLY the engineered prompt, ready to paste into an AI chat — no explanations, no surrounding quotes, no preamble.',
  };
  const base = prompts[normalizedAction] ?? prompts.grammar;
  // For the prompt-engineer action, the style describes the output the
  // engineered prompt should ask for — not the wording of our response.
  const styleModifier = style && style !== 'Default'
    ? normalizedAction === 'prompt'
      ? ` The engineered prompt should instruct the model to respond in a ${style.toLowerCase()} style.`
      : ` Write in a ${style.toLowerCase()} style.`
    : '';
  const paramModifiers = normalizedAction === 'prompt' ? promptParamModifiers(promptParams) : '';
  return base + styleModifier + paramModifiers;
}

// ─── Adapter table ────────────────────────────────────────────────────────────

export interface ProviderSpec {
  label: string; // human name used in error messages ('OpenAI error: …')
  chatUrl: string;
  modelsUrl: string;
  defaultModel: string;
  headers: (apiKey: string) => Record<string, string>;
  body: (model: string, systemPrompt: string, text: string, maxTokens: number) => Record<string, unknown>;
  extract: (data: any) => string | undefined;
}

function bearerHeaders(apiKey: string): Record<string, string> {
  return { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` };
}

// OpenAI-compatible chat body (OpenAI, Groq, OpenRouter). OpenRouter's old
// implementation sent no temperature — preserve that.
function openAiStyleBody(temperature?: number) {
  return (model: string, systemPrompt: string, text: string, maxTokens: number) => ({
    model,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: text },
    ],
    max_tokens: maxTokens,
    ...(temperature !== undefined ? { temperature } : {}),
  });
}

const extractOpenAiStyle = (data: any): string | undefined => data?.choices?.[0]?.message?.content;

// OpenAI proper rejects the legacy `max_tokens` on newer models ("Use
// 'max_completion_tokens' instead") and reasoning models (o-series, gpt-5
// family) additionally reject any temperature other than the default.
// `max_completion_tokens` is accepted by all current OpenAI chat models, so
// send it unconditionally; temperature stays only for non-reasoning models.
// Groq/OpenRouter still expect `max_tokens` — don't apply this to them.
const OPENAI_REASONING_MODEL_RE = /^(o\d|gpt-5)/;

function openAiBody(model: string, systemPrompt: string, text: string, maxTokens: number) {
  return {
    model,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: text },
    ],
    max_completion_tokens: maxTokens,
    ...(OPENAI_REASONING_MODEL_RE.test(model) ? {} : { temperature: 0.7 }),
  };
}

export const PROVIDER_SPECS: Record<string, ProviderSpec> = {
  openai: {
    label: 'OpenAI',
    chatUrl: 'https://api.openai.com/v1/chat/completions',
    modelsUrl: 'https://api.openai.com/v1/models',
    defaultModel: 'gpt-4o-mini',
    headers: bearerHeaders,
    body: openAiBody,
    extract: extractOpenAiStyle,
  },
  anthropic: {
    label: 'Anthropic',
    chatUrl: 'https://api.anthropic.com/v1/messages',
    modelsUrl: 'https://api.anthropic.com/v1/models',
    defaultModel: 'claude-3-5-haiku-20241022',
    headers: (apiKey) => ({
      'Content-Type': 'application/json',
      'x-api-key': apiKey,
      'anthropic-version': '2023-06-01',
      // Anthropic rejects browser-origin requests unless this opt-in is sent.
      // The service worker counts as a browser origin, so it's required here too.
      'anthropic-dangerous-direct-browser-access': 'true',
    }),
    body: (model, systemPrompt, text, maxTokens) => ({
      model,
      max_tokens: maxTokens,
      system: systemPrompt,
      messages: [{ role: 'user', content: text }],
    }),
    extract: (data) => data?.content?.[0]?.text,
  },
  groq: {
    label: 'Groq',
    chatUrl: 'https://api.groq.com/openai/v1/chat/completions',
    modelsUrl: 'https://api.groq.com/openai/v1/models',
    defaultModel: 'llama-3.3-70b-versatile',
    headers: bearerHeaders,
    body: openAiStyleBody(0.7),
    extract: extractOpenAiStyle,
  },
  openrouter: {
    label: 'OpenRouter',
    chatUrl: 'https://openrouter.ai/api/v1/chat/completions',
    modelsUrl: 'https://openrouter.ai/api/v1/models',
    defaultModel: 'openai/gpt-4o-mini',
    headers: (apiKey) => ({
      ...bearerHeaders(apiKey),
      'HTTP-Referer': 'https://lexai.dev',
      'X-Title': 'LexAI',
    }),
    body: openAiStyleBody(undefined),
    extract: extractOpenAiStyle,
  },
};

// Display name for a provider id ('groq' → 'Groq'), for user-facing messages.
export function providerLabel(provider: string): string {
  return PROVIDER_SPECS[provider]?.label ?? provider;
}

// A rejected key is the one failure users can actually fix, and the provider's
// own wording ("Invalid API Key") doesn't say where to fix it.
const KEY_HINT = ' — open LexAI Settings and re-enter your API key for this provider.';
const isAuthStatus = (status: number) => status === 401 || status === 403;

// ─── Chat call ────────────────────────────────────────────────────────────────

// Scale the output budget with the input instead of the old hard-coded 1024
// (which truncated "Expand" on long selections). chars ≈ tokens × 4, so this
// allows roughly 4× the input length in output, clamped to a sane range.
export function defaultMaxTokens(text: string): number {
  return Math.max(1024, Math.min(8192, Math.ceil(text.length)));
}

export interface CallOptions {
  maxTokens?: number;
}

export async function callProvider(
  config: LexAIConfig,
  text: string,
  systemPrompt: string,
  opts?: CallOptions,
): Promise<LexAIResponse> {
  const provider = config.provider || 'openai';
  const spec = PROVIDER_SPECS[provider];
  if (!spec) {
    return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
  }

  const model = config.model || spec.defaultModel;
  const maxTokens = opts?.maxTokens ?? defaultMaxTokens(text);

  let res: Response;
  try {
    res = await fetchWithTimeout(spec.chatUrl, {
      method: 'POST',
      headers: spec.headers(config.apiKey ?? ''),
      body: JSON.stringify(spec.body(model, systemPrompt, text, maxTokens)),
    });
  } catch (err) {
    return { error: `Network error reaching ${spec.label}: ${String(err)}` };
  }

  // Guard the parse — gateways/proxies return HTML error pages (502 etc.)
  // which would otherwise surface as a raw SyntaxError to the user.
  const data = await res.json().catch(() => null);
  if (!res.ok) {
    const msg = data?.error?.message ?? `HTTP ${res.status}`;
    return { error: `${spec.label} error: ${msg}${isAuthStatus(res.status) ? KEY_HINT : ''}` };
  }

  const result = spec.extract(data);
  if (!result) return { error: `${spec.label} returned an empty response.` };
  return { result: result.trim() };
}

// ─── Live model listing ───────────────────────────────────────────────────────

// Drop non-chat models (embeddings, audio, image, etc.) so the picker stays useful.
const NON_CHAT_MODEL_RE = /embedding|whisper|tts|dall-e|audio|realtime|moderation|image|guard|transcribe|speech|rerank/i;

export async function listModels(
  provider: string,
  apiKey?: string,
): Promise<{ models?: string[]; error?: string; keyRejected?: boolean }> {
  const spec = PROVIDER_SPECS[provider];
  if (!spec) return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };

  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  if (provider === 'anthropic') {
    if (!apiKey) return { error: 'Anthropic requires an API key to list models.' };
    headers['x-api-key'] = apiKey;
    headers['anthropic-version'] = '2023-06-01';
    // Allow the extension origin to call Anthropic directly (avoids a CORS 403).
    headers['anthropic-dangerous-direct-browser-access'] = 'true';
  } else if (apiKey) {
    // OpenRouter's list is public, so the key is optional there; OpenAI/Groq require it.
    headers['Authorization'] = `Bearer ${apiKey}`;
  }

  let res: Response;
  try {
    res = await fetchWithTimeout(spec.modelsUrl, { method: 'GET', headers }, 15000);
  } catch (err) {
    return { error: `Network error reaching ${spec.label}: ${String(err)}` };
  }

  const data = await res.json().catch(() => null);
  if (!res.ok) {
    const msg = data?.error?.message ?? data?.error ?? `HTTP ${res.status}`;
    // Flag rejected keys so Options can tell the user to re-enter one instead
    // of leaving the 🔒 badge implying the stored key is usable.
    return { error: `${spec.label} error: ${msg}`, ...(isAuthStatus(res.status) ? { keyRejected: true } : {}) };
  }

  const raw = Array.isArray(data?.data) ? data.data : [];
  const ids = raw
    .map((m: any) => (typeof m === 'string' ? m : m?.id))
    .filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)
    .filter((id: string) => !NON_CHAT_MODEL_RE.test(id))
    .sort((a: string, b: string) => a.localeCompare(b));

  if (ids.length === 0) return { error: `No models returned by ${spec.label}.` };
  return { models: ids };
}
