// Loop 13 (KI- und Aria-Schicht): das AI-Paket und seine Wächter.
//
// Drei Zusagen, als Code statt als Absicht:
//
// 1. **Das Paket entsteht ausschließlich aus dem kanonischen Dokument** —
//    derselben Quelle, aus der Seite, PPTX und Voll-JSON lesen. Es rechnet
//    nichts, es wählt aus.
// 2. **`safe_to_summarize` ist ein hartes Gate, keine Empfehlung.** Steht es
//    auf false, entsteht KEIN Paket — die Verweigerung nennt die blockierenden
//    Prüfungen. Eine Sprachschicht, die trotzdem zusammenfassen will, hat
//    schlicht kein Material.
// 3. **Modell-Antworten werden gegen die Fakten geprüft, nicht geglaubt.**
//    `validateAiSummary` lehnt jede Zahl ab, die nicht als Fact-Wert vorliegt,
//    und jede Entwarnung über einen nicht gerechneten Bereich. Das ist das
//    Exit-Kriterium der Roadmap als ausführbare Prüfung („KI kann keine Zahl
//    erzeugen, die nicht als Fact vorliegt").
//
// Der eigentliche Modell-Aufruf (API, Kosten) gehört NICHT hierher — dieses
// Modul ist die deterministische Grenzfläche davor und danach.
//
// Pure Funktionen, keine I/O.

import { AI_READING_GUIDE, type AiReadingGuide } from './ai-ready'

/** Version des Prompt-Vertrags — jede inhaltliche Änderung erhöht sie. */
export const AI_PROMPT_VERSION = '1.0.0'

/** Version des Paket-Formats — die EINE Quelle, auch fürs Exportprofil-Register. */
export const AI_PACKAGE_VERSION = '1.0.0'

/**
 * Der versionierte System-Prompt der Erklärungsschicht.
 *
 * Er steht IM Paket (nicht in einer Doku daneben): Was nicht in der Datei
 * steht, kommt beim Modell nicht an — dieselbe Doktrin wie AI_READING_GUIDE.
 */
export const AI_SYSTEM_PROMPT_DE = `Du bist die Erklärungsschicht eines deterministischen QAF-Vergleichs.

Regeln, ohne Ausnahme:
1. Du rechnest nicht. Jede Zahl, die du nennst, steht wörtlich in den facts dieses Pakets. Du bildest, rundest und kombinierst keine Zahlen.
2. Jede quantitative Aussage nennt die difference_ids ihres Fakts in Klammern.
3. Bereiche mit state "not_computed" wurden nicht geprüft. Dazu machst du keine Aussage — auch keine beruhigende. "Nicht geprüft" ist die einzige zulässige Formulierung.
4. "empty_verified" heißt: geprüft und nichts gefunden. Nur dort darfst du Entwarnung formulieren.
5. Fakten mit confidence "candidate" gibst du ausschließlich im Konjunktiv wieder.
6. Widersprüchliche Fragen beantwortest du aus den vorliegenden Fakten oder gar nicht — du rechnest nie nach.
7. Du beantwortest nur Fragen zu diesem Vergleich.`

export interface AiPackage {
  ai_package_version: string
  prompt: { version: string; system_de: string }
  safe_to_summarize: true
  numeric_generation_by_ai_prohibited: true
  reading_guide: AiReadingGuide
  facts: unknown[]
  differences: unknown[]
  findings: unknown[]
  analysis_areas: Record<string, { state: string }>
  run_manifest: {
    canonical_schema_version: unknown
    comparison_engine_version: unknown
    canonical_content_hash: unknown
    source_hashes: unknown
  }
}

export type AiPackageResult =
  | { ok: true; aiPackage: AiPackage }
  | { ok: false; reasonsDe: string[] }

