"""ADR-0055 Phase B — DiscoveryCandidate emission from the turn loop.

A ``DiscoveryCandidate`` is **structured evidence** — never a
mutation.  When a turn's audit trail satisfies a deterministic
predicate, an entry is emitted to the discovery candidate stream.
Candidates **never** load into the active teaching corpus; the only
path to corpus extension is the review-gated
``TeachingChainProposal`` (Phase C, not yet built).

Trigger set (Phase B lands the first; the others are stubbed in the
``Literal`` so the structure is stable when later phases add them):

  - ``would_have_grounded`` — the turn fell through to the universal
    "insufficient grounding" disclosure, the classified intent was
    ``CAUSE`` or ``VERIFICATION``, the subject lemma is in the
    ratified cognition pack, and no active chain matched
    ``(subject, intent)``.  A reviewed chain of that subject/intent
    would have grounded the turn.

  - ``successful_comparison`` — open question §5 in ADR-0055; not
    fired in Phase B.

  - ``hedge_acknowledged`` — open question §5 in ADR-0055; not
    fired in Phase B.

  - ``oov_resolved_via_decomp`` — not fired in Phase B.

Determinism contract:

  - ``extract_discovery_candidates`` is a pure function of its
    inputs.
  - ``candidate_id`` is a SHA-256 hash of a canonical JSON encoding
    of the candidate's load-bearing fields; identical inputs always
    produce the identical id.
  - No LLM, no stochastic sampling, no clock-time read.

Trust boundary:

  - This module reads pack + corpus indices and a ``TurnEvent``.
    It never writes to the corpus, the pack, or runtime state.
  - The ``source_turn_trace`` is the upstream ``TurnEvent.trace_hash``
    when present; absent that, the empty string.  Tying every
    candidate to a replayable turn is the load-bearing audit
    property.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from typing import Any, Literal

from generate.intent import IntentTag

# ``chat.pack_grounding`` and ``chat.teaching_grounding`` are
# imported lazily inside ``extract_discovery_candidates`` to break a
# circular import chain when an entry-point (e.g. the CLI) imports
# ``teaching.proposals`` → ``teaching.discovery`` before ``chat``
# has been fully initialized.


DiscoveryTrigger = Literal[
    "would_have_grounded",
    "successful_comparison",
    "hedge_acknowledged",
    "oov_resolved_via_decomp",
]


# ADR-0056 Phase C1: typed claim domain for the contemplation loop.
ClaimDomain = Literal["factual", "relational", "evaluative"]


@dataclass(frozen=True, slots=True)
class EvidencePointer:
    """One unit of admissible evidence used by the contemplation loop.

    Only three source families admit a pointer: reviewed teaching
    corpus chains, ratified pack atoms, and vault entries stamped
    ``EpistemicStatus.COHERENT``.  SPECULATIVE / CONTESTED / FALSIFIED
    vault entries contest but do not contribute as evidence.
    """

    source: Literal["corpus", "pack", "vault_coherent"]
    ref: str
    polarity: Literal["affirms", "falsifies"]
    epistemic_status: str

    def as_dict(self) -> dict[str, Any]:
        return {
            "source": self.source,
            "ref": self.ref,
            "polarity": self.polarity,
            "epistemic_status": self.epistemic_status,
        }

    @classmethod
    def from_dict(cls, payload: dict[str, Any]) -> "EvidencePointer":
        return cls(
            source=payload["source"],
            ref=payload["ref"],
            polarity=payload["polarity"],
            epistemic_status=payload["epistemic_status"],
        )


@dataclass(frozen=True, slots=True)
class SubQuestion:
    """One decomposed sub-question + its outcome (ADR-0056 §SubQuestion).

    ``outcome="gap_recorded"`` is the load-bearing case from Call 1
    in ADR-0056: the sub-question could not be decomposed further so
    the system records the gap and stops.
    """

    sub_id: str
    proposed_subject: str
    proposed_intent: str
    outcome: Literal["grounded", "gap_recorded", "depth_failsafe"]
    evidence: tuple[EvidencePointer, ...] = ()

    def as_dict(self) -> dict[str, Any]:
        return {
            "sub_id": self.sub_id,
            "proposed_subject": self.proposed_subject,
            "proposed_intent": self.proposed_intent,
            "outcome": self.outcome,
            "evidence": [e.as_dict() for e in self.evidence],
        }

    @classmethod
    def from_dict(cls, payload: dict[str, Any]) -> "SubQuestion":
        return cls(
            sub_id=payload["sub_id"],
            proposed_subject=payload["proposed_subject"],
            proposed_intent=payload["proposed_intent"],
            outcome=payload["outcome"],
            evidence=tuple(
                EvidencePointer.from_dict(e) for e in payload.get("evidence", [])
            ),
        )


@dataclass(frozen=True, slots=True)
class DiscoveryCandidate:
    """Structured evidence that a reviewed chain would have helped.

    Phase B emits the Phase-B fields only.  ADR-0056 Phase C1 adds
    typed contemplation fields (``polarity``, ``claim_domain``,
    ``evidence``, ``sub_questions``, ``contemplation_depth``,
    ``recursion_overflow``).  Defaults make a freshly-emitted Phase B
    candidate a trivially-valid un-contemplated C1 candidate.
    """

    candidate_id: str
    proposed_chain: dict[str, Any]
    trigger: DiscoveryTrigger
    source_turn_trace: str
    pack_consistent: bool
    boundary_clean: bool
    review_state: Literal["unreviewed"] = "unreviewed"
    domain: Literal["cognition", "math"] = "cognition"
    # Phase C1 fields.  Defaults preserve byte-equality with Phase B
    # ``as_dict`` output when the candidate has not been contemplated.
    polarity: Literal["affirms", "falsifies", "undetermined"] = "undetermined"
    claim_domain: ClaimDomain = "factual"
    evidence: tuple[EvidencePointer, ...] = ()
    sub_questions: tuple[SubQuestion, ...] = ()
    contemplation_depth: int = 0
    recursion_overflow: bool = False

    def as_dict(self) -> dict[str, Any]:
        out: dict[str, Any] = {
            "candidate_id": self.candidate_id,
            "proposed_chain": self.proposed_chain,
            "trigger": self.trigger,
            "source_turn_trace": self.source_turn_trace,
            "pack_consistent": self.pack_consistent,
            "boundary_clean": self.boundary_clean,
            "review_state": self.review_state,
        }
        if self.domain != "cognition":
            out["domain"] = self.domain
        # Phase C1 fields are emitted only when contemplation has
        # produced non-default content.  This keeps a freshly-emitted
        # Phase B candidate's JSONL line byte-identical to the pre-C1
        # encoding.
        if (
            self.polarity != "undetermined"
            or self.claim_domain != "factual"
            or self.evidence
            or self.sub_questions
            or self.contemplation_depth != 0
            or self.recursion_overflow
        ):
            out["polarity"] = self.polarity
            out["claim_domain"] = self.claim_domain
            out["evidence"] = [e.as_dict() for e in self.evidence]
            out["sub_questions"] = [s.as_dict() for s in self.sub_questions]
            out["contemplation_depth"] = self.contemplation_depth
            out["recursion_overflow"] = self.recursion_overflow
        return out

    @classmethod
    def from_dict(cls, payload: dict[str, Any]) -> "DiscoveryCandidate":
        return cls(
            candidate_id=payload["candidate_id"],
            proposed_chain=payload["proposed_chain"],
            trigger=payload["trigger"],
            source_turn_trace=payload["source_turn_trace"],
            pack_consistent=payload["pack_consistent"],
            boundary_clean=payload["boundary_clean"],
            review_state=payload.get("review_state", "unreviewed"),
            domain=payload.get("domain", "cognition"),
            polarity=payload.get("polarity", "undetermined"),
            claim_domain=payload.get("claim_domain", "factual"),
            evidence=tuple(
                EvidencePointer.from_dict(e) for e in payload.get("evidence", [])
            ),
            sub_questions=tuple(
                SubQuestion.from_dict(s) for s in payload.get("sub_questions", [])
            ),
            contemplation_depth=payload.get("contemplation_depth", 0),
            recursion_overflow=payload.get("recursion_overflow", False),
        )


_TEACHING_INTENT_NAME: dict[IntentTag, str] = {
    IntentTag.CAUSE: "cause",
    IntentTag.VERIFICATION: "verification",
}


def _hash_candidate_id(payload: dict[str, Any]) -> str:
    """Deterministic SHA-256 over a canonical JSON encoding.

    Sorted keys + tight separators keep the hash stable across
    Python runtimes and dict-insertion order.  This is the
    ``candidate_id`` — used both as the on-disk JSONL line key and
    by Phase C to look up the originating candidate.
    """
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def _boundary_clean(turn_event: Any) -> bool:
    """Return True iff the source turn produced no safety/ethics
    refusal and no hedge injection.

    Tolerates events that pre-date the bundled-verdicts era (ADR-0039
    onward) by reading the canonical fields directly.
    """
    refusal_emitted = bool(getattr(turn_event, "refusal_emitted", False) or False)
    hedge_injected = bool(getattr(turn_event, "hedge_injected", False) or False)
    if refusal_emitted or hedge_injected:
        return False
    verdicts = getattr(turn_event, "verdicts", None)
    if verdicts is not None:
        if bool(getattr(verdicts, "refusal_emitted", False) or False):
            return False
        if bool(getattr(verdicts, "hedge_injected", False) or False):
            return False
    return True


def _trace_hash(turn_event: Any) -> str:
    value = getattr(turn_event, "trace_hash", "") or ""
    return str(value)


def extract_discovery_candidates(
    turn_event: Any,
    intent_tag: IntentTag | None,
    intent_subject_lemma: str | None,
    *,
    grounding_source: str | None = None,
) -> tuple[DiscoveryCandidate, ...]:
    """Return zero or more DiscoveryCandidates for a single turn.

    Phase B only fires the ``would_have_grounded`` trigger.  All
    other triggers in the ``DiscoveryTrigger`` Literal are reserved
    for later phases.

    Fires when **every** condition holds (deterministic predicate):

      1. ``grounding_source`` is ``"none"`` or absent — the turn
         fell through to the universal disclosure.
      2. ``intent_tag`` is ``CAUSE`` or ``VERIFICATION`` — the
         intent set the teaching-grounded surface answers.
      3. ``intent_subject_lemma`` is a non-empty pack lemma in the
         ratified cognition pack.
      4. ``(subject_lemma, intent_name)`` is **not** in the active
         corpus — a chain of that shape would have grounded the
         turn but does not exist.

    Order of conditions matters for tests: short-circuit on the
    cheapest predicate first.
    """
    source = (grounding_source or getattr(turn_event, "grounding_source", "none") or "none").lower()
    if source != "none":
        return ()
    if intent_tag is None or intent_tag not in _TEACHING_INTENT_NAME:
        return ()
    if not intent_subject_lemma or not isinstance(intent_subject_lemma, str):
        return ()
    lemma = intent_subject_lemma.strip().lower()
    if not lemma:
        return ()

    from chat.pack_resolver import is_resolvable
    from chat.teaching_grounding import _all_chains_index

    # ADR-0064 — discovery gate uses cross-pack residency (any mounted
    # lexicon pack) AND cross-corpus chain lookup (any registered
    # teaching corpus).  A kinship CAUSE prompt whose subject is in
    # the relations pack but has no relations-chain in the active
    # corpus is now also a discovery signal.
    if not is_resolvable(lemma):
        return ()

    intent_name = _TEACHING_INTENT_NAME[intent_tag]
    if (lemma, intent_name) in _all_chains_index():
        return ()

    # The candidate's proposed_chain is intentionally partial: Phase B
    # can only assert that a chain of this (subject, intent) would
    # have helped.  Connective and object remain null; Phase C is
    # where a complete proposed entry is constructed and review-gated.
    proposed_chain = {
        "subject": lemma,
        "intent": intent_name,
        "connective": None,
        "object": None,
    }

    trace_hash = _trace_hash(turn_event)
    boundary_clean = _boundary_clean(turn_event)
    trigger: DiscoveryTrigger = "would_have_grounded"

    hash_payload = {
        "proposed_chain": proposed_chain,
        "trigger": trigger,
        "source_turn_trace": trace_hash,
    }
    candidate_id = _hash_candidate_id(hash_payload)

    candidate = DiscoveryCandidate(
        candidate_id=candidate_id,
        proposed_chain=proposed_chain,
        trigger=trigger,
        source_turn_trace=trace_hash,
        pack_consistent=True,  # subject is in pack; object is null pending Phase C
        boundary_clean=boundary_clean,
        review_state="unreviewed",
    )
    return (candidate,)


def format_candidate_jsonl(candidate: DiscoveryCandidate) -> str:
    """Serialise to one JSONL line (sorted keys, no trailing newline)."""
    return json.dumps(candidate.as_dict(), sort_keys=True, separators=(",", ":"))


__all__ = [
    "ClaimDomain",
    "DiscoveryCandidate",
    "DiscoveryTrigger",
    "EvidencePointer",
    "SubQuestion",
    "extract_discovery_candidates",
    "format_candidate_jsonl",
]
