/**
 * Single Anthropic client used by every service module.
 *
 * Centralises model selection, retry, timeout/abort, structured-output
 * parsing, schema validation, and best-effort call telemetry. Callers
 * import `callLLM` from here — they must not reach `/api/anthropic` on
 * their own.
 */

import { storage } from './storage';
import { withRetry, RetryableError, parseRetryAfter, isRetryableStatus } from './llmRetry';
import { toolSchemaRegistry } from './llmSchemas';
import { pb } from './pb';

const ANTHROPIC_URL = '/api/anthropic/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
const DEFAULT_TIMEOUT_MS = 60_000;

const TIER_DEFAULTS = {
  fast: 'claude-haiku-4-5-20251001',
  standard: 'claude-sonnet-4-6',
  reasoning: 'claude-opus-4-7',
};

export class LLMHttpError extends Error {
  constructor(status, statusText, body) {
    super(`API Error: ${status} ${statusText} - ${typeof body === 'string' ? body : JSON.stringify(body)}`);
    this.name = 'LLMHttpError';
    this.status = status;
    this.body = body;
  }
}

export class LLMTruncatedError extends Error {
  constructor(task) {
    super(`LLM response truncated (stop_reason: max_tokens) for task "${task}". Increase max_tokens or shorten the input.`);
    this.name = 'LLMTruncatedError';
  }
}

export class LLMOutputError extends Error {
  constructor(message) {
    super(message);
    this.name = 'LLMOutputError';
  }
}

export class LLMValidationError extends Error {
  constructor(task, zodError) {
    super(`LLM output failed schema validation for task "${task}": ${zodError?.message ?? zodError}`);
    this.name = 'LLMValidationError';
    this.cause = zodError;
  }
}

export function resolveModel(tier) {
  const key = `admin:model:${tier}`;
  const override = storage.get(key);
  if (override) return String(override).trim();
  if (tier === 'standard') {
    const legacy = storage.get('admin:model');
    if (legacy) return String(legacy).trim();
  }
  return TIER_DEFAULTS[tier] ?? TIER_DEFAULTS.standard;
}

/**
 * Extract the outermost balanced JSON value (object or array) from arbitrary
 * model output. Strips ```json fences first. Brace-matching ignores braces
 * inside strings; escapes inside strings are skipped.
 */
export function parseStructuredText(raw) {
  if (typeof raw !== 'string') throw new LLMOutputError('LLM returned no text.');
  let text = raw.trim();
  text = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, '');

  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (ch !== '{' && ch !== '[') continue;
    const open = ch;
    const close = ch === '{' ? '}' : ']';
    let depth = 0;
    let inString = false;
    for (let j = i; j < text.length; j++) {
      const c = text[j];
      if (inString) {
        if (c === '\\') { j++; continue; }
        if (c === '"') inString = false;
        continue;
      }
      if (c === '"') { inString = true; continue; }
      if (c === open) depth++;
      else if (c === close) {
        depth--;
        if (depth === 0) {
          const slice = text.slice(i, j + 1);
          try {
            return JSON.parse(slice);
          } catch {
            break;
          }
        }
      }
    }
  }
  throw new LLMOutputError('No balanced JSON value found in LLM output.');
}

export const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];

function buildMessages({ messages, user }) {
  if (Array.isArray(messages) && messages.length) return messages;
  if (typeof user === 'string' && user.length) return [{ role: 'user', content: user }];
  throw new Error('callLLM requires either `messages` or `user`.');
}

// Telemetry collection is optional. If the migration hasn't been applied on a
// given deploy, the first POST returns 404; we then disable further attempts
// to keep the console clean and avoid wasted round-trips.
let llmCallsDisabled = false;
function logLlmCall(record) {
  if (llmCallsDisabled) return;
  try {
    pb.collection('llm_calls').create(record).catch((err) => {
      if (err?.status === 404) llmCallsDisabled = true;
    });
  } catch {
    /* collection may not exist yet — swallow */
  }
}

function isChatLikeTask(task) {
  if (!task) return false;
  return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.');
}