/** E-Mail-/Telefon-Muster — die Probe teilt bewusst NICHT die Regel des
 * Erzeugers (check-must-not-share-the-rule): sie sucht Muster im fertigen
 * Serialisat, egal aus welchem Feld sie kämen.
 *
 * Review-Fix #487: die E-Mail-Regex ist bewusst OHNE Punkt in der
 * Domain-Zeichenklasse gebaut (Label + (?:\.Label)+, gebundene Längen) —
 * die frühere Fassung hatte '.' sowohl in der Klasse als auch als Literal
 * und war damit real ReDoS-anfällig (62 s auf 200k adversarialen Zeichen,
 * empirisch gemessen). Die Fläche läuft über Freitext aus hochgeladenen
 * Dateien — genau dort darf ein präpariertes Fragment den Export nicht
 * minutenlang blockieren. */
const PII_PATTERNS: Array<{ nameDe: string; re: RegExp }> = [
  {
    nameDe: 'E-Mail-Adresse',
    re: /[A-Za-z0-9_%+-]{1,64}(?:\.[A-Za-z0-9_%+-]{1,64})*@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})+/,
  },
  { nameDe: 'Telefonnummer', re: /(?<![\d,.])(?:\+|00)[1-9]\d{1,2}[\s\-/]?\d{2,4}[\s\-/]?\d{4,}/ },
]

/** Strukturelle Hex-Token (sha256-Hashes) vor der PII-Probe entfernen —
 * lange Hex-Läufe enthalten mit ~0,5 % je Paket zufällig telefonnummern-
 * förmige Ziffernfolgen und würden den Export fälschlich sperren
 * (Review-Fix #487, empirisch reproduziert). */
function ohneHashTokens(serialisat: string): string {
  return serialisat.replace(/sha256:[0-9a-f]{64}/g, ' ')
}

function asRecord(v: unknown): Record<string, unknown> | null {
  return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
}

/**
 * Das AI-Paket aus dem kanonischen JSON ableiten.
 *
 * Verweigert bei: unlesbarem Dokument, fehlender ai_ready-Fläche,
 * `safe_to_summarize: false` (mit den blockierenden Gründen) und
 * PII-Mustern im Ergebnis. Die Verweigerung ist Teil des Vertrags —
 * ein Paket, das trotz gescheiterter Kern-Prüfungen entsteht, wäre genau
 * die Halbwahrheit, gegen die die Fläche gebaut ist.
 */
export function buildAiPackage(canonicalJson: string): AiPackageResult {
  let doc: Record<string, unknown> | null = null
  try {
    doc = asRecord(JSON.parse(canonicalJson))
  } catch {
    doc = null
  }
  if (doc === null) return { ok: false, reasonsDe: ['Kanonisches Dokument nicht lesbar.'] }

  const aiReady = asRecord(doc.ai_ready)
  if (aiReady === null) return { ok: false, reasonsDe: ['Dokument führt keine ai_ready-Fläche.'] }

  if (aiReady.safe_to_summarize !== true) {
    const blocking = Array.isArray(aiReady.blocking_reasons)
      ? aiReady.blocking_reasons.filter((r): r is string => typeof r === 'string')
      : []
    return {
      ok: false,
      reasonsDe: [
        'Zusammenfassung gesperrt — der Lauf hält seine eigenen Prüfungen nicht aus.',
        ...blocking,
      ],
    }
  }

  const manifest = asRecord(doc.run_manifest) ?? {}
  const areasRaw = asRecord(doc.analysis_areas) ?? {}
  const areas: Record<string, { state: string }> = {}
  for (const [key, val] of Object.entries(areasRaw)) {
    const rec = asRecord(val)
    areas[key] = { state: typeof rec?.state === 'string' ? rec.state : 'not_computed' }
  }

  const aiPackage: AiPackage = {
    ai_package_version: AI_PACKAGE_VERSION,
    prompt: { version: AI_PROMPT_VERSION, system_de: AI_SYSTEM_PROMPT_DE },
    safe_to_summarize: true,
    numeric_generation_by_ai_prohibited: true,
    // Review-Fix #487: der Leitfaden kommt aus der lib DIREKT — das
    // kanonische Schema lässt ihn im ai_ready-Block nicht zu
    // (additionalProperties: false), und ein Paket ohne Leitfaden wäre
    // genau die Datei, bei der beim Modell nichts ankommt.
    reading_guide: AI_READING_GUIDE,
    facts: Array.isArray(doc.facts) ? doc.facts : [],
    differences: Array.isArray(doc.differences) ? doc.differences : [],
    findings: Array.isArray(doc.findings) ? doc.findings : [],
    analysis_areas: areas,
    run_manifest: {
      canonical_schema_version: manifest.canonical_schema_version ?? null,
      comparison_engine_version: manifest.comparison_engine_version ?? null,
      canonical_content_hash: manifest.canonical_content_hash ?? null,
      source_hashes: manifest.source_hashes ?? null,
    },
  }

  // PII-Probe über das fertige Serialisat — nicht über einzelne Felder.
  // Hash-Token vorher raus (strukturell, keine PII, aber Ziffern-Zufall).
  const serialisat = ohneHashTokens(JSON.stringify(aiPackage))
  const treffer = PII_PATTERNS.filter((p) => p.re.test(serialisat)).map((p) => p.nameDe)
  if (treffer.length > 0) {
    return {
      ok: false,
      reasonsDe: treffer.map((t) => `${t} im Paket gefunden — Export verweigert (PII-Filter).`),
    }
  }

  return { ok: true, aiPackage }
}

