"""Lens — main orchestrator implementing the sandwich pipeline.

User Input → Interpretation → LLM → Verification → Governance → Output

The governance step is deterministic: flags trigger policy evaluation,
policy produces an action, the bridge executes it. FORCE_REVISE has
a hard limit of 1 attempt — if the revision still has error-level flags,
it escalates to HARD_STOP.
"""

from __future__ import annotations

from collections.abc import AsyncIterator

from dataclasses import dataclass, field

from aurora_lens.config import LensConfig
from aurora_lens.pef.prompts import build_pef_system_message
from aurora_lens.pef.state import PEFState
from aurora_lens.pef.span import Span
from aurora_lens.interpret.base import ExtractionBackend
from aurora_lens.interpret.pef_updater import update_pef
from aurora_lens.verify.checker import Checker
from aurora_lens.verify.flags import Flag, FlagType
from aurora_lens.govern.decision import GovernanceDecision, InterventionAction
from aurora_lens.govern.canonical_bridge import CanonicalScannerGateBridge
from aurora_lens.govern.stream_gate import (
    BufferedStreamResult,
    ProgressSignal,
    StreamPhase,
    make_governed_chunk_dict,
)
from aurora_lens.governor import StructuralGovernor


@dataclass
class LensResult:
    """Result of processing a single user turn through the lens."""
    response: str                                   # Final response text (post-governance)
    flags: list[Flag]                               # Verification flags (empty = clean)
    pef_snapshot: str                               # PEF state summary after this turn
    turn: int                                       # Turn number
    span: Span                                      # Detected span of user input
    model: str = ""                                 # Model used
    action: InterventionAction = InterventionAction.PASS  # Governance action taken
    decision: GovernanceDecision | None = None       # Full governance decision (if flags)
    original_response: str | None = None            # Pre-intervention response (if modified)
    usage: dict | None = None                       # Token usage from adapter, if available
    self_refused: bool = False                      # True when LLM self-refused (action=PASS but refusal frame detected)


