// Volltextsuche über Bewerbungen und ihre E-Mail-Korrespondenz (SQLite FTS5).
//
// Bisher filterte die Startseite nur clientseitig über Firma + Stelle der schon
// gerenderten Zeilen. Damit war unauffindbar, was nur in einer E-Mail steht — der
// Name der Ansprechpartnerin, ein Betreff, eine Zusage im Fließtext. Diese
// Suche indiziert daher beides.
//
// Aufbau: ein FTS5-Index aus *Fragmenten*. Ein Fragment ist entweder eine
// Bewerbung (Firma, Stelle, Notizen, Stellenbeschreibung …) oder eine einzelne
// E-Mail (Absender/Empfänger, Betreff, Text). Jedes E-Mail-Fragment trägt Firma
// und Stelle seiner Bewerbung mit — nur so findet eine Anfrage wie
// "müller bosch" die Bewerbung, obwohl der Name allein in der E-Mail und die
// Firma allein in der Bewerbung steht (FTS5 verknüpft Terme innerhalb *einer*
// Zeile mit UND).
//
// Gepflegt wird der Index von SQL-Triggern, nicht von der Anwendung: Bewerbungen
// und E-Mails werden an vielen Stellen geschrieben (Web-UI, REST-API, IMAP-Abruf,
// KI-Import), und ein vergessener Aufruf würde den Index still veralten lassen.
//
// Die Ergebnisse werden nach Bewerbung gruppiert zurückgegeben: eine Bewerbung
// ist ein Treffer, die Fragmente sagen, *wo* sie getroffen wurde ("in E-Mail von
// Frau Müller"). E-Mails ohne zugeordnete Bewerbung (Postfach) sind eigene Treffer.

// Spaltengewichte für bm25: ein Firmentreffer wiegt schwerer als ein Wort
// irgendwo im Mailtext. Reihenfolge = Spaltenreihenfolge im Index.
const GEWICHTE = [12.0, 8.0, 6.0, 4.0, 1.0]; // firma, stelle, person, betreff, text

// Beschnitt pro Mailtext: eine lange Signatur/Zitatkette bläht den Index auf,
// ohne die Suche besser zu machen.
const MAX_TEXT = 20000;

// Wie viele Bewerbungen eine Suche höchstens zurückgibt.
const LIMIT = 25;

// Markierungen um Fundstellen im Snippet. Bewusst keine HTML-Tags: die Rohtexte
// werden erst im Client escaped und die Marker danach durch <mark> ersetzt, sonst
// wäre der Snippet ein XSS-Vektor (Mailtexte sind Fremdinhalt).
const MARK_START = '\u0002';
const MARK_END = '\u0003';

// Feld-Filter, die der Nutzer tippen kann: "firma:bosch", "von:müller".
const FELD_ALIASE = {
  firma: 'firma', unternehmen: 'firma',
  stelle: 'stelle', job: 'stelle', position: 'stelle',
  person: 'person', von: 'person', an: 'person', name: 'person', kontakt: 'person',
  betreff: 'betreff', subject: 'betreff',
  text: 'text', notiz: 'text', notizen: 'text', mail: 'text', inhalt: 'text',
};

// Der Bewerbungs-Teil eines Fragments (Notizen, Beschreibung, Metadaten) — als
// SQL-Ausdruck, weil ihn Trigger und Backfill gleichermaßen brauchen.
const BEWERBUNG_TEXT = `
  trim(coalesce(%s.notizen, '') || ' ' || coalesce(%s.interne_notizen, '') || ' ' ||
       coalesce(%s.llm_notizen, '') || ' ' || coalesce(%s.ort, '') || ' ' ||
       coalesce(%s.art, '') || ' ' || coalesce(%s.status, '') || ' ' ||
       coalesce(%s.labels, '') || ' ' || coalesce(%s.stellenbeschreibung, '') || ' ' ||
       coalesce(%s.quelle_url, ''))
`;
const bewerbungText = (alias) => BEWERBUNG_TEXT.replace(/%s/g, alias);