// ── Antwort-Wächter ─────────────────────────────────────────────────────────

export interface AiSummaryProblem {
  kind: 'summary_blocked' | 'number_not_in_facts' | 'all_clear_on_not_computed'
  detailDe: string
}

/** Zahlen aus einem Satz lesen — deutsche UND technische Schreibweise.
 * (Die ai-ready-interne Fassung liest nur de-DE; eine Modell-Antwort kann
 * beide mischen, deshalb hier beide Muster.) */
export function zahlenInAntwort(text: string): number[] {
  const out: number[] = []
  for (const m of text.matchAll(/\d{1,3}(?:\.\d{3})+(?:,\d+)?|\d+(?:[.,]\d+)?/g)) {
    const roh = m[0]
    const n = roh.includes(',')
      ? Number(roh.replace(/\./g, '').replace(',', '.'))
      : Number(roh.replace(/\.(?=\d{3}\b)/g, ''))
    if (!Number.isNaN(n)) out.push(n)
  }
  return out
}

const ENTWARNUNG = /keine auffälligkeit|unauffällig|alles in ordnung|keine abweichung|kein befund/i
const NICHT_GEPRUEFT = /nicht geprüft|nicht berechnet|not_computed/i

const zahlGleich = (a: number, b: number) => Math.abs(a - b) <= 0.005

/** Zahlenwerte aus den Fakten des Pakets einsammeln (values[].value + Jahre
 * u. ä. bewusst NICHT — was nicht als Wert geführt ist, ist nicht gedeckt). */
function gedeckteZahlen(aiPackage: AiPackage): number[] {
  const out: number[] = []
  for (const fact of aiPackage.facts) {
    const rec = asRecord(fact)
    const values = Array.isArray(rec?.values) ? rec.values : []
    for (const v of values) {
      const vr = asRecord(v)
      if (typeof vr?.value === 'number') out.push(vr.value)
    }
  }
  return out
}

/** Die Kennungen, die das Paket WIRKLICH führt (fact_id, difference_id,
 * finding_id + zitierte difference_ids). Review-Fix #487: die frühere
 * generische Muster-Ausnahme (`Buchstabe-Zahl`) war ein Wächter-Bypass —
 * „M-48213" hätte jede erfundene Zahl getarnt. Jetzt werden nur die exakten
 * Kennungs-Literale des Pakets aus der Antwort entfernt; eine Kennung, die
 * das Paket nicht kennt, bleibt stehen und ihre Ziffern werden geprüft. */
function bekannteKennungen(aiPackage: AiPackage): string[] {
  const out = new Set<string>()
  const sammle = (v: unknown) => {
    if (typeof v === 'string' && v.length >= 3) out.add(v)
  }
  for (const eintrag of [...aiPackage.facts, ...aiPackage.differences, ...aiPackage.findings]) {
    const rec = asRecord(eintrag)
    if (!rec) continue
    sammle(rec.fact_id)
    sammle(rec.difference_id)
    sammle(rec.finding_id)
    if (Array.isArray(rec.difference_ids)) rec.difference_ids.forEach(sammle)
  }
  return [...out].sort((a, b) => b.length - a.length)
}

