"""Soft preflight sidecar — Ticket #000011.

Model-assisted preflight that augments the deterministic detectors
from #000010 with an LLM-driven shape classifier. Strict guardrail
per ticket §1: produces ONLY soft hints labeled ``SOFT_*``; cannot
create ``PREFLIGHT_OK`` or ``PREFLIGHT_BLOCKED`` without
deterministic support. Hard rule (D1 in
``docs/seven-point-program.md``) preserved.

Default-OFF (``soft_preflight_enabled: False``). Operator opts in
per-call via ``--soft-preflight`` CLI flag or per-policy via
``policy["soft_preflight_enabled"] = True``. The sidecar adds one
short LLM round-trip (~200ms median on the local Hermes endpoint;
constrained generation with max_tokens=128, temperature=0.0).

Output contract: a single ``SoftPreflightHint`` dataclass that
surfaces on the result dict, run-DAG ``preflight_payload`` clause,
& bench rows. Audit-line tail renders as ``· soft: <label>`` so
operators can distinguish soft from hard signals at a glance.

Hard rule (D1): NEVER bypasses or overrides the deterministic
verifier. The hint is advisory only; the verifier doesn't read
sidecar fields.
"""

from __future__ import annotations

import re
import time
from dataclasses import asdict, dataclass

SOFT_PREFLIGHT_VERSION = "soft-preflight-v0.1"

# Constrained enum the model must pick from. Each maps to a soft
# analogue of one #000010 deterministic detector kind. The SOFT_
# prefix is mandatory (see _normalize_label below) so audit replay
# can never confuse a soft hint with a hard verdict.
_SOFT_LABELS = (
    "SOFT_WELL_FORMED",
    "SOFT_FALSE_PREMISE_SUSPECTED",
    "SOFT_CONTRADICTION_SUSPECTED",
    "SOFT_TIME_SENSITIVE",
    "SOFT_SCOPE_AMBIGUOUS",
    "SOFT_OUT_OF_CORPUS_LIKELY",
    "SOFT_BROAD_QUANTIFIER",
    "SOFT_MULTI_HOP_REASONING",
    "SOFT_SUBJECTIVE",
)


@dataclass(frozen=True)
class SoftPreflightHint:
    """One sidecar hint about a question's logical/epistemic shape.

    NEVER enters the verifier proof path. NEVER folds into
    ``governance_policy_hash``. ALWAYS labeled ``SOFT_*`` so the
    distinction from deterministic detector output is explicit at
    every layer (audit-line tails, bench rows, run-DAG payload).
    """

    raw_question: str
    sidecar_version: str  # SOFT_PREFLIGHT_VERSION
    classifier_label: str  # one of _SOFT_LABELS or SOFT_DISABLED
    confidence: float  # 0.0-1.0; advisory only, never gates
    rationale: str  # one-line model explanation
    timestamp_ns: int  # monotonic_ns at hint creation
    model_profile_id: str  # which model produced the hint
    elapsed_ms: float  # LLM call latency for cost tracking

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


def _stub_hint(
    label: str,
    rationale: str,
    *,
    raw_question: str = "",
    model_profile_id: str = "",
    elapsed_ms: float = 0.0,
) -> SoftPreflightHint:
    """Return a default hint when the sidecar is disabled or fails.

    `label` is `SOFT_DISABLED` (sidecar opt-out) or `SOFT_PARSE_FAIL`
    (LLM returned junk). Either way, downstream treats this as an
    advisory non-signal — the deterministic detectors still own the
    ground truth.
    """
    return SoftPreflightHint(
        raw_question=raw_question,
        sidecar_version=SOFT_PREFLIGHT_VERSION,
        classifier_label=label,
        confidence=0.0,
        rationale=rationale,
        timestamp_ns=time.monotonic_ns(),
        model_profile_id=model_profile_id,
        elapsed_ms=elapsed_ms,
    )


# System prompt for the soft preflight LLM call. Constrained
# generation (max_tokens=128, temp=0.0) keeps the output short &
# deterministic-ish. The model picks ONE label from the enum & gives
# a one-line rationale; no chain-of-thought, no multi-line output.
_SOFT_PREFLIGHT_SYSTEM_PROMPT = """You are a question-shape classifier. Your job is to read a single question and classify its shape using ONE of these tokens, plus a one-line rationale.

LABELS:
- SOFT_WELL_FORMED — question is logically well-formed; no obvious shape concerns
- SOFT_FALSE_PREMISE_SUSPECTED — question presupposes a fact that may not be true
- SOFT_CONTRADICTION_SUSPECTED — question asks about contradictory or impossible state
- SOFT_TIME_SENSITIVE — question's answer changes over time; may be stale relative to corpus
- SOFT_SCOPE_AMBIGUOUS — question's intended scope is unclear (which X, when, where)
- SOFT_OUT_OF_CORPUS_LIKELY — question references a private/uploaded/specific document
- SOFT_BROAD_QUANTIFIER — question asks for "all X" or "every Y" without bounded scope
- SOFT_MULTI_HOP_REASONING — answering requires combining multiple non-obvious facts
- SOFT_SUBJECTIVE — question asks for opinion, judgment, or aesthetic preference

OUTPUT FORMAT (strict, one line, no preamble):
LABEL: <one of the labels above>
RATIONALE: <one short sentence>

Do not write anything else. Pick the BEST single label even if multiple apply."""