// Schema + Trigger + einmaliger Backfill. Idempotent: läuft bei jedem Start.
async function ensureSchema({ exec, dbGet }) {
  await exec(`
    CREATE VIRTUAL TABLE IF NOT EXISTS suche_index USING fts5(
      firma, stelle, person, betreff, text,
      user_id UNINDEXED,
      bewerbung_id UNINDEXED,
      typ UNINDEXED,
      ref_id UNINDEXED,
      meta UNINDEXED,
      tokenize = 'unicode61 remove_diacritics 2'
    )
  `);

  // Trigger. "typ" trennt die beiden Fragmentarten, "ref_id" zeigt auf die
  // Quellzeile — beides brauchen die Trigger, um gezielt zu löschen.
  await exec(`
    CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ai AFTER INSERT ON bewerbungen BEGIN
      INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
      VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
              ${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
    END
  `);

  // Beim Ändern: eigenes Fragment neu aufbauen und Firma/Stelle in den
  // E-Mail-Fragmenten nachziehen (sie tragen sie denormalisiert mit).
  await exec(`
    CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_au AFTER UPDATE ON bewerbungen BEGIN
      DELETE FROM suche_index WHERE typ = 'bewerbung' AND ref_id = old.id;
      INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
      VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
              ${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
      UPDATE suche_index SET firma = coalesce(new.firma, ''), stelle = coalesce(new.stelle, '')
       WHERE typ = 'email' AND bewerbung_id = new.id;
    END
  `);

  await exec(`
    CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ad AFTER DELETE ON bewerbungen BEGIN
      DELETE FROM suche_index WHERE bewerbung_id = old.id;
    END
  `);

  // E-Mails: Firma/Stelle der zugeordneten Bewerbung wandern ins Fragment, damit
  // "müller bosch" trifft. meta trägt, was das Ergebnis anzeigt (Betreff, Absender,
  // Datum, Richtung) — als JSON, unindiziert.
  const emailFragment = () => `
    INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
    SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
           trim(coalesce(new.from_addr, '') || ' ' || coalesce(new.to_addr, '')),
           coalesce(new.subject, ''), substr(coalesce(new.body_text, ''), 1, ${MAX_TEXT}),
           new.user_id, new.bewerbung_id, 'email', new.id,
           json_object('betreff', coalesce(new.subject, ''), 'von', coalesce(new.from_addr, ''),
                       'an', coalesce(new.to_addr, ''), 'datum', coalesce(new.email_date, new.created_at),
                       'richtung', coalesce(new.direction, ''))
      FROM (SELECT 1) LEFT JOIN bewerbungen b ON b.id = new.bewerbung_id;
  `;

  await exec(`
    CREATE TRIGGER IF NOT EXISTS emails_suche_ai AFTER INSERT ON emails BEGIN
      ${emailFragment()}
    END
  `);

  // Eine E-Mail wird u. a. beim Zuordnen im Postfach aktualisiert (bewerbung_id) —
  // dann muss das Fragment mitsamt Firma/Stelle neu entstehen.
  await exec(`
    CREATE TRIGGER IF NOT EXISTS emails_suche_au AFTER UPDATE ON emails BEGIN
      DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
      ${emailFragment()}
    END
  `);

  await exec(`
    CREATE TRIGGER IF NOT EXISTS emails_suche_ad AFTER DELETE ON emails BEGIN
      DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
    END
  `);

  // Backfill: nur beim allerersten Start nach der Einführung (Index noch leer,
  // Daten aber vorhanden). Danach halten die Trigger alles aktuell.
  const leer = await dbGet('SELECT count(*) AS n FROM suche_index');
  if (leer && leer.n === 0) {
    await exec(`
      INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
      SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''), coalesce(b.email_empfaenger, ''), '',
             ${bewerbungText('b')}, b.user_id, b.id, 'bewerbung', b.id, ''
        FROM bewerbungen b
    `);
    await exec(`
      INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
      SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
             trim(coalesce(e.from_addr, '') || ' ' || coalesce(e.to_addr, '')),
             coalesce(e.subject, ''), substr(coalesce(e.body_text, ''), 1, ${MAX_TEXT}),
             e.user_id, e.bewerbung_id, 'email', e.id,
             json_object('betreff', coalesce(e.subject, ''), 'von', coalesce(e.from_addr, ''),
                         'an', coalesce(e.to_addr, ''), 'datum', coalesce(e.email_date, e.created_at),
                         'richtung', coalesce(e.direction, ''))
        FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
    `);
  }
}

// ---------------------------------------------------------------------------
//  Eingabe -> FTS5-MATCH-Ausdruck
// ---------------------------------------------------------------------------