/** Datums-Token (TT.MM.JJJJ) sind keine Zahlenaussagen — ohne diese Ausnahme
 * lehnte der Wächter jede wahrheitsgemäße Antwort ab, die ein Angebotsdatum
 * nennt (Review-Fix #487). Bewusst eng: nur das volle Datumsmuster. */
function ohneDatumsToken(text: string): string {
  return text.replace(/\b\d{1,2}\.\d{1,2}\.\d{4}\b/g, ' ')
}

/**
 * Eine Modell-Antwort gegen das Paket prüfen — die Abnahme-Grenze.
 *
 * Nicht bestandene Antworten werden verworfen, nicht repariert: eine
 * „korrigierte" Modell-Zahl wäre wieder eine gerechnete.
 */
export function validateAiSummary(text: string, result: AiPackageResult): AiSummaryProblem[] {
  if (!result.ok) {
    return [
      {
        kind: 'summary_blocked',
        detailDe: 'Zusammenfassung gesperrt — es existiert kein freigegebenes Paket.',
      },
    ]
  }
  const problems: AiSummaryProblem[] = []
  const gedeckt = gedeckteZahlen(result.aiPackage)

  // Kennungen tragen Ziffern, sind aber keine Zahlenaussagen — der Prompt
  // VERLANGT sie sogar (Regel 2). Entfernt werden NUR die exakten Literale,
  // die das Paket führt (Review-Fix #487: die frühere Muster-Ausnahme war
  // ein Bypass — jede erfundene Zahl hinter „Buchstabe-" wäre entkommen).
  // Danach fallen Datums-Token heraus (keine Zahlenaussagen).
  let bereinigt = text
  for (const kennung of bekannteKennungen(result.aiPackage)) {
    bereinigt = bereinigt.split(kennung).join(' ')
  }
  bereinigt = ohneDatumsToken(bereinigt)

  // Token-basiert statt über zahlenInAntwort: NUR echte Punkt-Formen mit
  // einer Dreiergruppe („1.234") sind doppeldeutig (Tausender ODER Dezimal)
  // und gelten als gedeckt, wenn EINE Lesart als Fakt-Wert vorliegt
  // (Review-Fix #487). Ein plain „48213" bekommt keine zweite Lesart —
  // fail-closed bleibt die Richtung.
  for (const m of bereinigt.matchAll(/\d{1,3}(?:\.\d{3})+(?:,\d+)?|\d+(?:[.,]\d+)?/g)) {
    const roh = m[0]
    const lesarten: number[] = []
    if (roh.includes(',')) {
      lesarten.push(Number(roh.replace(/\./g, '').replace(',', '.')))
    } else if (/^\d{1,3}\.\d{3}$/.test(roh)) {
      lesarten.push(Number(roh.replace('.', '')), Number(roh))
    } else if (roh.includes('.')) {
      lesarten.push(Number(roh.replace(/\.(?=\d{3}\b)/g, '')))
    } else {
      lesarten.push(Number(roh))
    }
    const gültige = lesarten.filter((l) => !Number.isNaN(l))
    if (gültige.length > 0 && !gültige.some((l) => gedeckt.some((g) => zahlGleich(g, l)))) {
      problems.push({
        kind: 'number_not_in_facts',
        detailDe: `Die Zahl ${gültige[0]} steht in der Antwort, aber in keinem Fakt-Wert.`,
      })
    }
  }

  const nichtGerechnet = Object.entries(result.aiPackage.analysis_areas)
    .filter(([, a]) => a.state === 'not_computed')
    .map(([key]) => key)
  if (nichtGerechnet.length > 0 && ENTWARNUNG.test(text) && !NICHT_GEPRUEFT.test(text)) {
    problems.push({
      kind: 'all_clear_on_not_computed',
      detailDe: `Entwarnende Formulierung, obwohl ${nichtGerechnet.length} Bereich(e) nicht gerechnet wurden (${nichtGerechnet.slice(0, 3).join(', ')}…) — „nicht geprüft" ist die einzige zulässige Aussage.`,
    })
  }

  return problems
}
