"""meta_decision.py - Threshold self-calibration from historical outcomes.

Records each threshold decision (was it applied? was it correct?) and
automatically drifts threshold values when false-positive or false-negative
rates exceed configured limits. Data persisted to disk for durability.
"""

from __future__ import annotations

import json
import logging
import time
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

_THRESHOLD_REGISTRY: dict[str, dict[str, Any]] = {}
_PERSIST_PATH: Path | None = None

# Default threshold values
_DEFAULTS: dict[str, float] = {
    "min_score_delta": 1.5,
    "max_age_sec": 7200.0,
    "min_open_interval_sec": 120.0,
    "asia_low_atr_cutoff": 0.10,
    "asia_trend_atr_cutoff": 0.14,
    "asia_adx_cutoff": 16.0,
    "asia_trend_adx_cutoff": 22.0,
    "score_jump_delta": 1.5,
    "macro_event_cooldown_sec": 600.0,
    "vol_high_cutoff": 2.0,
    "vol_elevated_cutoff": 1.5,
    "vol_low_cutoff": 0.5,
    "veto_win_rate": 0.35,
    "penalty_win_rate": 0.45,
    "boost_win_rate": 0.70,
    "veto_mult_trending": 1.15,
    "veto_mult_ranging": 0.85,
    "asia_noise_weakpoint_min": 2,
    "asia_trend_min_score": 3.0,
    "asia_weak_score_cutoff": 2.0,
    "num_veto_se": 1.5,
    "num_boost_se": 1.5,
    "weight_low_adx": 1.5,
    "weight_low_atr": 1.0,
    "weight_neutral_dir": 0.5,
    "weight_neutral_ema": 0.5,
    "weight_low_score": 0.3,
    "noise_weight_threshold": 2.0,
    "overlap_atr_mult": 1.2,
    "overlap_trend_atr_mult": 1.07,
    "overlap_adx_mult": 1.125,
    "overlap_trend_adx_mult": 1.09,
    "overlap_noise_mult": 1.5,
}

# Calibration limits
_MIN_SAMPLES = 10
_CALIBRATE_WINDOW = 30
_MAX_FP_RATE = 0.40   # false positive: applied but wrong
_MAX_FN_RATE = 0.40   # false negative: not applied but should have been
_DRIFT_FACTOR = 1.15   # tighten/loosen multiplier per calibration
_ADD_DRIFT = 0.03      # additive drift for probability-like thresholds

# Probability-like thresholds use additive drift because multiplying
# a probability near 0.5 by 1.15 produces asymmetric and unintuitive moves.
_THRESHOLD_CALIBRATION: dict[str, dict[str, Any]] = {
    "veto_win_rate": {"mode": "add", "drift": _ADD_DRIFT, "max_fp_rate": 0.35, "max_fn_rate": 0.35},
    "penalty_win_rate": {"mode": "add", "drift": _ADD_DRIFT, "max_fp_rate": 0.35, "max_fn_rate": 0.35},
    "boost_win_rate": {"mode": "add", "drift": _ADD_DRIFT, "max_fp_rate": 0.35, "max_fn_rate": 0.35},
}


def _persist_path() -> Path:
    global _PERSIST_PATH
    if _PERSIST_PATH is None:
        import config_manager
        d = config_manager.get_data_dir() / "learning"
        d.mkdir(parents=True, exist_ok=True)
        _PERSIST_PATH = d / "meta_decision_thresholds.json"
    return _PERSIST_PATH


def _load_persisted() -> None:
    try:
        p = _persist_path()
        if not p.exists():
            return
        data = json.loads(p.read_text(encoding="utf-8"))
        if not isinstance(data, dict):
            return
        for name, entry in data.items():
            if name not in _THRESHOLD_REGISTRY:
                continue
            _THRESHOLD_REGISTRY[name]["current"] = float(entry.get("current", _DEFAULTS.get(name, 1.0)))
            _THRESHOLD_REGISTRY[name]["history"] = list(entry.get("history", []))
    except Exception:
        logger.warning("Failed to load persisted meta_decision thresholds", exc_info=True)


def _save() -> None:
    try:
        p = _persist_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        data = {}
        for name, entry in _THRESHOLD_REGISTRY.items():
            data[name] = {"current": entry["current"], "history": entry["history"][-200:]}
        p.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
    except Exception:
        logger.warning("Failed to save meta_decision thresholds", exc_info=True)


def register(name: str, default: float | None = None) -> None:
    """Register a threshold for self-calibration. Safe to call multiple times."""
    _ensure_loaded()
    if name in _THRESHOLD_REGISTRY:
        return
    d = default if default is not None else _DEFAULTS.get(name, 1.0)
    _THRESHOLD_REGISTRY[name] = {"current": float(d), "default": float(d), "history": []}