// Was der Nutzer tippen darf:
//   müller            -> Präfixsuche über alle Felder
//   "frau müller"     -> Wortfolge
//   firma:bosch       -> nur in diesem Feld
//   -zeitarbeit       -> schließt Treffer aus
// Alles andere (FTS-Sonderzeichen wie * : ^ NEAR) wird entschärft: jeder Term
// geht in Anführungszeichen in die Query, damit eine Eingabe wie "c++" oder ein
// Doppelpunkt keinen Syntaxfehler auslöst statt zu suchen.
function buildMatch(roh) {
  const q = String(roh == null ? '' : roh).trim();
  if (!q) return null;

  const TOKEN = /(-?)(?:(\w+):)?(?:"([^"]*)"|(\S+))/gu;
  const positiv = [];
  const negativ = [];

  let m;
  while ((m = TOKEN.exec(q)) !== null) {
    const [, minus, feldRoh, phrase, wort] = m;
    const inhalt = (phrase != null ? phrase : wort || '').trim();
    // Ohne Buchstaben/Ziffern bleibt nichts Suchbares übrig (z. B. "--" oder ":").
    const sauber = inhalt.replace(/"/g, ' ').trim();
    if (!/[\p{L}\p{N}]/u.test(sauber)) continue;

    const feld = feldRoh ? FELD_ALIASE[feldRoh.toLowerCase()] : null;
    // Unbekanntes Präfix ("foo:bar") ist kein Feldfilter, sondern Suchtext.
    const suchtext = feldRoh && !feld ? `${feldRoh} ${sauber}` : sauber;
    const term = umlautTerm(suchtext);
    // Ein Feldfilter darf auf einen geklammerten Ausdruck zeigen: {firma} : ("a"* OR "b"*)
    const mitFeld = feld ? `{${feld}} : ${term}` : term;
    (minus ? negativ : positiv).push(mitFeld);
  }

  // Eine reine Ausschlussfrage ("-zeitarbeit") hat keinen Anker — FTS5 braucht
  // mindestens einen positiven Term, und "alles außer X" ist ohnehin keine Suche.
  if (!positiv.length) return null;
  const basis = positiv.join(' AND ');
  return negativ.length ? `${basis} NOT (${negativ.join(' OR ')})` : basis;
}

// Ein Term als FTS5-Ausdruck, mit Präfixsuche ("bosch"* findet auch
// "boschgruppe" — beim Tippen der Normalfall).
//
// Dazu die deutsche Umlaut-Frage: der Index faltet Diakritika (Müller -> muller),
// deshalb findet "muller" bereits "Müller". Die ausgeschriebene Form "mueller"
// aber nicht — und genau die tippt man bei Namen ständig. Also wird zusätzlich
// die Variante mit aufgelöstem ue/oe/ae/ss gesucht, ODER-verknüpft. Falsche
// Auflösungen ("Neuenkirchen" -> "nunkirchen") laufen dabei einfach ins Leere,
// der Originalterm bleibt ja daneben stehen.
function umlautTerm(text) {
  const roh = `"${text}"*`;
  const variante = text
    .replace(/ue/gi, 'u').replace(/oe/gi, 'o').replace(/ae/gi, 'a')
    .replace(/ß/g, 'ss');
  if (variante.toLowerCase() === text.toLowerCase()) return roh;
  return `(${roh} OR "${variante}"*)`;
}

// ---------------------------------------------------------------------------
//  Suche
// ---------------------------------------------------------------------------

const SPALTEN = ['firma', 'stelle', 'person', 'betreff', 'text'];
const FELD_LABEL = { firma: 'Firma', stelle: 'Stelle', person: 'Kontakt', betreff: 'Betreff', text: 'Text' };

