"""Nachsichtiges Auslesen von JSON aus Modell-Antworten.

Hintergrund. Die Agenten verlangen von den Modellen JSON. Gelegentlich
scheitert das an einer Kleinigkeit, mit Abstand am haeufigsten an einem nicht
maskierten geraden Anfuehrungszeichen mitten in einem Textwert. Typisch ist ein
deutsches Zitat, das mit dem unteren Zeichen geoeffnet und mit einem geraden
Zeichen geschlossen wird:

    {"summary": "Sánchez nannte es „Angriff auf die Integritaet" und kuendigte..."}
                                                                ^ bricht das JSON

Der Standard-Parser bricht dort ab. Die nachgelagerten Notfall-Pfade der Agenten
retten dann oft nur das Bruchstueck bis zu dieser Stelle, was still zu
abgeschnittenen Lagebildern und verlorenen Recherche-Ergebnissen fuehrt
(beobachtet am 31.07.2026 auf Staging, vor allem auf dem EU-Modellweg).

Dieses Modul repariert genau solche Faelle. Wichtig fuer die Sicherheit,
repariert wird ausschliesslich als Rueckfallebene, nachdem der normale Parser
gescheitert ist. Gueltiges JSON wird nie angefasst.
"""
import json
import logging
from typing import Any

logger = logging.getLogger("osint.json_utils")

# Zeichen, die nach einem schliessenden Anfuehrungszeichen eindeutig fuer ein
# echtes Kettenende sprechen. Beim Komma genuegt das nicht, weil auch im
# Fliesstext ein Zitat vor einem Komma stehen kann. Dort wird zusaetzlich
# geprueft, ob danach ueberhaupt ein gueltiger JSON-Wert beginnt.
_STRING_END_FOLLOWERS = "}]:"
_WHITESPACE = " \t\r\n"
_ESCAPE_IN_STRING = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
# Zeichen, mit denen ein JSON-Wert oder Schluessel beginnen kann
_VALUE_STARTERS = '"{['
_LITERALS = ("true", "false", "null")


def _endet_kette(text: str, pos: int) -> bool:
    """Beurteilt, ob das Anfuehrungszeichen an pos eine Zeichenkette beendet."""
    n = len(text)
    j = pos + 1
    while j < n and text[j] in _WHITESPACE:
        j += 1
    if j >= n:
        return True
    if text[j] in _STRING_END_FOLLOWERS:
        return True
    if text[j] != ",":
        return False
    # Komma. Ein echtes Kettenende wird von einem neuen Wert oder Schluessel
    # gefolgt, im Fliesstext dagegen von gewoehnlichen Woertern.
    k = j + 1
    while k < n and text[k] in _WHITESPACE:
        k += 1
    if k >= n:
        return True
    if text[k] in _VALUE_STARTERS or text[k].isdigit() or text[k] == "-":
        return True
    rest = text[k:k + 5]
    return any(rest.startswith(lit) for lit in _LITERALS)


def repair_json_text(text: str) -> str:
    """Maskiert Anfuehrungszeichen und Zeilenumbrueche, die faelschlich roh in
    JSON-Textwerten stehen.

    Verfahren. Der Text wird einmal zeichenweise durchlaufen und mitgefuehrt, ob
    wir uns gerade in einer Zeichenkette befinden. Trifft der Durchlauf innerhalb
    einer Zeichenkette auf ein Anfuehrungszeichen, entscheidet ein Blick auf das
    naechste nicht-leere Zeichen, ob es die Kette wirklich beendet. Steht dort
    Fliesstext statt eines Struktur-Zeichens, wird maskiert statt beendet.
    """
    out: list[str] = []
    in_string = False
    escaped = False
    i = 0
    n = len(text)

    while i < n:
        ch = text[i]

        if escaped:
            out.append(ch)
            escaped = False
            i += 1
            continue

        if ch == "\\":
            out.append(ch)
            escaped = True
            i += 1
            continue

        if ch == '"':
            if not in_string:
                in_string = True
                out.append(ch)
            elif _endet_kette(text, i):
                in_string = False
                out.append(ch)
            else:
                out.append('\\"')
            i += 1
            continue

        if in_string and ch in _ESCAPE_IN_STRING:
            out.append(_ESCAPE_IN_STRING[ch])
            i += 1
            continue

        out.append(ch)
        i += 1

    return "".join(out)


