"""Market-anchored calibration (V35) — pure functions, no I/O.

WHY THIS EXISTS
---------------
The model's invented per-market probabilities were *measured* to be badly
overconfident. Grading the engine's own stored predictions against actual
results: it says ~50% where reality is ~25%, ~67% where reality is ~37%
(calibration error / ECE on the order of 25-30%). That mis-calibration is the
direct cause of the false "value" picks and the negative realised ROI.

The de-vigged market price, by contrast, is empirically near-perfectly
calibrated. Out-of-sample (correction fit on 2023-24, tested on 2025-26;
78k real-odds football matches) the de-vigged market's ECE was:
    home 1.56% | draw 1.85% | away 1.49% | over2.5 1.79% | btts 1.38%
Adding one small, large-sample home-favourite correction cut MS-home ECE
from 1.56% -> 0.64%.

So for the DISPLAYED probabilities we anchor to the de-vigged market and apply
only that one proven correction. ~20-40x more calibrated than the model's
numbers, and fully transparent.

These functions are pure (stdlib only) so they can be unit-tested in isolation
without the DB or the heavy model stack.
"""

from __future__ import annotations

import json
import os
import threading
import time
from typing import Any, Dict, List, Optional, Tuple


def devig(odds: List[Optional[float]]) -> Optional[List[float]]:
    """Vig-removed (fair) probabilities from a group of decimal odds.

    ``p_i = (1/odds_i) / Σ(1/odds_j)`` — normalising the raw implied
    probabilities to sum to 1 removes the bookmaker margin.

    Returns ``None`` when ANY leg is missing or non-real (``<= 1.01``). That is
    deliberate: a market with a missing/placeholder leg has no real price, and
    the product rule is to never fabricate numbers for a match without odds.
    """
    if not odds or any(o is None or float(o) <= 1.01 for o in odds):
        return None
    inv = [1.0 / float(o) for o in odds]
    total = sum(inv)
    if total <= 0.0:
        return None
    return [x / total for x in inv]


# Home-favourite correction: measured (actual home-win rate − de-vigged implied)
# by implied-home band, out-of-sample on real-odds matches. Big home favourites
# win a few points MORE than the de-vigged price implies; underdogs are roughly
# unbiased. Values are deliberately conservative — universal and shrunk toward 0
# vs the raw tier-0 (soft-league) edge, because the bias is weaker in efficient
# top leagues. Applying these took MS-home OOS ECE 1.56% -> 0.64%.
#
# These static bands are the BUILT-IN FALLBACK. The live values come from the
# versioned artifact `config/market_anchor_corrections.json`, refreshed by
# `scripts/fit_anchor_corrections.py` (the guarded self-correction loop:
# measure on settled matches -> shrink/clip/min-sample gates -> out-of-sample
# acceptance -> write table). The engine only ever consumes the TABLE — the
# loop never modifies code.
_HOME_FAV_BANDS: Tuple[Tuple[float, float, float], ...] = (
    (0.45, 0.55, 0.010),
    (0.55, 0.65, 0.018),
    (0.65, 0.75, 0.028),
    (0.75, 1.01, 0.034),
)

_DEFAULT_CORRECTIONS_PATH = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "..", "config",
    "market_anchor_corrections.json",
)


def _corrections_path() -> str:
    return os.environ.get(
        "MARKET_ANCHOR_CORRECTIONS_PATH", _DEFAULT_CORRECTIONS_PATH
    )
_corrections_lock = threading.Lock()
_corrections_cache: Optional[Dict[str, Any]] = None
_corrections_ts: float = 0.0
# Re-check sources at most every 10 minutes: the self-correction cron writes a
# new table to app_settings; running engines pick it up WITHOUT a restart.
_CORRECTIONS_TTL_S = 600.0


def _parse_corrections(raw: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    parsed_table: Dict[str, Any] = {}
    for key in ("ms_home", "ms_away"):
        bands = raw.get("corrections", {}).get(key)
        if not (isinstance(bands, list) and bands):
            continue
        parsed = []
        for b in bands:
            lo = float(b["lo"]); hi = float(b["hi"]); delta = float(b["delta"])
            if not (0.0 <= lo < hi <= 1.01) or abs(delta) > 0.10:
                raise ValueError(f"correction band out of range: {b}")
            parsed.append((lo, hi, delta))
        parsed_table[key] = tuple(parsed)
    if not parsed_table:
        return None
    parsed_table["version"] = str(raw.get("version", "?"))
    return parsed_table


def _db_corrections_raw() -> Optional[Dict[str, Any]]:
    """Fetch the correction artifact from app_settings (the deployment's shared
    medium — the ai-engine container has no volume mounts, so a host-side cron
    can only reach the running engine through the database). Guarded: any
    failure → None, never breaks a prediction. Disable with MARKET_ANCHOR_DB=0."""
    if os.environ.get("MARKET_ANCHOR_DB", "1") == "0":
        return None
    try:
        import psycopg2  # local import: keeps module usable without DB deps
        from data.db import get_clean_dsn

        with psycopg2.connect(get_clean_dsn(), connect_timeout=3) as conn:
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT value FROM app_settings"
                    " WHERE key = 'market_anchor_corrections'"
                )
                row = cur.fetchone()
        if row and row[0]:
            return json.loads(row[0])
    except Exception:
        return None
    return None