_LABEL_LINE_RE = re.compile(r"\bLABEL:\s*([A-Z_]+)", re.IGNORECASE)
_RATIONALE_LINE_RE = re.compile(
    r"\bRATIONALE:\s*(.+?)(?:\n|$)", re.IGNORECASE | re.DOTALL
)


def _normalize_label(raw: str) -> str:
    """Coerce model output to a known SOFT_* label.

    Catches model drift: returns SOFT_PARSE_FAIL when the label
    isn't in the enum so the sidecar fails-closed (no soft hint
    rather than a misleading one).
    """
    cleaned = raw.strip().upper()
    # Strip trailing punctuation the model sometimes adds.
    cleaned = re.sub(r"[\s\.\,\;\:\!\?]+$", "", cleaned)
    # Force SOFT_ prefix even if model dropped it.
    if not cleaned.startswith("SOFT_"):
        cleaned = "SOFT_" + cleaned
    if cleaned in _SOFT_LABELS:
        return cleaned
    return "SOFT_PARSE_FAIL"


def _parse_soft_hint_response(raw: str) -> tuple[str, str]:
    """Parse the model's two-line response into ``(label, rationale)``.

    Robust to minor formatting drift (extra whitespace, trailing
    punctuation, missing RATIONALE line). Falls back to
    ``SOFT_PARSE_FAIL`` when even the LABEL line is missing.
    """
    if not raw:
        return "SOFT_PARSE_FAIL", "empty response"
    label_match = _LABEL_LINE_RE.search(raw)
    if not label_match:
        # Fallback — if the model just wrote the label without the
        # "LABEL:" prefix on the first line, try to extract.
        first_line = raw.strip().splitlines()[0] if raw.strip() else ""
        candidate = first_line.split()[0] if first_line else ""
        label = _normalize_label(candidate) if candidate else "SOFT_PARSE_FAIL"
    else:
        label = _normalize_label(label_match.group(1))
    rationale_match = _RATIONALE_LINE_RE.search(raw)
    if rationale_match:
        rationale = rationale_match.group(1).strip()
    else:
        # Some models embed rationale on the same line or use a
        # different separator. Fall back to the second line stripped.
        lines = [L.strip() for L in raw.strip().splitlines() if L.strip()]
        rationale = lines[1] if len(lines) > 1 else "(no rationale)"
    return label, rationale[:200]  # cap rationale length for hashing


def soft_preflight_question(
    question: str,
    *,
    chat_client,  # ChatClient duck-typed
    model_id: str,
    policy: dict | None = None,
    timeout_seconds: float = 5.0,
) -> SoftPreflightHint:
    """Run the soft preflight sidecar on ``question``.

    Calls the configured ChatClient with a constrained-generation
    prompt asking the model to pick ONE label from ``_SOFT_LABELS``
    plus a one-line rationale. Returns a ``SoftPreflightHint``
    that downstream surfaces on result dict / run-DAG / bench rows.

    Failure modes (all return a stub hint, never raise):

    - ``soft_preflight_enabled = False`` (default) — ``SOFT_DISABLED``
    - LLM raises an exception — ``SOFT_PARSE_FAIL``
    - LLM returns unparseable text — ``SOFT_PARSE_FAIL``
    - LLM returns a label not in the enum — ``SOFT_PARSE_FAIL``

    Confidence scoring is intentionally NOT calibrated against
    empirical agreement-rate — see #000011 §9. Initial value is
    ``0.5`` for any successful soft hint, ``0.0`` for stubs. A
    future calibration pass could derive confidence from
    agreement-with-hard-detectors on a labeled fixture.
    """
    policy = policy or {}
    if not bool(policy.get("soft_preflight_enabled", False)):
        return _stub_hint(
            "SOFT_DISABLED",
            "sidecar opted-out via policy",
            raw_question=question,
            model_profile_id=model_id,
        )
    if not question or not question.strip():
        return _stub_hint(
            "SOFT_DISABLED",
            "empty question",
            raw_question=question,
            model_profile_id=model_id,
        )

    t_start = time.monotonic()
    try:
        raw = chat_client.chat_completion(
            messages=[
                {"role": "system", "content": _SOFT_PREFLIGHT_SYSTEM_PROMPT},
                {"role": "user", "content": question},
            ],
            model=model_id,
            temperature=0.0,
            max_tokens=128,
            top_p=1.0,
        )
    except Exception as exc:  # noqa: BLE001 — sidecar must fail-closed
        return _stub_hint(
            "SOFT_PARSE_FAIL",
            f"chat_client raised: {type(exc).__name__}",
            raw_question=question,
            model_profile_id=model_id,
            elapsed_ms=(time.monotonic() - t_start) * 1000.0,
        )
    elapsed_ms = (time.monotonic() - t_start) * 1000.0

    label, rationale = _parse_soft_hint_response(raw)
    confidence = 0.0 if label == "SOFT_PARSE_FAIL" else 0.5

    return SoftPreflightHint(
        raw_question=question,
        sidecar_version=SOFT_PREFLIGHT_VERSION,
        classifier_label=label,
        confidence=confidence,
        rationale=rationale,
        timestamp_ns=time.monotonic_ns(),
        model_profile_id=model_id,
        elapsed_ms=elapsed_ms,
    )