// Findet Bewerbungen (und nicht zugeordnete E-Mails) zu einer Suchanfrage.
// `dbAll` ist der Query-Helfer der App, `userId` grenzt hart auf den Besitzer ein
// (der Index ist gemeinsam, die unindizierte user_id-Spalte trennt die Mandanten).
// Rückgabe: nach Score sortierte Treffer, jeder mit Fundstellen + Snippets.
async function suche(dbAll, userId, roh, limit = LIMIT) {
  const match = buildMatch(roh);
  if (!match || !userId) return [];

  const gewichte = GEWICHTE.join(', ');
  const snippets = SPALTEN
    .map((_, i) => `snippet(suche_index, ${i}, '${MARK_START}', '${MARK_END}', '…', 12) AS s_${i}`)
    .join(', ');

  // Fragmente laden (mehrere je Bewerbung möglich), das Gruppieren macht JS —
  // in SQL ginge es nur mit einem zweiten Durchlauf über die FTS-Tabelle.
  let rows;
  try {
    rows = await dbAll(
      `SELECT bewerbung_id, typ, ref_id, meta, ${snippets},
              bm25(suche_index, ${gewichte}) AS score
         FROM suche_index
        WHERE suche_index MATCH ? AND user_id = ?
        ORDER BY score
        LIMIT ?`,
      [match, userId, limit * 4] // Puffer: mehrere Fragmente fallen auf eine Bewerbung zusammen
    );
  } catch (e) {
    // Eine Eingabe, die FTS5 trotz Entschärfung nicht parst, ist kein Serverfehler,
    // sondern schlicht kein Treffer.
    if (/fts5|syntax/i.test(e.message)) return [];
    throw e;
  }
  if (!rows.length) return [];

  // Fragmente -> Treffer je Bewerbung. Nicht zugeordnete E-Mails bleiben einzeln.
  const treffer = new Map();
  for (const r of rows) {
    const key = r.bewerbung_id ? `b${r.bewerbung_id}` : `e${r.ref_id}`;
    if (!treffer.has(key)) {
      treffer.set(key, {
        typ: r.bewerbung_id ? 'bewerbung' : 'email',
        bewerbung_id: r.bewerbung_id || null,
        email_id: r.bewerbung_id ? null : r.ref_id,
        score: r.score,
        fundstellen: [],
      });
    }
    const t = treffer.get(key);
    t.score = Math.min(t.score, r.score); // bm25: kleiner = besser
    const fund = fundstelle(r);
    if (fund) t.fundstellen.push(fund);
  }

  const liste = [...treffer.values()].sort((a, b) => a.score - b.score).slice(0, limit);
  await ergaenzeBewerbungen(dbAll, userId, liste);
  for (const t of liste) t.fundstellen = t.fundstellen.slice(0, 3);
  return liste;
}

// Aus einem Fragment die beste Fundstelle bauen: die Spalte mit Markierung im
// Snippet (FTS5 liefert für Spalten ohne Treffer den Anfang des Textes zurück).
function fundstelle(row) {
  for (let i = 0; i < SPALTEN.length; i++) {
    const s = row[`s_${i}`];
    if (!s || s.indexOf(MARK_START) === -1) continue;
    const meta = row.meta ? sicherJson(row.meta) : null;
    return {
      quelle: row.typ,          // 'bewerbung' | 'email'
      feld: SPALTEN[i],
      feld_label: FELD_LABEL[SPALTEN[i]],
      snippet: s,
      email: meta && row.typ === 'email' ? meta : null,
    };
  }
  return null;
}

function sicherJson(s) {
  try { return JSON.parse(s); } catch (e) { return null; }
}

// Snippet ohne die Fundstellen-Marker — für Verbraucher, die nichts hervorheben
// (der KI-Chat etwa: Steuerzeichen im Tool-Ergebnis würden das Modell nur irritieren).
function ohneMarker(s) {
  return String(s == null ? '' : s).split(MARK_START).join('').split(MARK_END).join('');
}

// Die Anzeigedaten der getroffenen Bewerbungen nachladen (der Index hält nur
// Suchtext). Eine Abfrage für alle Treffer.
async function ergaenzeBewerbungen(dbAll, userId, liste) {
  const ids = liste.filter((t) => t.bewerbung_id).map((t) => t.bewerbung_id);
  if (!ids.length) return;
  const rows = await dbAll(
    `SELECT id, firma, stelle, status, datum, art FROM bewerbungen
      WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
    [userId, ...ids]
  );
  const byId = new Map(rows.map((r) => [r.id, r]));
  for (const t of liste) {
    const b = t.bewerbung_id ? byId.get(t.bewerbung_id) : null;
    if (b) t.bewerbung = b;
  }
  // Eine Bewerbung, die es nicht mehr gibt (Index hinkt hinterher), fällt raus.
  for (let i = liste.length - 1; i >= 0; i--) {
    if (liste[i].typ === 'bewerbung' && !liste[i].bewerbung) liste.splice(i, 1);
  }
}

module.exports = {
  ensureSchema, suche, buildMatch, ohneMarker,
  MARK_START, MARK_END, LIMIT, FELD_ALIASE,
};