def _load_corrections() -> Optional[Dict[str, Any]]:
    """Resolve the active correction table (thread-safe, TTL-cached).

    Source order:
      1. MARKET_ANCHOR_CORRECTIONS_PATH env file (tests/dev — file-only mode,
         malformed → static fallback, DB and default file are NOT consulted)
      2. app_settings DB row 'market_anchor_corrections' (production path —
         refreshed by scripts/fit_anchor_corrections.py)
      3. bundled config/market_anchor_corrections.json
      4. None → built-in static fallback bands
    """
    global _corrections_cache, _corrections_ts
    now = time.time()
    if now - _corrections_ts < _CORRECTIONS_TTL_S:
        return _corrections_cache
    with _corrections_lock:
        if now - _corrections_ts < _CORRECTIONS_TTL_S:
            return _corrections_cache
        table: Optional[Dict[str, Any]] = None
        env_path = os.environ.get("MARKET_ANCHOR_CORRECTIONS_PATH")
        if env_path:
            try:
                with open(env_path, "r", encoding="utf-8") as fh:
                    table = _parse_corrections(json.load(fh))
            except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
                table = None
        else:
            raw = _db_corrections_raw()
            if raw is not None:
                try:
                    table = _parse_corrections(raw)
                except (ValueError, KeyError, TypeError):
                    table = None
            if table is None:
                try:
                    with open(_corrections_path(), "r", encoding="utf-8") as fh:
                        table = _parse_corrections(json.load(fh))
                except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
                    table = None
        _corrections_cache = table
        _corrections_ts = time.time()
        return _corrections_cache


def reload_corrections() -> None:
    """Force re-read of the correction sources (used after a refresh/tests)."""
    global _corrections_ts, _corrections_cache
    with _corrections_lock:
        _corrections_ts = 0.0
        _corrections_cache = None


def home_favorite_delta(p_home: float) -> float:
    """Additive correction to the de-vigged home-win probability.

    Band semantics: a fitted-artifact band OVERRIDES the static prior where it
    exists (including an explicit delta of 0 — evidence of "no bias"). Where
    the artifact is SILENT (a range that never passed the min-sample gate,
    e.g. big favourites 0.75+), the static prior still applies — missing
    evidence must not silently erase proven knowledge."""
    table = _load_corrections()
    if table and "ms_home" in table:
        for lo, hi, delta in table["ms_home"]:
            if lo <= p_home < hi:
                return delta
    for lo, hi, delta in _HOME_FAV_BANDS:
        if lo <= p_home < hi:
            return delta
    return 0.0


def away_favorite_delta(p_away: float) -> float:
    """Additive correction to the de-vigged away-win probability.

    Scoreboard measurement (2026-06): away favourites also win a few points
    MORE than the de-vigged price implies (+2.6..+4.2pt). Unlike the home
    side there is NO built-in fallback — away corrections must be EARNED via
    the fitted artifact (scripts/fit_anchor_corrections.py passing its
    out-of-sample acceptance gate). No artifact → zero → prior behaviour."""
    table = _load_corrections()
    bands = table.get("ms_away", ()) if table else ()
    for lo, hi, delta in bands:
        if lo <= p_away < hi:
            return delta
    return 0.0


def apply_corrections(
    p1: float, px: float, p2: float
) -> Tuple[float, float, float]:
    """Apply favourite corrections to a 3-way (1, X, 2) vector.

    In practice only one side can be a favourite (both ≥0.45 would leave no
    room for the draw); if both bands somehow fire, the larger delta wins.
    The other two outcomes are renormalised so the vector still sums to 1."""
    d1 = home_favorite_delta(p1)
    d2 = away_favorite_delta(p2)
    if d1 <= 0.0 and d2 <= 0.0:
        return p1, px, p2
    if d1 >= d2:
        return apply_home_correction(p1, px, p2)
    p2n = min(0.98, p2 + d2)
    remaining = 1.0 - p2n
    rest = p1 + px
    if rest <= 0.0:
        return p1, px, p2n
    return p1 / rest * remaining, px / rest * remaining, p2n


def apply_home_correction(
    p1: float, px: float, p2: float
) -> Tuple[float, float, float]:
    """Apply the home-favourite delta to a 3-way (1, X, 2) probability vector,
    renormalising draw/away so the three still sum to 1.0."""
    delta = home_favorite_delta(p1)
    if delta <= 0.0:
        return p1, px, p2
    p1n = min(0.98, p1 + delta)
    remaining = 1.0 - p1n
    rest = px + p2
    if rest <= 0.0:
        return p1n, px, p2
    return p1n, px / rest * remaining, p2 / rest * remaining
