# SPDX-License-Identifier: Apache-2.0
"""Claims: assertive statement detection.

Extracts assertive claims from text using regex pattern matching.
This is a strict subset of governor.claim_signals — assertive and
hedged statements only. Date/entity/quantity extraction belongs
to the governor runtime, not the kernel.

Compatibility contract: any text that nlai classifies as ASSERTIVE
must also be classified as assertive by governor's SignalExtractor.
The reverse is NOT required (governor may extract more).
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any

from .evidence import Evidence


# Claim status
STATUS_UNSUPPORTED = "unsupported"
STATUS_SUPPORTED = "supported"
STATUS_CONTESTED = "contested"

# Claim strength
STRENGTH_ASSERTIVE = "assertive"
STRENGTH_HEDGED = "hedged"
VALID_STRENGTHS = frozenset({STRENGTH_ASSERTIVE, STRENGTH_HEDGED})


# ---------------------------------------------------------------------------
# Pre-compiled patterns (module-level constants, identical to governor)
# ---------------------------------------------------------------------------

# Assertive language — same pattern as governor.claim_signals.ASSERTIVE_PATTERN
ASSERTIVE_PATTERN = re.compile(
    r"\b(definitely|certainly|clearly|undoubtedly|obviously|"
    r"was acquired|acquired in|founded in|established in|"
    r"is located|is headquartered|died in|born in)\b",
    re.IGNORECASE,
)

# Hedged language — weak claims that are still claims
HEDGED_PATTERN = re.compile(
    r"\b(I think|I believe|probably|might be|could be|"
    r"perhaps|possibly|it seems|likely|unlikely|"
    r"may have|might have|appears to)\b",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class Claim:
    """An extracted claim from text.

    text: the matched phrase or surrounding sentence.
    strength: "assertive" or "hedged".
    status: starts as "unsupported" — evidence promotes to "supported".
            "supported" = evidence record(s) attached, NOT independently validated.
            "contested" = conflicting claims detected; sticky until human resolution.
    span: (start, end) character offsets in original text.
    evidence: attached evidence records (opaque — kernel records, doesn't validate).
    conflicts_with: texts of contradicting claims.
    """

    text: str
    strength: str
    status: str = STATUS_UNSUPPORTED
    span: tuple[int, int] = (0, 0)
    evidence: tuple[Evidence, ...] = ()
    conflicts_with: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        if self.strength not in VALID_STRENGTHS:
            raise ValueError(
                f"Invalid strength {self.strength!r}; "
                f"must be one of {sorted(VALID_STRENGTHS)}"
            )

    def to_dict(self) -> dict[str, Any]:
        # Canonical sorted order for stable hashes
        sorted_evidence = sorted(
            [e.to_dict() for e in self.evidence],
            key=lambda e: (e["kind"], e["reference"], e["source"]),
        )
        sorted_conflicts = sorted(self.conflicts_with)
        return {
            "text": self.text,
            "strength": self.strength,
            "status": self.status,
            "span": list(self.span),
            "evidence": sorted_evidence,
            "conflicts_with": sorted_conflicts,
        }

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> Claim:
        evidence_dicts = d.get("evidence", [])
        evidence = tuple(Evidence.from_dict(e) for e in evidence_dicts)
        conflicts = tuple(d.get("conflicts_with", []))
        return cls(
            text=d["text"],
            strength=d["strength"],
            status=d.get("status", STATUS_UNSUPPORTED),
            span=tuple(d.get("span", [0, 0])),
            evidence=evidence,
            conflicts_with=conflicts,
        )


def attach_evidence(claim: Claim, evidence: Evidence) -> Claim:
    """Attach evidence record, promote UNSUPPORTED → SUPPORTED.

    CONTESTED stays CONTESTED — evidence doesn't resolve contests.
    Returns new Claim (frozen). Idempotent: duplicate evidence ignored.
    """
    # Dedupe by value equality on frozen dataclass
    if evidence in claim.evidence:
        return claim

    new_evidence = claim.evidence + (evidence,)

    # Status transition: UNSUPPORTED → SUPPORTED, CONTESTED stays CONTESTED
    new_status = claim.status
    if claim.status == STATUS_UNSUPPORTED:
        new_status = STATUS_SUPPORTED

    return Claim(
        text=claim.text,
        strength=claim.strength,
        status=new_status,
        span=claim.span,
        evidence=new_evidence,
        conflicts_with=claim.conflicts_with,
    )


def contest_claim(claim: Claim, conflicting_text: str) -> Claim:
    """Mark claim as CONTESTED, record the conflicting text.

    Returns new Claim (frozen). Idempotent: duplicate conflict ignored.
    conflicting_text must be non-empty.
    """
    if not conflicting_text:
        raise ValueError("conflicting_text must be non-empty")

    # Dedupe by string equality
    if conflicting_text in claim.conflicts_with:
        return claim

    new_conflicts = claim.conflicts_with + (conflicting_text,)

    return Claim(
        text=claim.text,
        strength=claim.strength,
        status=STATUS_CONTESTED,
        span=claim.span,
        evidence=claim.evidence,
        conflicts_with=new_conflicts,
    )


# Sentence boundary pattern — split on . ! ? or newline, keeping it simple.
# Does not split on abbreviations like "Dr." or "U.S." — that's NLP territory.
_SENTENCE_BOUNDARY = re.compile(r"[.!?\n]+")


def _sentence_around(text: str, match_start: int, match_end: int) -> tuple[str, int, int]:
    """Find the sentence containing a match. Returns (sentence, start, end).

    Walks backward/forward from the match to find sentence boundaries.
    Falls back to the full text if no boundaries found.
    """
    # Walk backward to find sentence start
    sent_start = 0
    for m in _SENTENCE_BOUNDARY.finditer(text, 0, match_start):
        # The sentence starts after the boundary + any whitespace
        candidate = m.end()
        # Skip whitespace after boundary
        while candidate < len(text) and text[candidate] in " \t":
            candidate += 1
        sent_start = candidate

    # Walk forward to find sentence end
    boundary = _SENTENCE_BOUNDARY.search(text, match_end)
    if boundary:
        sent_end = boundary.start()
    else:
        sent_end = len(text)

    # Strip trailing whitespace from sentence
    sentence = text[sent_start:sent_end].strip()
    # Adjust span to match stripped text
    actual_start = sent_start
    while actual_start < sent_end and text[actual_start] in " \t":
        actual_start += 1
    actual_end = sent_end
    while actual_end > actual_start and text[actual_end - 1] in " \t":
        actual_end -= 1

    return sentence, actual_start, actual_end


def extract_claims(text: str) -> list[Claim]:
    """Extract assertive and hedged claims from text.

    Returns a list of Claim objects with sentence-level text and spans.
    The claim text is the surrounding sentence, not just the keyword.
    This gives contradiction detection enough context to work with.

    When multiple keywords appear in one sentence, the sentence appears
    once with the strongest strength (assertive > hedged).
    """
    # Collect all keyword matches with their sentence spans
    # span → strongest strength seen for that sentence
    sentence_map: dict[tuple[int, int], tuple[str, str]] = {}  # span → (sentence, strength)

    # Assertive claims first (higher priority)
    for m in ASSERTIVE_PATTERN.finditer(text):
        sentence, sent_start, sent_end = _sentence_around(text, m.start(), m.end())
        span = (sent_start, sent_end)
        if span not in sentence_map or sentence_map[span][1] != STRENGTH_ASSERTIVE:
            sentence_map[span] = (sentence, STRENGTH_ASSERTIVE)

    # Hedged claims — only add if sentence not already seen
    for m in HEDGED_PATTERN.finditer(text):
        sentence, sent_start, sent_end = _sentence_around(text, m.start(), m.end())
        span = (sent_start, sent_end)
        if span not in sentence_map:
            sentence_map[span] = (sentence, STRENGTH_HEDGED)

    # Build claims sorted by span position
    claims: list[Claim] = []
    for span in sorted(sentence_map):
        sentence, strength = sentence_map[span]
        claims.append(Claim(text=sentence, strength=strength, span=span))

    return claims


def _count_keyword_matches(text: str) -> tuple[int, int]:
    """Count raw keyword matches (assertive, hedged) for scoring.

    Returns (assertive_count, hedged_count). Counts individual keyword
    hits, not deduplicated sentence-level claims. This keeps the scoring
    semantics from 0.1.0 stable despite sentence-level claim extraction.
    """
    assertive = len(ASSERTIVE_PATTERN.findall(text))
    hedged = len(HEDGED_PATTERN.findall(text))
    return assertive, hedged


def assertiveness_score(text: str) -> float:
    """Score text assertiveness from 0.0 (no claims) to 1.0 (highly assertive).

    Same scoring semantics as governor.claim_signals.assertiveness_score.
    Counts individual keyword matches, not sentence-level claims.
    """
    assertive_count, hedged_count = _count_keyword_matches(text)
    total = assertive_count + hedged_count
    if total == 0:
        return 0.0

    # Assertive claims contribute full weight, hedged claims half
    weighted = assertive_count + (hedged_count * 0.5)

    # Normalize: 5+ weighted claims = score 1.0
    return min(weighted / 5.0, 1.0)
