"""
bayes_analiz.types — Core data types for the Bayes Lens (Lens #4 of 7).

Implements a Bayesian inference / probability update instrument for the
multi-lens analytical apparatus defined in AGENTS.md §4.1.

  Lens: Bayes | Faculty: Kalb | Domain: Bayesian inference / probability update

Sorts implemented (mapping to AGENTS.md Appendix C):
  - Hypothesis     — A proposition whose truth-status is being evaluated
  - Evidence       — An observation that updates hypothesis probability
  - BayesModel     — A complete Bayesian model (hypotheses + evidence + priors)
  - PosteriorEntry — A single posterior update record

Governing axioms enforced at construction:
  AX21: Tecelli degrees are continuous (ℝ≥₀), not binary
  AX22: Unreachable supremum — no score reaches 1.0
  T6/KV₄: Convergence bound — all scores in [0, 1)
  AX63:  Tesanüd/İnfirâd asymmetry — affirmations compose super-additively,
         denials remain isolated
  AX57:  Transparency — every output must disclose its epistemic status

KV₇ compliance: This module imports ONLY from the standard library.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional


# ---------------------------------------------------------------------------
# Score clamping — T6/KV₄: strict upper bound < 1.0
# ---------------------------------------------------------------------------

def clamp_score(value: float) -> float:
    """Clamp a score to [0, 0.9999].

    Per T6 (Convergence Bound) and KV₄ (Ne Ayn Ne Gayr):
    no instrument achieves convergence = 1.0.
    """
    return min(max(value, 0.0), 0.9999)


# ---------------------------------------------------------------------------
# Enumerations
# ---------------------------------------------------------------------------

class EvidenceType(Enum):
    """Classification of evidence per its epistemic effect."""
    CONFIRMING = "confirming"        # Raises posterior for target hypothesis
    DISCONFIRMING = "disconfirming"  # Lowers posterior for target hypothesis
    NEUTRAL = "neutral"              # No significant effect on posterior
    MIXED = "mixed"                  # Affects multiple hypotheses differently


class HypothesisStatus(Enum):
    """Evaluation status of a hypothesis."""
    UNTESTED = "untested"            # No evidence evaluated yet
    SUPPORTED = "supported"          # Posterior > prior after evidence
    WEAKENED = "weakened"            # Posterior < prior after evidence
    INDETERMINATE = "indeterminate"  # Evidence mixed / inconclusive


class CompositionMode(Enum):
    """How multiple evidence items compose — AX63 formalisation.

    Per AX63 (Tesanüd/İnfirâd asymmetry):
      - TESANUD: Independent affirmations compound super-additively
      - INFIRAD: Denials remain isolated — do NOT compound
    """
    TESANUD = "tesanud"       # Super-additive composition (positive evidence)
    INFIRAD = "infirad"       # Isolated / non-composable (negative evidence)
    INDEPENDENT = "independent"  # Neutral — no composition effect


# ---------------------------------------------------------------------------
# Core Data Types
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class Hypothesis:
    """A proposition whose truth-status is evaluated via Bayesian update.

    Attributes:
        name: Unique identifier for the hypothesis.
        description: Natural-language statement of the hypothesis.
        prior: Prior probability in (0, 1). Must be > 0 and < 1.
               Per AX21, degrees are continuous; per AX22/T6, never 0 or 1.
    """
    name: str
    description: str = ""
    prior: float = 0.5

    def __post_init__(self) -> None:
        if not self.name or not self.name.strip():
            raise ValueError("Hypothesis.name must be non-empty")
        if not (0.0 < self.prior < 1.0):
            raise ValueError(
                f"Hypothesis.prior must be in (0, 1), got {self.prior}. "
                "AX22/T6: extremes 0.0 and 1.0 are unreachable."
            )


@dataclass(frozen=True)
class Evidence:
    """An observation that updates hypothesis probabilities.

    Attributes:
        name: Unique identifier for this evidence item.
        description: Natural-language description of the evidence.
        evidence_type: Classification per EvidenceType enum.
        likelihood: P(E|H) — probability of observing this evidence
                    given the hypothesis is true. In (0, 1).
        likelihood_complement: P(E|¬H) — probability of observing this
                               evidence given the hypothesis is false. In (0, 1).
        source_independent: Whether this evidence source is independent of
                           other sources. Required True for tesanüd (KV₇/AX63).
    """
    name: str
    likelihood: float
    likelihood_complement: float
    evidence_type: EvidenceType = EvidenceType.NEUTRAL
    description: str = ""
    source_independent: bool = True

    def __post_init__(self) -> None:
        if not self.name or not self.name.strip():
            raise ValueError("Evidence.name must be non-empty")
        if not (0.0 < self.likelihood < 1.0):
            raise ValueError(
                f"Evidence.likelihood must be in (0, 1), got {self.likelihood}. "
                "AX22/T6: extremes are unreachable."
            )
        if not (0.0 < self.likelihood_complement < 1.0):
            raise ValueError(
                f"Evidence.likelihood_complement must be in (0, 1), "
                f"got {self.likelihood_complement}. AX22/T6: extremes are unreachable."
            )

    @property
    def bayes_factor(self) -> float:
        """Bayes factor K = P(E|H) / P(E|¬H).

        K > 1 → evidence supports H (confirming)
        K < 1 → evidence weakens H (disconfirming)
        K ≈ 1 → evidence is neutral
        """
        return self.likelihood / self.likelihood_complement


@dataclass(frozen=True)
class PosteriorEntry:
    """Record of a single posterior update.

    Attributes:
        hypothesis_name: Which hypothesis was updated.
        evidence_name: Which evidence caused the update.
        prior: Probability before this update.
        posterior: Probability after this update. Always in (0, 1).
        bayes_factor: The Bayes factor used for this update.
        composition_mode: How this update composes with others (AX63).
    """
    hypothesis_name: str
    evidence_name: str
    prior: float
    posterior: float
    bayes_factor: float
    composition_mode: CompositionMode = CompositionMode.INDEPENDENT

    def __post_init__(self) -> None:
        if not (0.0 < self.posterior < 1.0):
            raise ValueError(
                f"PosteriorEntry.posterior must be in (0, 1), got {self.posterior}. "
                "AX22/T6: extremes are unreachable."
            )


@dataclass
class BayesModel:
    """A complete Bayesian model: hypotheses + evidence + update trail.

    This is the primary container for Bayesian analysis. It holds:
      - A set of hypotheses with priors
      - A set of evidence items
      - A record of all posterior updates performed

    Design constraints (AGENTS.md §4.2):
      - All scores clamped to [0, 1) per T6/KV₄
      - No imports from other lenses (KV₇)
      - Supports the tesanüd/infirâd asymmetry (AX63)

    Attributes:
        name: Model identifier.
        hypotheses: Map of hypothesis name → Hypothesis.
        evidence_items: Map of evidence name → Evidence.
        updates: Chronological list of posterior update records.
        current_posteriors: Map of hypothesis name → current posterior.
                          Tracks the latest probability for each hypothesis.
    """
    name: str
    hypotheses: Dict[str, Hypothesis] = field(default_factory=dict)
    evidence_items: Dict[str, Evidence] = field(default_factory=dict)
    updates: List[PosteriorEntry] = field(default_factory=list)
    current_posteriors: Dict[str, float] = field(default_factory=dict)

    def add_hypothesis(self, h: Hypothesis) -> None:
        """Register a hypothesis and initialise its posterior to its prior."""
        if h.name in self.hypotheses:
            raise ValueError(f"Hypothesis '{h.name}' already registered")
        self.hypotheses[h.name] = h
        self.current_posteriors[h.name] = h.prior

    def add_evidence(self, e: Evidence) -> None:
        """Register an evidence item."""
        if e.name in self.evidence_items:
            raise ValueError(f"Evidence '{e.name}' already registered")
        self.evidence_items[e.name] = e

    def update(self, hypothesis_name: str, evidence_name: str) -> PosteriorEntry:
        """Apply Bayesian update: compute posterior from current prior and evidence.

        Uses Bayes' theorem:
          P(H|E) = P(E|H) · P(H) / [P(E|H) · P(H) + P(E|¬H) · P(¬H)]

        The result is clamped to (0.0001, 0.9999) per AX22/T6 — the
        supremum is unreachable.

        Args:
            hypothesis_name: Must be a registered hypothesis.
            evidence_name: Must be a registered evidence item.

        Returns:
            PosteriorEntry recording this update.

        Raises:
            KeyError: If hypothesis or evidence not registered.
        """
        if hypothesis_name not in self.hypotheses:
            raise KeyError(f"Unknown hypothesis: '{hypothesis_name}'")
        if evidence_name not in self.evidence_items:
            raise KeyError(f"Unknown evidence: '{evidence_name}'")

        ev = self.evidence_items[evidence_name]
        prior = self.current_posteriors[hypothesis_name]

        # Bayes' theorem
        numerator = ev.likelihood * prior
        denominator = (ev.likelihood * prior +
                       ev.likelihood_complement * (1.0 - prior))

        if denominator == 0.0:
            posterior = prior  # No update possible
        else:
            posterior = numerator / denominator

        # Clamp to (0.0001, 0.9999) — AX22/T6: extremes unreachable
        posterior = min(max(posterior, 0.0001), 0.9999)

        # Determine composition mode per AX63
        bf = ev.bayes_factor
        if bf > 1.0 and ev.source_independent:
            mode = CompositionMode.TESANUD
        elif bf < 1.0:
            mode = CompositionMode.INFIRAD
        else:
            mode = CompositionMode.INDEPENDENT

        entry = PosteriorEntry(
            hypothesis_name=hypothesis_name,
            evidence_name=evidence_name,
            prior=prior,
            posterior=posterior,
            bayes_factor=bf,
            composition_mode=mode,
        )
        self.updates.append(entry)
        self.current_posteriors[hypothesis_name] = posterior
        return entry

    def get_posterior(self, hypothesis_name: str) -> float:
        """Return current posterior for a hypothesis."""
        if hypothesis_name not in self.current_posteriors:
            raise KeyError(f"Unknown hypothesis: '{hypothesis_name}'")
        return self.current_posteriors[hypothesis_name]

    def get_updates_for(self, hypothesis_name: str) -> List[PosteriorEntry]:
        """Return all update records for a given hypothesis."""
        return [u for u in self.updates if u.hypothesis_name == hypothesis_name]

    def get_status(self, hypothesis_name: str) -> HypothesisStatus:
        """Determine current status of a hypothesis.

        Returns:
            UNTESTED if no updates yet,
            SUPPORTED if posterior > prior,
            WEAKENED if posterior < prior,
            INDETERMINATE otherwise.
        """
        if hypothesis_name not in self.hypotheses:
            raise KeyError(f"Unknown hypothesis: '{hypothesis_name}'")
        h = self.hypotheses[hypothesis_name]
        updates = self.get_updates_for(hypothesis_name)
        if not updates:
            return HypothesisStatus.UNTESTED
        posterior = self.current_posteriors[hypothesis_name]
        if posterior > h.prior:
            return HypothesisStatus.SUPPORTED
        elif posterior < h.prior:
            return HypothesisStatus.WEAKENED
        return HypothesisStatus.INDETERMINATE

    def count_independent_confirmations(self, hypothesis_name: str) -> int:
        """Count independent confirming evidence items for a hypothesis.

        Per AX63/KV₇: only independent sources with tesanüd composition
        contribute to super-additive convergence.
        """
        return sum(
            1 for u in self.get_updates_for(hypothesis_name)
            if u.composition_mode == CompositionMode.TESANUD
        )

    def tesanud_strength(self, hypothesis_name: str) -> float:
        """Compute super-additive composition strength for a hypothesis.

        Per AX63 (Tesanüd/İnfirâd asymmetry):
        Independent confirmations compose super-additively.
        Strength = 1 - (1 - base_score)^n  where n = independent confirmations.

        Returns a score in [0, 1), clamped per T6/KV₄.
        """
        n = self.count_independent_confirmations(hypothesis_name)
        if n == 0:
            return 0.0
        posterior = self.current_posteriors.get(hypothesis_name, 0.5)
        # Super-additive: each independent confirmation raises the floor
        strength = 1.0 - (1.0 - posterior) ** (1.0 + 0.1 * (n - 1))
        return clamp_score(strength)

    def to_dict(self) -> dict:
        """Serialise model state to a plain dict (for JSON output)."""
        return {
            "name": self.name,
            "hypotheses": {
                name: {
                    "description": h.description,
                    "prior": h.prior,
                    "current_posterior": self.current_posteriors[name],
                    "status": self.get_status(name).value,
                }
                for name, h in self.hypotheses.items()
            },
            "evidence_items": {
                name: {
                    "description": e.description,
                    "type": e.evidence_type.value,
                    "likelihood": e.likelihood,
                    "likelihood_complement": e.likelihood_complement,
                    "bayes_factor": round(e.bayes_factor, 4),
                    "independent": e.source_independent,
                }
                for name, e in self.evidence_items.items()
            },
            "update_count": len(self.updates),
        }
