"""Shared Bayesian inference primitives for AgntSpace.

Provides the Beta distribution posterior and inverse CDF (ppf) used by
both the trust module (domain trust scores) and the memory module
(epistemic confidence on knowledge graph triples).

Pure Python — no scipy dependency. The Beta inverse CDF uses Newton-
Raphson on the regularized incomplete beta function with a normal
approximation fallback. Accurate to ~4 decimal places for α, β in
[0.5, 200].

Usage:
    from agntspace.infra.bayesian import BetaPosterior

    score = BetaPosterior(alpha=8.0, beta=2.0)
    score.mean          # 0.8
    score.variance      # 0.0145...
    score.credible_lower(0.05)  # 5th percentile ≈ 0.58
    score.decision_score()      # conservative gating score
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from functools import lru_cache


@dataclass
class BetaPosterior:
    """Bayesian Beta(α, β) posterior for binary observations.

    Properties:
    - ``mean``: posterior mean E[θ] = α / (α + β)
    - ``variance``: posterior variance
    - ``evidence``: total weight α + β (higher = more confident)
    - ``credible_lower(p)``: p-th percentile (default 5th)
    - ``credible_upper(p)``: p-th percentile (default 95th)

    The credible interval is what makes this properly Bayesian: a
    domain/triple with mean 0.80 but only 3 observations has a wide
    interval (5th percentile ≈ 0.35), while one with 50 observations
    has a tight interval (5th percentile ≈ 0.70). Gating decisions
    use the lower bound so the system is conservative when evidence
    is thin.
    """

    alpha: float = 2.0
    beta: float = 2.0   # named 'beta' — shadows the builtin, but it's the standard name

    @property
    def mean(self) -> float:
        """Posterior mean: E[θ] = α / (α + β)."""
        total = self.alpha + self.beta
        if total <= 0:
            return 0.5
        return self.alpha / total

    @property
    def evidence(self) -> float:
        """Total evidence weight (α + β). Higher = more confident."""
        return self.alpha + self.beta

    @property
    def variance(self) -> float:
        """Posterior variance: Var[θ] = αβ / ((α+β)²(α+β+1)).

        High variance = uncertain. Low variance = confident.
        """
        a, b = self.alpha, self.beta
        total = a + b
        if total <= 0:
            return 0.25
        return (a * b) / (total * total * (total + 1))

    @property
    def std(self) -> float:
        """Posterior standard deviation."""
        return math.sqrt(max(0.0, self.variance))

    def credible_lower(self, percentile: float = 0.05) -> float:
        """Lower bound of the credible interval (inverse CDF).

        Default 5th percentile = lower bound of a 90% credible interval.
        """
        return beta_ppf(percentile, self.alpha, self.beta)

    def credible_upper(self, percentile: float = 0.95) -> float:
        """Upper bound of the credible interval."""
        return beta_ppf(percentile, self.alpha, self.beta)

    def update(self, success: bool, weight: float = 1.0) -> None:
        """Bayesian update: observe one event (in-place).

        success=True → α += weight (evidence of competence)
        success=False → β += weight (evidence of failure)
        """
        if success:
            self.alpha += weight
        else:
            self.beta += weight

    def decayed_params(
        self,
        days_since: float,
        half_life_days: float,
        prior_alpha: float = 2.0,
        prior_beta: float = 2.0,
    ) -> tuple[float, float]:
        """Compute effective α, β after temporal decay.

        Decay reduces evidence toward the prior — the posterior
        distribution widens as evidence ages, naturally lowering the
        credible interval lower bound.

        Formula: eff_α = prior_α + (α - prior_α) × 0.5^(days/half_life)
        """
        if days_since <= 0 or half_life_days <= 0:
            return (self.alpha, self.beta)
        decay = 0.5 ** (days_since / half_life_days)
        eff_a = prior_alpha + (self.alpha - prior_alpha) * decay
        eff_b = prior_beta + (self.beta - prior_beta) * decay
        return (max(0.01, eff_a), max(0.01, eff_b))

    def decision_score(
        self,
        days_since: float = 0.0,
        half_life_days: float = 90.0,
        prior_alpha: float = 2.0,
        prior_beta: float = 2.0,
        percentile: float = 0.05,
    ) -> float:
        """Conservative gating score: credible lower bound with decay.

        This is the score that should drive permission decisions.
        """
        if days_since > 0:
            eff_a, eff_b = self.decayed_params(
                days_since, half_life_days, prior_alpha, prior_beta,
            )
        else:
            eff_a, eff_b = self.alpha, self.beta
        return beta_ppf(percentile, eff_a, eff_b)


# ── Beta distribution math (pure Python) ────────────────────────


@lru_cache(maxsize=2048)
def beta_ppf(p: float, a: float, b: float) -> float:
    """Inverse CDF (percent point function) of Beta(a, b).

    Uses Newton's method on the regularized incomplete beta function.
    Pure Python — no scipy dependency.

    Cached: this is a pure deterministic function of (p, a, b). The
    knowledge graph reweight loop calls it once per active triple
    (~13µs each, cold), and most triples share a small set of
    (α, β) pairs derived from the 3 authority tiers × default
    confidence — so cache hit rate approaches 100% in practice.
    Caching cuts reweight cost ~11x at 25k triples in benchmarks.
    The trust module reaches the same code via the BetaPosterior
    helpers below and benefits identically.
    """
    if a <= 0 or b <= 0:
        return 0.0
    if p <= 0:
        return 0.0
    if p >= 1:
        return 1.0

    total = a + b
    mu = a / total
    var = (a * b) / (total * total * (total + 1))
    sigma = math.sqrt(var) if var > 0 else 0.001

    normal_approx = mu + sigma * _inv_normal_cdf(p)
    normal_approx = max(0.001, min(0.999, normal_approx))

    if total < 4:
        return normal_approx

    x = normal_approx
    for _ in range(30):
        cdf = _beta_reg_inc(x, a, b)
        pdf = _beta_pdf(x, a, b)
        if pdf < 1e-15:
            break
        delta = (cdf - p) / pdf
        x = x - delta
        x = max(0.001, min(0.999, x))
        if abs(delta) < 1e-8:
            break

    return x


def beta_pdf(x: float, a: float, b: float) -> float:
    """Beta distribution PDF at x (public alias)."""
    return _beta_pdf(x, a, b)


def _beta_pdf(x: float, a: float, b: float) -> float:
    if x <= 0 or x >= 1:
        return 0.0
    try:
        log_pdf = (
            math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
            + (a - 1) * math.log(x) + (b - 1) * math.log(1 - x)
        )
        return math.exp(log_pdf)
    except (ValueError, OverflowError):
        return 0.0


def _beta_reg_inc(x: float, a: float, b: float) -> float:
    """Regularized incomplete beta function I_x(a, b) — CDF of Beta(a, b)."""
    if x <= 0:
        return 0.0
    if x >= 1:
        return 1.0
    if x > (a + 1) / (a + b + 2):
        return 1.0 - _beta_reg_inc(1.0 - x, b, a)
    try:
        log_prefix = (
            a * math.log(x) + b * math.log(1 - x)
            - math.log(a)
            - (math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b))
        )
        prefix = math.exp(log_prefix)
    except (ValueError, OverflowError):
        return 0.5
    cf = _beta_cf(x, a, b)
    return max(0.0, min(1.0, prefix * cf))


def _beta_cf(x: float, a: float, b: float, max_iter: int = 200) -> float:
    """Continued fraction for the regularized incomplete beta function."""
    tiny = 1e-30
    f = tiny
    c = tiny
    d = 0.0
    for m in range(max_iter):
        if m == 0:
            numerator = 1.0
        elif m % 2 == 1:
            k = (m - 1) // 2 + 1
            numerator = -(a + k - 1) * (a + b + k - 1) * x / (
                (a + 2 * k - 2) * (a + 2 * k - 1)
            )
        else:
            k = m // 2
            numerator = k * (b - k) * x / (
                (a + 2 * k - 1) * (a + 2 * k)
            )
        d = 1.0 + numerator * d
        if abs(d) < tiny:
            d = tiny
        d = 1.0 / d
        c = 1.0 + numerator / c
        if abs(c) < tiny:
            c = tiny
        f *= c * d
        if abs(c * d - 1.0) < 1e-10:
            break
    return f


def _inv_normal_cdf(p: float) -> float:
    """Inverse standard normal CDF (Beasley-Springer-Moro)."""
    if p <= 0:
        return -6.0
    if p >= 1:
        return 6.0
    a = [
        -3.969683028665376e+01, 2.209460984245205e+02,
        -2.759285104469687e+02, 1.383577518672690e+02,
        -3.066479806614716e+01, 2.506628277459239e+00,
    ]
    b = [
        -5.447609879822406e+01, 1.615858368580409e+02,
        -1.556989798598866e+02, 6.680131188771972e+01,
        -1.328068155288572e+01,
    ]
    c = [
        -7.784894002430293e-03, -3.223964580411365e-01,
        -2.400758277161838e+00, -2.549732539343734e+00,
        4.374664141464968e+00, 2.938163982698783e+00,
    ]
    d = [
        7.784695709041462e-03, 3.224671290700398e-01,
        2.445134137142996e+00, 3.754408661907416e+00,
    ]
    p_low = 0.02425
    p_high = 1 - p_low
    if p < p_low:
        q = math.sqrt(-2 * math.log(p))
        return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
               ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
    if p <= p_high:
        q = p - 0.5
        r = q * q
        return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / \
               (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1)
    q = math.sqrt(-2 * math.log(1 - p))
    return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
           ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