class Lens:
    """The aurora-lens pipeline orchestrator.

    Wires: Interpretation → LLM Adapter → Verification → Governance
    """

    def __init__(
        self,
        config: LensConfig,
        initial_pef: PEFState | None = None,
        session_id: str = "",
    ):
        self._config = config
        if initial_pef is not None:
            self._pef = initial_pef
        else:
            self._pef = PEFState(session_id=session_id)
        if config.extraction_backend is not None:
            self._backend: ExtractionBackend = config.extraction_backend
        else:
            from aurora_lens.interpret.spacy_backend import SpacyBackend
            self._backend = SpacyBackend(model=config.spacy_model)
        self._checker = Checker(self._backend)
        self._governor = StructuralGovernor()
        self._history: list[dict[str, str]] = []

        # Governance bridge
        if config.governance_bridge is not None:
            self._bridge = config.governance_bridge
        else:
            self._bridge = CanonicalScannerGateBridge(audit_path=config.audit_log_path)

    @property
    def pef(self) -> PEFState:
        """Access the current PEF state (read-only access point)."""
        return self._pef

    async def process(self, user_input: str, external_flags: list[Flag] | None = None) -> LensResult:
        """Process a single user turn through the full pipeline.

        1. Advance turn
        2. Interpret user input (extract entities, relationships, update PEF)
        3. Generate PEF context for LLM
        4. Call LLM with PEF-injected system prompt
        5. Verify LLM response against PEF state
        5.5. Governance: evaluate flags, intervene if needed
        6. Return result with governance decision
        """
        # Step 1: Advance turn
        turn = self._pef.advance_turn()

        # Step 2: Interpretation layer (pluggable backend)
        ext_flags = external_flags or []
        if self._config.auto_interpret:
            extraction = await self._backend.extract(user_input, self._pef)
            if extraction.extraction_error:
                # Inadmissible extraction — do not call LLM, return governed failure
                err = extraction.extraction_error
                reason = err.get("reason", "UNKNOWN")
                snippet = (err.get("raw_preview", "") or err.get("snippet", ""))[:150]
                flags = [
                    Flag(
                        flag_type=FlagType.EXTRACTION_FAILED,
                        entity_name="extraction",
                        claim=f"Extraction failed: {reason}",
                        evidence=snippet or str(err),
                        severity="error",
                        extraction_diagnostic=err,
                    )
                ]
                if ext_flags:
                    flags = flags + list(ext_flags)
                decision = await self._bridge.decide(flags, "", self._pef)
                governed_msg = (
                    "Unable to interpret your message. Extraction failed. Please rephrase."
                )
                decision.original_response = ""
                decision.corrected_response = governed_msg
                decision.governed_response = governed_msg
                self._bridge.log_decision(
                    decision,
                    turn=turn,
                    pef_context=self._pef.to_context_summary(),
                    pre_llm=True,
                    pef_snapshot=self._pef.to_dict(),
                )
                self._history.append({"role": "user", "content": user_input})
                self._history.append({"role": "assistant", "content": governed_msg})
                return LensResult(
                    response=governed_msg,
                    flags=flags,
                    pef_snapshot=self._pef.to_context_summary(),
                    turn=turn,
                    span=Span.PRESENT,
                    model="",
                    action=decision.action,
                    decision=decision,
                    original_response=None,
                )
            update_pef(extraction, self._pef)
            detected_span = extraction.span

            # Pre-LLM: unresolvable referents in user input.
            # If the question contains an ambiguous possessive pronoun (e.g. "her"
            # when both Emma and Anna are present), the LLM must not be allowed to
            # guess — that would violate the PEF no-premature-binding axiom.
            # Intervene here before the LLM call so the flag fires regardless of
            # whether the LLM happens to hedge correctly or not.
            if self._config.auto_verify and extraction.ambiguous_referents:
                pronoun_list = ", ".join(f"'{p}'" for p in extraction.ambiguous_referents)
                interp_flags = [
                    Flag(
                        flag_type=FlagType.UNRESOLVED_REFERENT,
                        entity_name=pronoun,
                        claim=f"Pronoun '{pronoun}' cannot be uniquely resolved",
                        evidence=(
                            "Multiple same-category entities are present; "
                            "binding without explicit grounding is not permitted"
                        ),
                        severity="warning",
                    )
                    for pronoun in extraction.ambiguous_referents
                ]
                if ext_flags:
                    interp_flags = interp_flags + list(ext_flags)
                pre_decision = await self._bridge.decide(interp_flags, user_input, self._pef)
                if pre_decision.action != InterventionAction.PASS:
                    clarification = (
                        f"The pronoun {pronoun_list} cannot be uniquely resolved - "
                        f"multiple entities are plausible antecedents. "
                        f"Please specify which entity you are referring to."
                    )
                    pre_decision.original_response = ""
                    pre_decision.corrected_response = clarification
                    pre_decision.governed_response = clarification
                    self._bridge.log_decision(
                        pre_decision,
                        turn=turn,
                        pef_context=self._pef.to_context_summary(),
                        pre_llm=True,
                        pef_snapshot=self._pef.to_dict(),
                    )
                    self._history.append({"role": "user", "content": user_input})
                    self._history.append({"role": "assistant", "content": clarification})
                    return LensResult(
                        response=clarification,
                        flags=interp_flags,
                        pef_snapshot=self._pef.to_context_summary(),
                        turn=turn,
                        span=detected_span,
                        model="",
                        action=pre_decision.action,
                        decision=pre_decision,
                        original_response=None,
                    )

            # Pre-LLM: comparative ambiguity gate.
            # Ask only when the comparand is genuinely underdetermined (2+ candidates).
            # Forced collapse (exactly 1 candidate) and ungrounded (0 candidates) pass silently.
            if self._config.auto_verify and extraction.comparative_ambiguities:
                ca = extraction.comparative_ambiguities[0]  # Ask about first; one question only
                comp_flags = [
                    Flag(
                        flag_type=FlagType.UNRESOLVED_COMPARAND,
                        entity_name=ca.noun,
                        claim=f"'{ca.adjective}' has {len(ca.candidates)} eligible comparands",
                        evidence=(
                            f"Candidates: {', '.join(ca.candidates)}. "
                            "Cannot collapse without explicit comparand."
                        ),
                        severity="warning",
                    )
                ]
                if ext_flags:
                    comp_flags = comp_flags + list(ext_flags)
                comp_decision = await self._bridge.decide(comp_flags, user_input, self._pef)
                if comp_decision.action != InterventionAction.PASS:
                    question = f"{ca.adjective.capitalize()} than whose {ca.noun}?"
                    comp_decision.original_response = ""
                    comp_decision.corrected_response = question
                    comp_decision.governed_response = question
                    self._bridge.log_decision(
                        comp_decision,
                        turn=turn,
                        pef_context=self._pef.to_context_summary(),
                        pre_llm=True,
                        pef_snapshot=self._pef.to_dict(),
                    )
                    self._history.append({"role": "user", "content": user_input})
                    self._history.append({"role": "assistant", "content": question})
                    return LensResult(
                        response=question,
                        flags=comp_flags,
                        pef_snapshot=self._pef.to_context_summary(),
                        turn=turn,
                        span=detected_span,
                        model="",
                        action=comp_decision.action,
                        decision=comp_decision,
                        original_response=None,
                    )

        else:
            detected_span = Span.PRESENT

        # Step 3: PEF context for LLM
        pef_context = self._pef.to_context_summary() if self._config.inject_pef_context else ""

        # Step 4: Call LLM — build messages (system + history + user), adapter is transport-only
        history = self._history[-self._config.max_history_turns * 2:]
        messages: list[dict[str, str]] = []
        if pef_context:
            messages.append({"role": "system", "content": build_pef_system_message(pef_context)})
        if history:
            messages.extend(history)
        messages.append({"role": "user", "content": user_input})

        import logging
        _lens_log = logging.getLogger(__name__)
        _lens_log.info(
            "lens_adapter_generate",
            extra={"adapter": type(self._config.adapter).__name__, "path": "process"},
        )
        adapter_response = await self._config.adapter.generate(messages)

        # Step 5: Verification layer (backend + PEF comparison)
        flags: list[Flag] = []
        if self._config.auto_verify:
            flags = await self._checker.check(adapter_response.text, self._pef, user_input=user_input)
        # Merge external flags (Phase 9) — enforcement identical regardless of source
        if ext_flags:
            flags = list(flags) + list(ext_flags)

        # Step 5.5: Structural governor — evaluate for state-transition admissibility
        governor_verdict = self._governor.evaluate(adapter_response.text, self._pef)

        # Step 5.5a: If governor HALTs and scanner had no flags, synthesize a flag
        if governor_verdict.status != "ADMIT" and not flags:
            flags.append(Flag(
                flag_type=FlagType.UNRESOLVED_STATE_TRANSITION,
                entity_name=governor_verdict.details.get("subject", "process") if governor_verdict.details else "process",
                claim=f"Governor verdict: {governor_verdict.reason}",
                evidence=governor_verdict.details.get("source_sentence", "") if governor_verdict.details else "",
                severity="error",
            ))

        # Step 5.6: Governance — evaluate flags and intervene
        final_response = adapter_response.text
        original_response = None
        decision = None

        if flags:
            decision = await self._bridge.decide(flags, adapter_response.text, self._pef)
            decision.governor_verdict = governor_verdict

            if decision.action != InterventionAction.PASS:
                decision.original_response = adapter_response.text
                final_response = await self._bridge.intervene(
                    decision,
                    self._config.adapter,
                    user_input,
                    pef_context,
                    history if history else None,
                )
                decision.corrected_response = final_response
                original_response = adapter_response.text

                # Log the final decision (no revision loop — bridge dispatches on pathway_id)
                self._bridge.log_decision(
                    decision,
                    turn=turn,
                    pef_context=pef_context,
                    pre_llm=False,
                    pef_snapshot=self._pef.to_dict(),
                )

        else:
            decision = GovernanceDecision(
                action=InterventionAction.PASS,
                flags=[],
                rationale="No verification flags",
                policy=getattr(self._bridge, '_policy', None)
                    and getattr(self._bridge._policy, 'name', 'unknown')
                    or 'unknown',
                governor_verdict=governor_verdict,
            )
            self._bridge.log_decision(
                decision,
                turn=turn,
                pef_context=pef_context,
                pre_llm=False,
                pef_snapshot=self._pef.to_dict(),
            )

        # Ensure exported flags match the final decision state.
        final_flags = decision.flags if getattr(decision, "flags", None) else flags

        # Detect LLM self-refusal: no flags raised but model declined via refusal language.
        self_refused = (
            not final_flags
            and decision.action == InterventionAction.PASS
            and self._checker.is_refusal_frame(adapter_response.text)
        )

        # Step 6: Update conversation history (with the final governed response)
        self._history.append({"role": "user", "content": user_input})
        self._history.append({"role": "assistant", "content": final_response})

        return LensResult(
            response=final_response,
            flags=final_flags,
            pef_snapshot=self._pef.to_context_summary(),
            turn=turn,
            span=detected_span,
            model=adapter_response.model,
            action=decision.action,
            decision=decision,
            original_response=original_response,
            usage=adapter_response.usage,
            self_refused=self_refused,
        )

    async def process_stream(self, user_input: str, external_flags: list[Flag] | None = None) -> AsyncIterator[tuple[str | dict, object]]:
        """Stream completion with post-stream verification. Yields (kind, payload).

        kind is "extraction_failed" | "chunk" | "metadata".
        - extraction_failed: payload is LensResult (return JSON, do not stream)
        - chunk: payload is (chunk_dict, content_delta) for SSE forwarding
        - metadata: payload is aurora dict for final SSE event
        """
        turn = self._pef.advance_turn()
        ext_flags = external_flags or []

        if self._config.auto_interpret:
            extraction = await self._backend.extract(user_input, self._pef)
            if extraction.extraction_error:
                err = extraction.extraction_error
                reason = err.get("reason", "UNKNOWN")
                snippet = (err.get("raw_preview", "") or err.get("snippet", ""))[:150]
                flags = [
                    Flag(
                        flag_type=FlagType.EXTRACTION_FAILED,
                        entity_name="extraction",
                        claim=f"Extraction failed: {reason}",
                        evidence=snippet or str(err),
                        severity="error",
                        extraction_diagnostic=err,
                    )
                ]
                if ext_flags:
                    flags = flags + list(ext_flags)
                decision = await self._bridge.decide(flags, "", self._pef)
                governed_msg = (
                    "Unable to interpret your message. Extraction failed. Please rephrase."
                )
                decision.original_response = ""
                decision.corrected_response = governed_msg
                decision.governed_response = governed_msg
                self._bridge.log_decision(
                    decision,
                    turn=turn,
                    pef_context=self._pef.to_context_summary(),
                    pre_llm=True,
                    pef_snapshot=self._pef.to_dict(),
                )
                self._history.append({"role": "user", "content": user_input})
                self._history.append({"role": "assistant", "content": governed_msg})
                result = LensResult(
                    response=governed_msg,
                    flags=flags,
                    pef_snapshot=self._pef.to_context_summary(),
                    turn=turn,
                    span=Span.PRESENT,
                    model="",
                    action=decision.action,
                    decision=decision,
                    original_response=None,
                )
                yield ("extraction_failed", result)
                return
            update_pef(extraction, self._pef)
            detected_span = extraction.span

            # Pre-LLM: same unresolvable-referent gate as in process().
            if self._config.auto_verify and extraction.ambiguous_referents:
                pronoun_list = ", ".join(f"'{p}'" for p in extraction.ambiguous_referents)
                interp_flags = [
                    Flag(
                        flag_type=FlagType.UNRESOLVED_REFERENT,
                        entity_name=pronoun,
                        claim=f"Pronoun '{pronoun}' cannot be uniquely resolved",
                        evidence=(
                            "Multiple same-category entities are present; "
                            "binding without explicit grounding is not permitted"
                        ),
                        severity="warning",
                    )
                    for pronoun in extraction.ambiguous_referents
                ]
                if ext_flags:
                    interp_flags = interp_flags + list(ext_flags)
                pre_decision = await self._bridge.decide(interp_flags, user_input, self._pef)
                if pre_decision.action != InterventionAction.PASS:
                    clarification = (
                        f"The pronoun {pronoun_list} cannot be uniquely resolved - "
                        f"multiple entities are plausible antecedents. "
                        f"Please specify which entity you are referring to."
                    )
                    pre_decision.original_response = ""
                    pre_decision.corrected_response = clarification
                    pre_decision.governed_response = clarification
                    self._bridge.log_decision(
                        pre_decision,
                        turn=turn,
                        pef_context=self._pef.to_context_summary(),
                        pre_llm=True,
                        pef_snapshot=self._pef.to_dict(),
                    )
                    self._history.append({"role": "user", "content": user_input})
                    self._history.append({"role": "assistant", "content": clarification})
                    result = LensResult(
                        response=clarification,
                        flags=interp_flags,
                        pef_snapshot=self._pef.to_context_summary(),
                        turn=turn,
                        span=detected_span,
                        model="",
                        action=pre_decision.action,
                        decision=pre_decision,
                        original_response=None,
                    )
                    yield ("extraction_failed", result)
                    return

            # Pre-LLM: same comparative ambiguity gate as in process().
            if self._config.auto_verify and extraction.comparative_ambiguities:
                ca = extraction.comparative_ambiguities[0]
                comp_flags = [
                    Flag(
                        flag_type=FlagType.UNRESOLVED_COMPARAND,
                        entity_name=ca.noun,
                        claim=f"'{ca.adjective}' has {len(ca.candidates)} eligible comparands",
                        evidence=(
                            f"Candidates: {', '.join(ca.candidates)}. "
                            "Cannot collapse without explicit comparand."
                        ),
                        severity="warning",
                    )
                ]
                if ext_flags:
                    comp_flags = comp_flags + list(ext_flags)
                comp_decision = await self._bridge.decide(comp_flags, user_input, self._pef)
                if comp_decision.action != InterventionAction.PASS:
                    question = f"{ca.adjective.capitalize()} than whose {ca.noun}?"
                    comp_decision.original_response = ""
                    comp_decision.corrected_response = question
                    comp_decision.governed_response = question
                    self._bridge.log_decision(
                        comp_decision,
                        turn=turn,
                        pef_context=self._pef.to_context_summary(),
                        pre_llm=True,
                        pef_snapshot=self._pef.to_dict(),
                    )
                    self._history.append({"role": "user", "content": user_input})
                    self._history.append({"role": "assistant", "content": question})
                    result = LensResult(
                        response=question,
                        flags=comp_flags,
                        pef_snapshot=self._pef.to_context_summary(),
                        turn=turn,
                        span=detected_span,
                        model="",
                        action=comp_decision.action,
                        decision=comp_decision,
                        original_response=None,
                    )
                    yield ("extraction_failed", result)
                    return

        else:
            detected_span = Span.PRESENT

        pef_context = self._pef.to_context_summary() if self._config.inject_pef_context else ""
        history = self._history[-self._config.max_history_turns * 2:]
        messages: list[dict[str, str]] = []
        if pef_context:
            messages.append({"role": "system", "content": build_pef_system_message(pef_context)})
        if history:
            messages.extend(history)
        messages.append({"role": "user", "content": user_input})

        # ── Phase 1: Private accumulation ─────────────────────────────────────
        #
        # All provider chunks are accumulated into a private buffer.  Nothing
        # is yielded to the user during this phase.  Raw streamed tokens must
        # never be shown directly to the user before lawful release.

        _chunks: list[tuple[dict, str]] = []
        _accumulated: list[str] = []
        _total_bytes = 0
        _stream_truncated = False
        _stream_dropped_chars = 0
        _upstream_stream = None
        _stream_completed = False
        _fallback_model = ""
        _fallback_usage: dict | None = None

        if self._config.stream_emit_progress:
            yield ("progress", ProgressSignal(status="streaming"))

        try:
            _upstream_stream = self._config.adapter.generate_stream(messages)
            async for _chunk_dict, _content_delta in _upstream_stream:
                _delta_bytes = len(_content_delta.encode("utf-8"))
                _max_bytes = self._config.max_stream_bytes
                if _max_bytes > 0 and _total_bytes + _delta_bytes > _max_bytes:
                    _stream_truncated = True
                    _stream_dropped_chars += len(_content_delta)
                    continue
                # Private accumulation only — NOT forwarded to user yet.
                _chunks.append((_chunk_dict, _content_delta))
                _accumulated.append(_content_delta)
                _total_bytes += _delta_bytes
            _stream_completed = True
        except NotImplementedError:
            # Adapter does not support streaming; fall back to generate().
            # The result is treated identically — a private buffer of one chunk.
            import logging
            logging.getLogger(__name__).info(
                "lens_stream_fallback",
                extra={
                    "adapter": type(self._config.adapter).__name__,
                    "reason": "generate_stream not implemented, calling generate",
                },
            )
            logging.getLogger(__name__).info(
                "lens_adapter_generate",
                extra={"adapter": type(self._config.adapter).__name__, "path": "process_stream_fallback"},
            )
            _adapter_response = await self._config.adapter.generate(messages)
            _fallback_model = _adapter_response.model
            _fallback_usage = _adapter_response.usage
            if _adapter_response.text:
                _fb_dict = {"choices": [{"delta": {"content": _adapter_response.text}, "index": 0}]}
                _chunks.append((_fb_dict, _adapter_response.text))
                _accumulated.append(_adapter_response.text)
            _stream_completed = True
        finally:
            if _upstream_stream is not None:
                await _upstream_stream.aclose()
            if not _stream_completed:
                # Provider stream aborted before buffer was complete.
                # Nothing was yielded to the user, so no content leaked.
                # Reason is always "provider_abort": this code path is only
                # reachable when the provider's generate_stream() raises or
                # closes early.  A client disconnect after governed release
                # cannot reach this path because governance is committed
                # (stream_completed=True) before any chunk is yielded.
                # Log the abort.  No return here — if an exception (e.g.
                # CancelledError) caused the abort, it propagates naturally
                # after the finally block and the post-buffer code is skipped.
                _abort_decision = GovernanceDecision(
                    action=InterventionAction.PASS,
                    flags=[],
                    rationale="Stream aborted before buffer complete",
                    policy="unknown",
                )
                self._bridge.log_decision(
                    _abort_decision,
                    turn=turn,
                    stream=True,
                    stream_completed=False,
                    stream_abort_reason="provider_abort",
                    pef_context=self._pef.to_context_summary(),
                )

        # If the provider stream was aborted (non-exception path, e.g. adapter
        # raised without CancelledError), abort was already logged; stop here.
        if not _stream_completed:
            return

        # ── Phase 2: Governance decision ──────────────────────────────────────
        #
        # Run Lens verification on the complete buffered text.  This is
        # identical to the non-streaming governance path — same checker, same
        # governor, same bridge.  Lens is the sole admissibility authority.

        full_text = "".join(_accumulated)

        if self._config.stream_emit_progress:
            yield ("progress", ProgressSignal(status="verifying"))

        flags: list[Flag] = []
        if self._config.auto_verify:
            flags = await self._checker.check(full_text, self._pef, user_input=user_input)
        if ext_flags:
            flags = list(flags) + list(ext_flags)

        governor_verdict = self._governor.evaluate(full_text, self._pef)
        if governor_verdict.status != "ADMIT" and not flags:
            flags.append(Flag(
                flag_type=FlagType.UNRESOLVED_STATE_TRANSITION,
                entity_name=governor_verdict.details.get("subject", "process") if governor_verdict.details else "process",
                claim=f"Governor verdict: {governor_verdict.reason}",
                evidence=governor_verdict.details.get("source_sentence", "") if governor_verdict.details else "",
                severity="error",
            ))

        decision: GovernanceDecision
        _admit_path: bool

        if flags:
            decision = await self._bridge.decide(flags, full_text, self._pef)
            decision.governor_verdict = governor_verdict
            _admit_path = decision.action in (
                InterventionAction.PASS, InterventionAction.SOFT_CORRECT
            )
        else:
            decision = GovernanceDecision(
                action=InterventionAction.PASS,
                flags=[],
                rationale="No verification flags",
                policy=getattr(self._bridge, "_policy", None)
                and getattr(self._bridge._policy, "name", "unknown")
                or "unknown",
                governor_verdict=governor_verdict,
            )
            _admit_path = True

        # ── Phase 3a: NON-ADMIT — suppress buffer, emit governed continuation ─
        #
        # Raw candidate is permanently suppressed.  The Governor
        # produces the lawful governed continuation via enforce().  Forensic
        # event includes blocked_response_hash (candidate) and
        # governed_response_hash (governed output), identical to non-streaming.

        if not _admit_path:
            decision.original_response = full_text  # suppressed candidate
            _governed = await self._bridge.intervene(
                decision,
                self._config.adapter,
                user_input,
                pef_context,
                history if history else None,
            )
            decision.corrected_response = _governed
            decision.governed_response = _governed

            self._bridge.log_decision(
                decision,
                turn=turn,
                stream=True,
                stream_completed=True,
                stream_truncated=_stream_truncated,
                stream_dropped_chars=_stream_dropped_chars,
                pef_context=pef_context,
                pre_llm=False,
                pef_snapshot=self._pef.to_dict(),
            )

            # History records the governed response, not the suppressed candidate.
            self._history.append({"role": "user", "content": user_input})
            self._history.append({"role": "assistant", "content": _governed})

            if self._config.stream_emit_progress:
                yield ("progress", ProgressSignal(status="releasing"))

            # Emit governed continuation as a single "governed_chunk" event.
            # The chunk_dict is constructed from policy — never from the raw buffer.
            _model_hint = _fallback_model or (
                _chunks[0][0].get("model", "") if _chunks else ""
            )
            yield ("governed_chunk", (
                make_governed_chunk_dict(_governed, _model_hint),
                _governed,
            ))

        else:
            # ── Phase 3b: ADMIT — log and release buffered chunks ─────────────
            #
            # Admissibility confirmed.  The private buffer is now released to
            # the user as "chunk" events, preserving the original cadence.

            decision.original_response = full_text
            decision.governed_response = full_text

            self._bridge.log_decision(
                decision,
                turn=turn,
                stream=True,
                stream_completed=True,
                stream_truncated=_stream_truncated,
                stream_dropped_chars=_stream_dropped_chars,
                pef_context=pef_context,
                pre_llm=False,
                pef_snapshot=self._pef.to_dict(),
            )

            self._history.append({"role": "user", "content": user_input})
            self._history.append({"role": "assistant", "content": full_text})

            if self._config.stream_emit_progress:
                yield ("progress", ProgressSignal(status="releasing"))

            for _chunk_dict, _content_delta in _chunks:
                yield ("chunk", (_chunk_dict, _content_delta))

        # ── Phase 4: Metadata ─────────────────────────────────────────────────

        aurora: dict = {
            "governance": decision.action.name,
            "turn": turn,
            "stream_governed": True,   # marks full-buffer governance path
        }
        if decision.action == InterventionAction.SOFT_CORRECT:
            aurora["unverified"] = True
        if decision.cid:
            aurora["audit_id"] = decision.cid
        if (
            decision.action not in (InterventionAction.PASS, InterventionAction.SOFT_CORRECT)
            and decision.forensic_event is not None
        ):
            aurora["forensic_event"] = decision.forensic_event
        if _stream_truncated:
            aurora["stream_truncated"] = True
            aurora["stream_dropped_chars"] = _stream_dropped_chars
        if self._config.include_operator_detail and decision.action != InterventionAction.PASS:
            aurora["flags"] = [f.flag_type.name for f in flags]
            aurora["rationale"] = decision.rationale
            aurora["policy"] = decision.policy
            if decision.governance_note:
                aurora["governance_note"] = decision.governance_note

        # Internal log channel: always populated, popped by proxy before SSE
        # serialisation so they never reach the client.
        # Uses decision.flags (not raw checker flags) to match the audit record.
        aurora["_log_flags"] = [f.flag_type.name for f in decision.flags]
        aurora["_log_policy"] = decision.policy
        aurora["_log_pathway"] = decision.pathway_id
        aurora["_log_commitment_closed"] = decision.commitment_closed

        yield ("metadata", aurora)

    async def seed_history(self, history: list[dict[str, str]]) -> None:
        """Seed PEF and conversation history from prior turns.

        Called for newly-created sessions when a stateless client sends the full
        conversation history in each POST (standard OpenAI pattern). Runs extraction
        on prior user messages to build PEF state without calling the LLM or
        verification — extraction failures are non-fatal and silently skipped.
        """
        for msg in history:
            role = msg.get("role", "")
            content = msg.get("content", "")
            if role == "user" and content and self._config.auto_interpret:
                self._pef.advance_turn()
                try:
                    extraction = await self._backend.extract(content, self._pef)
                    if not extraction.extraction_error:
                        update_pef(extraction, self._pef)
                except Exception:
                    pass  # Seeding failures are non-fatal
            self._history.append({"role": role, "content": content})

    def reset(self) -> None:
        """Reset all state (PEF, history). New conversation."""
        self._pef = PEFState()
        self._history.clear()
