// Conversational chat over Ollama Cloud with tool calling.
//
// The rest of the app uses the LLM as a one-shot generator (cover letters,
// e-mail replies). This module adds an interactive chat: it streams assistant
// tokens from Ollama (`stream: true`, NDJSON) and lets the assistant look up
// the user's application data on demand via tool calls instead of having every
// application baked into the system prompt (which would be slow and grow with
// the data).
//
// Unlike lib/documents.js, no JSON output schema is enforced — the model just
// replies as text, token by token, so the UI can render progressively.

const promptStore = require('./prompts');
const config = require('./config');

// Firmenrecherche braucht mehr Runden als eine reine Datenbankfrage: Bewerbung
// holen -> web_suche -> ein, zwei Seiten lesen -> antworten. Mit 4 Runden lief
// der Assistent dabei ins Limit, statt zu antworten.
const MAX_TOOL_ROUNDS = 8;

function isConfigured() {
  return Boolean(config.ollama().apiKey);
}

// Stream a single chat completion from Ollama. `messages` = [{role, content}]
// in chronological order (system prompt is prepended separately). `tools` is an
// optional Ollama tool-definition array; when the model calls a tool the
// returned `toolCalls` is non-empty and `content` is usually empty. `onToken`
// fires with each incremental text chunk. Returns { content, toolCalls }.
// Aborts cleanly via `signal`.
async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) {
  const { host: ollamaHost, model: ollamaModel, apiKey } = config.ollama();
  if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt (unter „Einstellungen“ konfigurieren).');

  const allMessages = [];
  if (system) allMessages.push({ role: 'system', content: system });
  for (const m of messages) {
    // Preserve tool_calls (assistant) and tool_name (tool result) so Ollama can
    // match tool calls to their results across rounds.
    const msg = { role: m.role, content: m.content };
    if (m.tool_calls) msg.tool_calls = m.tool_calls;
    if (m.tool_name) msg.tool_name = m.tool_name;
    allMessages.push(msg);
  }

  const body = {
    model: ollamaModel,
    stream: true,
    options: { temperature },
    messages: allMessages,
  };
  if (tools && tools.length) body.tools = tools;

  let res;
  try {
    res = await fetch(`${ollamaHost}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
      body: JSON.stringify(body),
      signal,
    });
  } catch (err) {
    if (err.name === 'AbortError') throw new Error('Abgebrochen.');
    throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
  }
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`Ollama-API antwortete mit ${res.status}: ${text.slice(0, 300)}`);
  }
  if (!res.body || !res.body.getReader) {
    // Node without streaming body support: fall back to buffered response.
    const data = await res.json();
    const msg = (data && data.message) || {};
    const text = (msg.content || '').trim();
    if (onToken && text) onToken(text);
    return { content: text, toolCalls: normalizeToolCalls(msg.tool_calls) };
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let full = '';
  const toolCalls = [];

  // Ollama streams one JSON object per line (NDJSON). Accumulate partial
  // lines across chunks, then parse each complete line.
  const handleLine = (line) => {
    line = line.trim();
    if (!line) return;
    let obj;
    try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives
    const msg = obj && obj.message;
    if (!msg) return;
    const delta = msg.content;
    if (delta) {
      full += delta;
      if (onToken) onToken(delta);
    }
    if (msg.tool_calls && msg.tool_calls.length) {
      // Ollama emits tool_calls (complete) on the final chunk; capture them.
      toolCalls.length = 0;
      toolCalls.push(...normalizeToolCalls(msg.tool_calls));
    }
  };

  while (true) {
    let chunk;
    try {
      chunk = await reader.read();
    } catch (err) {
      if (err.name === 'AbortError') throw new Error('Abgebrochen.');
      throw err;
    }
    if (chunk.done) break;
    buffer += decoder.decode(chunk.value, { stream: true });
    let nl;
    while ((nl = buffer.indexOf('\n')) >= 0) {
      const line = buffer.slice(0, nl);
      buffer = buffer.slice(nl + 1);
      handleLine(line);
    }
  }
  handleLine(buffer); // flush trailing line
  return { content: full.trim(), toolCalls };
}

// Coerce Ollama's tool_calls into a stable shape: [{function:{name, arguments}}]
// where arguments is always a plain object (Ollama sometimes sends a string).
function normalizeToolCalls(raw) {
  if (!Array.isArray(raw)) return [];
  const out = [];
  for (const tc of raw) {
    const fn = (tc && tc.function) || {};
    let args = fn.arguments;
    if (typeof args === 'string') {
      try { args = JSON.parse(args); } catch (e) { args = {}; }
    }
    out.push({ function: { name: fn.name, arguments: args || {} } });
  }
  return out;
}

// Run a full agentic turn: stream the model's reply; if it calls tools,
// execute them (via `executeTool(name, args) -> any JSON-serialisable value`),
// feed the results back, and loop until the model answers in plain text (or
// MAX_TOOL_ROUNDS is hit). `onToken` streams the final text answer; `onToolCall`
// fires for each tool invocation so the UI can show what's being looked up.
async function runChat({ system, messages, tools, executeTool, onToken, onToolCall, signal, temperature = 0.6 }) {
  const convo = messages.map((m) => ({ role: m.role, content: m.content }));

  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
    const { content, toolCalls } = await streamChat({
      system, messages: convo, tools, onToken, temperature, signal,
    });
    if (!toolCalls || !toolCalls.length) return content;

    // Append the assistant's tool-call message verbatim so the model sees its
    // own call in the next round, then one tool-result message per call.
    convo.push({
      role: 'assistant',
      content: content || '',
      tool_calls: toolCalls,
    });
    for (const tc of toolCalls) {
      const name = tc.function.name;
      if (onToolCall) onToolCall(name, tc.function.arguments);
      let result;
      try {
        result = await executeTool(name, tc.function.arguments);
      } catch (err) {
        result = { error: err.message || String(err) };
      }
      convo.push({
        role: 'tool',
        tool_name: name,
        content: JSON.stringify(result),
      });
    }
  }
  throw new Error('Maximale Anzahl Tool-Aufrufe erreicht.');
}

// Kurzen Thread-Titel aus dem ersten Schlagabtausch formulieren lassen — wie in
// ChatGPT, statt die ersten 60 Zeichen der Frage abzuschneiden ("Ich habe mich
// bei der Hetzner Online GmbH bewor…"). Ein eigener, winziger Aufruf ohne
// Werkzeuge: Der Chat-Systemprompt würde hier nur stören (er will beraten, nicht
// betiteln). Wirft nicht — schlägt die Titelgenerierung fehl, bleibt der Aufrufer
// einfach beim Fallback-Titel.
async function generateTitle({ userText, assistantText, signal }) {
  const system =
    'Du formulierst kurze Titel für Chat-Verläufe eines Bewerbungs-Assistenten. ' +
    'Antworte mit NICHTS außer dem Titel: 2 bis 5 Wörter, Deutsch, keine ' +
    'Anführungszeichen, kein Punkt am Ende, keine Einleitung. Benenne das Thema ' +
    'konkret (Firma, Stelle oder Anliegen), nicht generisch wie "Frage zur Bewerbung".';
  const inhalt =
    'Nachricht des Nutzers:\n' + String(userText || '').slice(0, 600) +
    '\n\nAntwort des Assistenten:\n' + String(assistantText || '').slice(0, 600) +
    '\n\nTitel:';

  try {
    const { content } = await streamChat({
      system,
      messages: [{ role: 'user', content: inhalt }],
      temperature: 0.2,
      signal,
    });
    // Modelle packen den Titel gern in Anführungszeichen, Sternchen oder eine
    // "Titel:"-Zeile — das wieder abräumen.
    let titel = String(content || '')
      .split('\n').map((z) => z.trim()).filter(Boolean).pop() || '';
    titel = titel
      .replace(/^\s*(titel|title)\s*:\s*/i, '')
      .replace(/^["“„'*#\s]+|["“”'*.\s]+$/g, '')
      .trim();
    if (titel.length > 60) titel = titel.slice(0, 60).trim();
    return titel;
  } catch (err) {
    return '';
  }
}

// Build the German system prompt that grounds the assistant. `context` is the
// lightweight core gathered by the server: { heute, settings, profil }.
// Application data is NOT baked in — the assistant fetches it via tools.
function buildContextPrompt(ctx) {
  const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber';
  const heute = (ctx && ctx.heute) || '';
  const parts = [];
  // The assistant's role/tone is editable (Vorlagen page); the sections below
  // describe the runtime wiring (tools, date, profile) and stay in code.
  parts.push(promptStore.get(ctx && ctx.prompts, 'chat', { name }));
  parts.push(
    '# Werkzeuge\n' +
    'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' +
    'Nutzer nach einer konkreten Bewerbung, Firma, Status, Stellenbeschreibung, ' +
    'Korrespondenz oder einem Termin fragt — rufe niemals Daten aus dem Gedächtnis ' +
    'ab, die du per Werkzeug holen kannst. Lege mehrere benötigte Werkzeuge in ' +
    'einer Runde parallel an. Wenn eine Suche keine Treffer liefert, weise den ' +
    'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).\n\n' +
    'Zusätzlich kannst du mit `web_suche` und `web_seite_lesen` im Internet ' +
    'recherchieren. Nutze das für alles, was nicht in den Bewerbungsdaten steht: ' +
    'Hintergrund zu einer Firma (Produkte, Größe, Standorte, Kultur, aktuelle ' +
    'Nachrichten), Gehaltsspannen, Technologien, Vorbereitung auf ein Gespräch. ' +
    'Typischer Ablauf bei „Was muss ich über Firma X wissen?": erst die Bewerbung ' +
    'per Werkzeug holen (Stelle, Ort, Stellenbeschreibung, quelle_url), dann ' +
    'gezielt im Web nachrecherchieren und beides zusammenführen. Recherchiere ' +
    'nicht ungefragt bei jeder Frage — nur, wenn aktuelles oder externes Wissen ' +
    'die Antwort wirklich besser macht.\n\n' +
    'Bei Web-Rechercheergebnissen: Gib nur wieder, was in den Treffern steht, und ' +
    'nenne die Quelle als URL. Wenn du etwas nicht belegen kannst, sage es — rate ' +
    'nicht. Achte darauf, dass Treffer wirklich die gemeinte Firma betreffen ' +
    '(Namensdopplungen, falscher Standort) und weise auf Zweifel hin.'
  );
  if (heute) {
    parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' +
      'Terminangaben aus Werkzeugen sind UTC-Zeitstempel (ISO, endet auf Z). ' +
      'Rechne in UTC-Zeiten und leite daraus ab, was "heute", "morgen" usw. ist. ' +
      'Zeige dem Nutzer Terminzeiten in seiner lokalen Zeit (Europe/Berlin = UTC+1, ' +
      'bzw. UTC+2 während der Sommerzeit).');
  }

  if (ctx && ctx.profil) {
    parts.push('# Dein Profil (Lebenslauf)\n' + ctx.profil +
      '\nNutze dies, um Antworten auf den Bewerber zuzuschneiden: Wenn der Nutzer ' +
      'nach einer Firma fragt, hebe hervor, welche Anforderungen der Stelle zu ' +
      'seinem Profil passen und wo Lücken bestehen.');
  }

  return parts.join('\n\n');
}

module.exports = {
  isConfigured, streamChat, runChat, buildContextPrompt, generateTitle, MAX_TOOL_ROUNDS,
  // Dynamic so an edit on /einstellungen is reflected without a restart.
  get OLLAMA_MODEL() { return config.ollama().model; },
};