def record_outcome(name: str, was_applied: bool, was_correct: bool) -> None:
    """Record one decision outcome for a threshold.

    Args:
        name: Threshold name (must be registered first).
        was_applied: True if the threshold triggered.
        was_correct: True if the decision turned out to be the right call.
    """
    _ensure_loaded()
    if name not in _THRESHOLD_REGISTRY:
        register(name)
    _THRESHOLD_REGISTRY[name]["history"].append({
        "applied": was_applied, "correct": was_correct, "ts": time.time(),
    })
    _maybe_calibrate(name)


def _maybe_calibrate(name: str, window: int = _CALIBRATE_WINDOW) -> None:
    _ensure_loaded()
    entry = _THRESHOLD_REGISTRY.get(name)
    if not entry or len(entry["history"]) < _MIN_SAMPLES:
        return
    recent = entry["history"][-window:]
    applied = [h for h in recent if h["applied"]]
    not_applied = [h for h in recent if not h["applied"]]

    # Insufficient observations on either side: skip FP/FN drift and only
    # mean-revert slightly toward the default.
    if len(applied) < 3 or len(not_applied) < 3:
        _mean_revert(entry)
        return

    fp = sum(1 for h in applied if not h["correct"])
    fn = sum(1 for h in not_applied if h["correct"])
    fp_rate = fp / len(applied)
    fn_rate = fn / len(not_applied)
    current = entry["current"]
    default = entry["default"]

    cfg = _THRESHOLD_CALIBRATION.get(name, {"mode": "multiply"})
    mode = cfg.get("mode", "multiply")
    max_fp_rate = cfg.get("max_fp_rate", _MAX_FP_RATE)
    max_fn_rate = cfg.get("max_fn_rate", _MAX_FN_RATE)

    if fp_rate > max_fp_rate:
        current = _tighten(current, default, mode, cfg)
        print(f"[meta_decision] {name} fp_rate={fp_rate:.0%} > {max_fp_rate:.0%}, tighten: {entry['current']}→{current}")
    elif fn_rate > max_fn_rate:
        current = _loosen(current, default, mode, cfg)
        print(f"[meta_decision] {name} fn_rate={fn_rate:.0%} > {max_fn_rate:.0%}, loosen: {entry['current']}→{current}")
    else:
        _mean_revert(entry)
        return

    entry["current"] = current
    _save()


def _tighten(current: float, default: float, mode: str, cfg: dict[str, Any]) -> float:
    if mode == "add":
        return round(min(default + 0.10, current + cfg.get("drift", _ADD_DRIFT)), 4)
    return round(min(default * 2.0, current * _DRIFT_FACTOR), 4)


def _loosen(current: float, default: float, mode: str, cfg: dict[str, Any]) -> float:
    if mode == "add":
        return round(max(default - 0.10, current - cfg.get("drift", _ADD_DRIFT)), 4)
    return round(max(default * 0.5, current / _DRIFT_FACTOR), 4)


def _mean_revert(entry: dict[str, Any]) -> None:
    current = entry["current"]
    default = entry["default"]
    drift = abs(current - default) * 0.02
    if drift > 0.001:
        current = round(current - (1.0 if current > default else -1.0) * drift, 4)
        entry["current"] = current
        _save()


def get_threshold(name: str) -> float:
    """Get current (possibly drifted) value for a threshold."""
    _ensure_loaded()
    if name not in _THRESHOLD_REGISTRY:
        register(name)
    return _THRESHOLD_REGISTRY[name]["current"]


def reset(name: str) -> None:
    """Reset a threshold to its default value."""
    _ensure_loaded()
    if name in _THRESHOLD_REGISTRY:
        _THRESHOLD_REGISTRY[name]["current"] = _THRESHOLD_REGISTRY[name]["default"]
        _THRESHOLD_REGISTRY[name]["history"] = []
        _save()


def summary() -> dict[str, dict[str, Any]]:
    """Return a readable snapshot of all thresholds."""
    _ensure_loaded()
    return {
        n: {"current": e["current"], "default": e["default"], "samples": len(e["history"])}
        for n, e in sorted(_THRESHOLD_REGISTRY.items())
    }


_loaded = False


def _ensure_loaded() -> None:
    global _loaded
    if not _loaded:
        for name, default in _DEFAULTS.items():
            _THRESHOLD_REGISTRY.setdefault(
                name, {"current": float(default), "default": float(default), "history": []}
            )
        _load_persisted()
        _loaded = True