def loads_forgiving(text: str, context: str = "") -> Any | None:
    """json.loads mit Reparatur-Rueckfallebene. Gibt None zurueck, wenn auch die
    Reparatur nichts Verwertbares ergibt."""
    if not text:
        return None
    try:
        return json.loads(text)
    except (json.JSONDecodeError, ValueError):
        pass
    try:
        data = json.loads(repair_json_text(text))
    except (json.JSONDecodeError, ValueError):
        return None
    logger.info("JSON-Reparatur erfolgreich%s (%d Zeichen)", f" [{context}]" if context else "", len(text))
    return data


def _strip_fences(text: str) -> str:
    """Entfernt umschliessende Markdown-Code-Zaeune."""
    cleaned = (text or "").strip()
    if cleaned.startswith("```"):
        nl = cleaned.find("\n")
        if nl != -1:
            cleaned = cleaned[nl + 1:]
    if cleaned.endswith("```"):
        cleaned = cleaned[:-3].rstrip()
    return cleaned.strip()


def _extract_block(text: str, opener: str, closer: str, want: type) -> Any | None:
    """Sucht den ersten vollstaendigen JSON-Block der gewuenschten Art, erst
    unveraendert, dann repariert."""
    if not text:
        return None
    for candidate in (text, repair_json_text(text)):
        decoder = json.JSONDecoder()
        idx = 0
        while True:
            start = candidate.find(opener, idx)
            if start == -1:
                break
            try:
                obj, _ = decoder.raw_decode(candidate, start)
            except (json.JSONDecodeError, ValueError):
                idx = start + 1
                continue
            if isinstance(obj, want):
                return obj
            idx = start + 1
        # Zweiter Durchgang nur, wenn die Reparatur ueberhaupt etwas geaendert hat
        if candidate is not text:
            break
    return None


def _extract_all_blocks(text: str, opener: str, closer: str, want: type) -> list:
    """Alle vollstaendigen JSON-Bloecke der gewuenschten Art, in Reihenfolge.

    Modelle korrigieren sich gelegentlich selbst und haengen nach einer ersten
    Antwort eine zweite, vollstaendigere an, etwa mit einem Hinweis wie "Let me
    redo this properly". Wer nur den ersten Block nimmt, verliert die bessere
    Fassung.
    """
    if not text:
        return []
    for candidate in (text, repair_json_text(text)):
        decoder = json.JSONDecoder()
        gefunden = []
        idx = 0
        while True:
            start = candidate.find(opener, idx)
            if start == -1:
                break
            try:
                obj, ende = decoder.raw_decode(candidate, start)
            except (json.JSONDecodeError, ValueError):
                idx = start + 1
                continue
            if isinstance(obj, want):
                gefunden.append(obj)
                idx = ende
            else:
                idx = start + 1
        if gefunden:
            return gefunden
        if candidate is not text:
            break
    return []


def extract_json_objects(text: str) -> list[dict]:
    """Alle vollstaendigen JSON-Objekte im Text, mit Reparatur-Rueckfall."""
    cleaned = _strip_fences(text)
    direct = loads_forgiving(cleaned)
    if isinstance(direct, dict):
        return [direct]
    return _extract_all_blocks(text, "{", "}", dict)


def extract_json_arrays(text: str) -> list[list]:
    """Alle vollstaendigen JSON-Arrays im Text, mit Reparatur-Rueckfall."""
    cleaned = _strip_fences(text)
    direct = loads_forgiving(cleaned)
    if isinstance(direct, list):
        return [direct]
    return _extract_all_blocks(text, "[", "]", list)


def extract_json_object(text: str) -> dict | None:
    """Erstes vollstaendiges JSON-Objekt im Text, mit Reparatur-Rueckfall."""
    cleaned = _strip_fences(text)
    direct = loads_forgiving(cleaned)
    if isinstance(direct, dict):
        return direct
    return _extract_block(text, "{", "}", dict)


def extract_json_array(text: str) -> list | None:
    """Erstes vollstaendiges JSON-Array im Text, mit Reparatur-Rueckfall."""
    cleaned = _strip_fences(text)
    direct = loads_forgiving(cleaned)
    if isinstance(direct, list):
        return direct
    return _extract_block(text, "[", "]", list)


def looks_truncated(text: str) -> bool:
    """Heuristik, ob ein geretteter Text mitten im Satz abbricht. Dient nur dem
    Protokoll, damit stille Teilverluste sichtbar werden."""
    s = (text or "").rstrip()
    if not s:
        return True
    return s[-1] not in ".!?)]}\"'…:*_`-"
