"""Pointer-line parser for ``answer_mode="claim_lattice_pointer"``.

The CTI / Clause Lattice Intelligence runtime accepts weak natural-language
pointer clauses from the model and compiles them into structured claim
nodes. Hermes does NOT produce JSON, does NOT produce a formal graph —
Hermes only writes:

    Claim text here. [E12]
    Another claim. [E13,E14]

The runtime owns the structure. The model owns the prose. This stays
inside the model's prose-generation distribution (citation-style is
heavily represented in training; random hex tokens are not), so one-
shot SCHEMA_INVALID failures from JSON drift disappear.

Two-layer id discipline:

    pointer_id     E1, E2, E3, ...     short numeric tag the model writes
    evidence_id    E1f8e4c2a, ...      content-addressed sha256, run-stable

The model's prompt only ever shows pointer ids. The runtime maps each
pointer id back to its content-addressed evidence_id and stores the
content-addressed form in the cache, run-DAG, and audit chain so
provenance is run-stable.

Parser status taxonomy:

    PARSED                line had at least one ``[E\\d+...]`` tag and
                          surviving claim text after the strip
    NO_EVIDENCE_POINTER   line had non-empty prose but no pointer tag —
                          model produced a claim without citing
    EMPTY                 line was bullet/whitespace only (skipped)
"""

from __future__ import annotations

import re
from dataclasses import asdict, dataclass


# Match a bracketed pointer-id list. Examples:
#     [E12]
#     [E12,E13]
#     [E12, E13 ,E14]
#
# The outer brackets are required so the parser refuses fuzzy alternatives
# like (E12) or {E12}. The inner content is a comma-separated list of
# E\d+ tokens with optional whitespace. Pointer ids carry one capture
# group across the WHOLE bracket so the strip is clean.
_BRACKET_RE = re.compile(r"\[\s*(E\d+(?:\s*,\s*E\d+)*)\s*\]")

# Pointer id within a captured bracket payload.
_POINTER_RE = re.compile(r"E\d+")

# Bullet markers (subset of verify._BULLET_RE — kept narrower here so
# this module has no cross-import to verify.py): -, *, +, •, "1.", "2)".
_BULLET_RE = re.compile(r"^\s*(?:[-*+•]|\d+[.)])\s+")

# Trailing connector punctuation often left attached when the parser
# strips the trailing bracket. We keep terminal sentence punctuation
# (".", "!", "?", ":") because that's part of the claim text.
_TRAILING_STRIP = " ,;-—\t"

# Clause/claim separators within a single line (ticket #000048 step 2.4).
# A line that crams several pointered claims onto one row gets segmented
# here; the split is only KEPT if every resulting segment is itself a
# well-pointered claim (or a leading colon-terminated header to drop) —
# see _segment_line — so a legitimate single claim like "The cat is
# black and white [E1]." or "The cast: A, B, C [E1]." is never broken
# (splitting it would create pointer-less prose fragments → guard
# rejects the split). Order in the alternation is irrelevant for
# re.split (it splits on any match). Non-capturing groups so the
# separators are discarded, not re-inserted into the output.
_SEGMENT_SEP_RE = re.compile(
    r";"                                  # semicolon
    r"|(?<=[.!?])\s+(?=[A-Z])"            # sentence boundary: . ! ? then a capital
    r"|\s+[-—–]\s+"                       # spaced dash / en/em dash as a clause join
    r"|\s*\(\d+\)\s*"                     # inline enumeration marker: (1) (2) ...
    r"|\s+(?:and|or|because|although|since|while)\s+"   # conjunctions / subordinators
    r"|,\s+(?![^\[]*\])"                  # comma — but not one inside a [E1, E2] bracket
)


@dataclass(frozen=True)
class ParsedClaim:
    """One line of model output, parsed.

    - ``claim_text``     line with bullet marker stripped and pointer
                          tags removed; the natural-language claim
                          surviving for verification & rendering.
    - ``pointer_ids``    short ids the model wrote, in the order they
                          appeared. Decimal-suffixed E-prefixed
                          (``E1``, ``E12``). Verifier maps each back
                          to the content-addressed evidence_id.
    - ``parse_status``   PARSED / NO_EVIDENCE_POINTER. Empty/whitespace
                          lines are filtered by the parser and never
                          surface as claims.
    - ``raw_line``       the line as the model wrote it, pre-strip.
                          Useful for the audit trail and for the
                          render stage hash.
    """

    claim_text: str
    pointer_ids: list[str]
    parse_status: str
    raw_line: str

    def to_dict(self) -> dict:
        return asdict(self)