const SIMULATION_EXTRACTION_GRAPH = {
  topics: [
    { id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' },
    { id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' },
    { id: 'wekelijkse-sessie', label: 'Wekelijkse Leersessie', type: 'process', description: 'Elke week leren medewerkers via AI-gegenereerde vragen en quizzen.', learning_relevance: 'standard' },
  ],
  relations: [
    { source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' },
    { source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' },
  ],
};

const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify(SIMULATION_EXTRACTION_GRAPH);

const SIMULATION_CHAT_TEXT =
  'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.';

const SIMULATION_ARTICLE = {
  title: 'Voorbeeld leermodule',
  intro: 'Dit is een simulatie. Schakel Simulation Mode uit om echte content te genereren.',
  sections: [
    { heading: 'Wat dit is', body: 'Dit is een placeholder-sectie die alleen verschijnt wanneer simulatiemodus aan staat. Hij illustreert de structuur van het artikel zonder een echte API-aanroep te doen. Dat is handig voor UI-werk.' },
  ],
  keyTakeaways: ['Simulatiemodus levert geen echte inhoud.', 'Schakel uit voor productie.'],
};

const SIMULATION_SLIDE = {
  title: 'Voorbeeldslide',
  bullets: ['Eerste punt', 'Tweede punt'],
  speakerNote: 'Spreker-notitie ter illustratie.',
};

const SIMULATION_INFOGRAPHIC = {
  headline: 'Simulatie',
  tagline: 'Vervang door echte content',
  stats: [{ value: '100%', label: 'simulatie', icon: '📊' }],
  steps: [{ number: 1, title: 'Schakel uit', description: 'Zet simulatiemodus uit in Admin → Settings.', icon: '🔧' }],
  quote: 'Een simulatie vertelt niets nieuws.',
  colorTheme: 'teal',
};

const SIMULATION_TOOL_STUBS = {
  emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH,
  emit_curriculum_schedule: {
    weeks: Array.from({ length: 26 }, (_, i) => ({
      week_number: i + 1,
      theme: i < 13 ? 'Privacy' : 'Governance',
      topic_ids: ['sim-topic'],
      estimated_duration: 30,
      week_rationale: `Simulated rationale for week ${i + 1}.`
    }))
  },
  emit_topic_enrichment: {
    topics: [
      { id: 'radicale-transparantie', theme: 'Culture', complexity_weight: 2, difficulty: 'introductory' },
      { id: 'kennisbeheer', theme: 'Process', complexity_weight: 4, difficulty: 'advanced' },
      { id: 'wekelijkse-sessie', theme: 'Process', complexity_weight: 3, difficulty: 'intermediate' },
    ]
  },

  emit_learning_article: { article: SIMULATION_ARTICLE },
  emit_learning_slides: { slides: [SIMULATION_SLIDE] },
  emit_learning_infographic: { infographic: SIMULATION_INFOGRAPHIC },
  emit_learning_all: { article: SIMULATION_ARTICLE, slides: [SIMULATION_SLIDE], infographic: SIMULATION_INFOGRAPHIC },
  emit_custom_topic: { label: 'Simulatie onderwerp', type: 'concept', description: 'Een placeholder-onderwerp gegenereerd in simulatiemodus.' },
  emit_quiz_questions: {
    questions: [
      {
        id: 'sim-q1',
        question: 'Wat doet simulatiemodus?',
        topicLabel: 'Simulatie',
        options: ['Echte API-aanroepen', 'Stub-data tonen', 'Niets', 'Crasht de app'],
        correctIndex: 1,
        explanation: 'Simulatiemodus retourneert vaste stub-data zonder de API te raken.',
      },
    ],
  },
  emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
  set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
  emit_onboarding_overview: {
    title: 'Voorbeeldthema',
    what_it_is: 'Een korte simulatie-omschrijving van dit thema.',
    why_it_matters: 'In simulatiemodus laat dit zien hoe de onboarding-overview eruitziet zonder de API te raken.',
    key_points: ['Kernpunt één', 'Kernpunt twee', 'Kernpunt drie'],
    topics_covered: [{ topic_id: 'sim-topic', label: 'Simulatie onderwerp' }],
  },
};

function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
  return {
    text,
    toolUses,
    stopReason,
    usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
    requestId: null,
    model: 'simulation',
    durationMs: 400,
  };
}

async function simulatedResponse({ task, toolChoice }) {
  await new Promise((r) => setTimeout(r, 400));

  if (toolChoice?.type === 'tool' && SIMULATION_TOOL_STUBS[toolChoice.name]) {
    return stubResponse({
      stopReason: 'tool_use',
      toolUses: [{ name: toolChoice.name, input: SIMULATION_TOOL_STUBS[toolChoice.name] }],
    });
  }

  if (isChatLikeTask(task)) {
    return stubResponse({ text: SIMULATION_CHAT_TEXT });
  }
  return stubResponse({ text: SIMULATION_EXTRACTION_PAYLOAD });
}

function linkSignals(userSignal, timeoutSignal) {
  const controller = new AbortController();
  const abort = (reason) => controller.abort(reason);
  if (userSignal) {
    if (userSignal.aborted) controller.abort(userSignal.reason);
    else userSignal.addEventListener('abort', () => abort(userSignal.reason), { once: true });
  }
  if (timeoutSignal) {
    if (timeoutSignal.aborted) controller.abort(timeoutSignal.reason);
    else timeoutSignal.addEventListener('abort', () => abort(timeoutSignal.reason), { once: true });
  }
  return controller.signal;
}

function extractToolUses(content) {
  if (!Array.isArray(content)) return [];
  return content
    .filter((b) => b?.type === 'tool_use')
    .map((b) => ({ id: b.id, name: b.name, input: b.input }));
}

function extractText(content) {
  if (!Array.isArray(content)) return '';
  return content
    .filter((b) => b?.type === 'text' && typeof b.text === 'string')
    .map((b) => b.text)
    .join('');
}

function validateToolInputs(toolUses, task, toolSchemas) {
  const registry = { ...toolSchemaRegistry, ...(toolSchemas || {}) };
  for (const tu of toolUses) {
    const schema = registry[tu.name];
    if (!schema) continue;
    const result = schema.safeParse(tu.input);
    if (!result.success) throw new LLMValidationError(`${task}:${tu.name}`, result.error);
    tu.input = result.data;
  }
}

/**
 * @typedef {Object} CallLLMOptions
 * @property {string} task              Logging label, e.g. 'extract.source'.
 * @property {'fast'|'standard'|'reasoning'} [tier='standard']
 * @property {string|Array<{type:'text',text:string,cache_control?:{type:'ephemeral'}}>} [system]
 * @property {Array<{role:'user'|'assistant',content:any}>} [messages]
 * @property {string} [user]            Shorthand for a single user message.
 * @property {Array<object>} [tools]    Anthropic tool definitions.
 * @property {object} [toolChoice]      e.g. { type: 'tool', name: 'emit_knowledge_graph' }.
 * @property {import('zod').ZodTypeAny} [schema]   For text→JSON validation.
 * @property {Record<string, import('zod').ZodTypeAny>} [toolSchemas] Overrides for tool_use input validation.
 * @property {number} [maxTokens=4096]
 * @property {number} [temperature=0]
 * @property {number} [timeoutMs=60000]  Per-request timeout in ms. Increase for large structured extractions.
 * @property {AbortSignal} [signal]
 * @property {{ acquire: (opts?:{signal?:AbortSignal}) => Promise<void>, pauseUntil: (untilMs:number) => void }} [limiter]
 */

/**
 * @param {CallLLMOptions} options
 */
export async function callLLM(options) {
  const {
    task,
    tier = 'standard',
    system,
    messages,
    user,
    tools,
    toolChoice,
    schema,
    toolSchemas,
    maxTokens = 4096,
    temperature = 0,
    timeoutMs = DEFAULT_TIMEOUT_MS,
    signal,
    limiter,
  } = options;
  if (!task) throw new Error('callLLM requires a `task` label.');

  const useSimulation = storage.get('admin:use_simulation') === true;
  if (useSimulation) return simulatedResponse({ task, toolChoice });

  const model = resolveModel(tier);
  const messagesPayload = buildMessages({ messages, user });

  const body = {
    model,
    max_tokens: maxTokens,
    messages: messagesPayload,
  };
  // Temperature is not supported for reasoning tier models
  if (tier !== 'reasoning') {
    body.temperature = temperature;
  }
  if (system !== undefined) body.system = system;
  if (tools && tools.length) body.tools = tools;
  if (toolChoice) body.tool_choice = toolChoice;

  const start = Date.now();
  let result;
  try {
    result = await withRetry(
      async () => {
        if (limiter) await limiter.acquire({ signal });
        let didTimeout = false;
        const timeoutCtl = new AbortController();
        const timer = setTimeout(() => {
          didTimeout = true;
          timeoutCtl.abort();
        }, timeoutMs);
        const fetchSignal = linkSignals(signal, timeoutCtl.signal);

        try {
          const response = await fetch(ANTHROPIC_URL, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'anthropic-version': ANTHROPIC_VERSION,
            },
            body: JSON.stringify(body),
            signal: fetchSignal,
          });

          if (!response.ok) {
            const errBody = await response.json().catch(() => ({}));
            if (isRetryableStatus(response.status)) {
              const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
              if (response.status === 429 && retryAfterMs != null && limiter) {
                limiter.pauseUntil(Date.now() + retryAfterMs);
              }
              throw new RetryableError(response.status, retryAfterMs, `HTTP ${response.status}`);
            }
            throw new LLMHttpError(response.status, response.statusText, errBody);
          }

          const contentType = response.headers.get('content-type') || '';
          if (!contentType.includes('application/json')) {
            throw new Error('Your session has expired. Please refresh the page and log in again.');
          }

          return await response.json();
        } catch (err) {
          if (didTimeout) {
            throw new Error('Timeout');
          }
          throw err;
        } finally {
          if (timer) clearTimeout(timer);
        }
      },
      { signal },
    );
  } catch (err) {
    logLlmCall({
      task,
      model,
      tier,
      duration_ms: Date.now() - start,
      input_tokens: 0,
      output_tokens: 0,
      cache_read_tokens: 0,
      cache_create_tokens: 0,
      stop_reason: '',
      ok: false,
      error_msg: String(err?.message ?? err).slice(0, 500),
    });
    throw err;
  }

  const stopReason = result.stop_reason || '';
  const toolUses = extractToolUses(result.content);
  const text = extractText(result.content);
  const usage = result.usage || {};

  const truncationRequiresFailure =
    stopReason === 'max_tokens' && (Boolean(schema) || Boolean(toolChoice));

  logLlmCall({
    task,
    model,
    tier,
    duration_ms: Date.now() - start,
    input_tokens: usage.input_tokens ?? 0,
    output_tokens: usage.output_tokens ?? 0,
    cache_read_tokens: usage.cache_read_input_tokens ?? 0,
    cache_create_tokens: usage.cache_creation_input_tokens ?? 0,
    stop_reason: stopReason,
    ok: !truncationRequiresFailure,
    error_msg: truncationRequiresFailure ? 'max_tokens' : '',
  });

  if (truncationRequiresFailure) throw new LLMTruncatedError(task);

  if (toolUses.length) validateToolInputs(toolUses, task, toolSchemas);

  let parsedFromText;
  if (schema && !toolUses.length) {
    const value = parseStructuredText(text);
    const parsed = schema.safeParse(value);
    if (!parsed.success) throw new LLMValidationError(task, parsed.error);
    parsedFromText = parsed.data;
  }

  return {
    text,
    toolUses,
    stopReason,
    usage: {
      input_tokens: usage.input_tokens ?? 0,
      output_tokens: usage.output_tokens ?? 0,
      cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
      cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
    },
    requestId: result.id ?? null,
    model: result.model ?? model,
    durationMs: Date.now() - start,
    parsed: parsedFromText,
  };
}