def _pointers_in(text: str) -> list[str]:
    """Pointer ids inside every ``[E\\d+...]`` bracket in ``text``, in order."""
    ids: list[str] = []
    for m in _BRACKET_RE.finditer(text):
        ids.extend(_POINTER_RE.findall(m.group(1)))
    return ids


def _make_claim(text_source: str, raw_line: str) -> ParsedClaim:
    """One ``ParsedClaim`` from a whole line or a single segment of one.

    PARSED iff ``text_source`` has at least one ``[E\\d+...]`` bracket;
    otherwise NO_EVIDENCE_POINTER. ``raw_line`` is the originating line
    (carried verbatim for the audit trail / render hash even when this
    claim is one clause of a multi-claim line).
    """
    pointer_ids = _pointers_in(text_source)
    if pointer_ids:
        claim_text = _BRACKET_RE.sub("", text_source)
        claim_text = _BULLET_RE.sub("", claim_text).strip().rstrip(_TRAILING_STRIP)
        return ParsedClaim(
            claim_text=claim_text,
            pointer_ids=pointer_ids,
            parse_status="PARSED",
            raw_line=raw_line,
        )
    return ParsedClaim(
        claim_text=_BULLET_RE.sub("", text_source).strip(),
        pointer_ids=[],
        parse_status="NO_EVIDENCE_POINTER",
        raw_line=raw_line,
    )


def _segment_line(raw_line: str) -> list[str] | None:
    """Split a line into clause/claim segments on ``_SEGMENT_SEP_RE``.

    Returns the list of segments to emit (as separate claims) **only if
    the split is safe**: every non-empty segment must be a well-pointered
    claim, with one exception — a *leading* colon-terminated header with
    no pointer ("Two facts:", "Key points:") is allowed and dropped. If
    the line has ≤1 non-empty segment, or any non-header segment lacks a
    pointer (splitting it would manufacture a pointer-less prose
    fragment from what was a single claim — "The cat is black and white
    [E1]." → ["The cat is black", "white [E1]"]), returns ``None`` so the
    caller keeps the line as one claim.
    """
    parts = [p for p in _SEGMENT_SEP_RE.split(raw_line) if p and p.strip()]
    if len(parts) < 2:
        return None
    # Drop a leading colon-terminated, pointer-less header.
    if not _pointers_in(parts[0]) and parts[0].rstrip().endswith(":"):
        parts = parts[1:]
    if len(parts) < 2:
        return None
    if any(not _pointers_in(p) for p in parts):
        return None
    return parts


def parse_pointer_claims(text: str) -> list[ParsedClaim]:
    """Walk ``text`` line by line; emit ``ParsedClaim``s.

    One claim per non-empty line — *except* a line that crams several
    well-pointered claims onto one row is split into one claim per
    clause (ticket #000048 step 2.4; see :func:`_segment_line` for the
    safety guard), and a continuation line — leading whitespace then a
    lowercase letter, no bullet glyph — is joined back onto the previous
    claim (a wrapped bullet: "- The cell respires aerobically\\n  to
    produce ATP efficiently. [E1]" is one claim, not two).

    Skips empty / whitespace-only lines. A claim with ≥1 ``[E\\d+...]``
    bracket gets ``parse_status="PARSED"``; prose with no bracket gets
    ``"NO_EVIDENCE_POINTER"``. The verifier owns the policy on what to
    do with a NO_EVIDENCE_POINTER claim.

    Pointer-id ordering is preserved as the model wrote them; duplicates
    are NOT deduped here so the verifier can flag repeated cites.
    """
    out: list[ParsedClaim] = []
    for raw_line in text.splitlines():
        # Continuation line (a wrapped bullet/sentence): leading
        # whitespace then a lowercase letter, no bullet glyph. Fold its
        # text + pointers into the preceding claim.
        if out and re.match(r"^\s+[a-z]", raw_line):
            prev = out[-1]
            joined_source = prev.claim_text + " " + raw_line.strip()
            new = _make_claim(joined_source, prev.raw_line + "\n" + raw_line)
            # PARSED if either part had a pointer; pointer order =
            # previous claim's ids then this line's.
            ids = list(prev.pointer_ids) + _pointers_in(raw_line)
            out[-1] = ParsedClaim(
                claim_text=new.claim_text,
                pointer_ids=ids,
                parse_status="PARSED" if ids else "NO_EVIDENCE_POINTER",
                raw_line=prev.raw_line + "\n" + raw_line,
            )
            continue

        # Skip lines that are pure whitespace or only a bullet glyph.
        if not _BULLET_RE.sub("", raw_line).strip():
            continue

        segments = _segment_line(raw_line)
        if segments is not None:
            for seg in segments:
                out.append(_make_claim(seg, raw_line))
        else:
            out.append(_make_claim(raw_line, raw_line))
    return out
