#!/usr/bin/env python3
"""PnL Feedback Scorer — unified strategy scoring across backtest, paper, and live.

Blends multiple performance signals into a single score that ranks strategies
for allocation decisions.  Recent P&L weighs more than historical backtest.

Usage::

    from pnl_feedback_scorer import PnLFeedbackScorer

    scorer = PnLFeedbackScorer()
    score = scorer.score_strategy("ema_macd")
    rankings = scorer.rank_all_strategies()
"""

from __future__ import annotations

import json
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import config_manager


@dataclass
class ScoreComponents:
    backtest_score: float = 0.0
    paper_score: float = 0.0
    live_score: float = 0.0
    blended_score: float = 0.0
    decay_factor: float = 1.0
    data_quality: float = 1.0


class PnLFeedbackScorer:
    """Unified strategy scoring engine.

    Weights (configurable):
      - backtest_weight: historical simulation performance
      - paper_weight: paper trading results
      - live_weight: real P&L

    Time decay: recent trades (>14 days) get exponentially decayed weight.
    """

    def __init__(
        self,
        backtest_weight: float = 0.25,
        paper_weight: float = 0.35,
        live_weight: float = 0.40,
        decay_half_life_days: float = 30.0,
        min_trades_for_score: int = 5,
    ):
        self.backtest_weight = backtest_weight
        self.paper_weight = paper_weight
        self.live_weight = live_weight
        self.decay_half_life_days = decay_half_life_days
        self.min_trades_for_score = min_trades_for_score

    # -- paths -----------------------------------------------------------

    def _backtest_dir(self) -> Path:
        return config_manager.get_data_dir() / "offline"

    def _sandbox_dir(self) -> Path:
        return config_manager.get_logs_dir()

    def _ledger_path(self) -> Path:
        return config_manager.get_logs_dir() / "trading_ledger.ndjson"

    # -- main scoring ----------------------------------------------------

    def score_strategy(self, strategy_id: str) -> dict[str, Any]:
        """Compute unified score for a single strategy."""
        bt = self._compute_backtest_component(strategy_id)
        paper = self._compute_paper_component(strategy_id)
        live = self._compute_live_component(strategy_id)

        blended = round(
            bt * self.backtest_weight
            + paper * self.paper_weight
            + live * self.live_weight,
            4,
        )

        return {
            "strategy_id": strategy_id,
            "backtest_score": round(bt, 4),
            "paper_score": round(paper, 4),
            "live_score": round(live, 4),
            "blended_score": blended,
            "weights": {
                "backtest": self.backtest_weight,
                "paper": self.paper_weight,
                "live": self.live_weight,
            },
            "scored_at": datetime.now(timezone.utc).isoformat(),
        }

    def rank_all_strategies(self) -> list[dict[str, Any]]:
        """Score all strategies with available data and rank by blended score."""
        strategy_ids = self._discover_strategy_ids()
        scores = [self.score_strategy(sid) for sid in strategy_ids]
        scores.sort(key=lambda s: s["blended_score"], reverse=True)
        for rank, s in enumerate(scores, 1):
            s["rank"] = rank
        return scores

    def get_top_strategy(self) -> dict[str, Any] | None:
        ranked = self.rank_all_strategies()
        return ranked[0] if ranked else None

    # -- component computation -------------------------------------------

    def _compute_backtest_component(self, strategy_id: str) -> float:
        """Score from offline backtest results."""
        best_path = self._backtest_dir() / "best_params.json"
        if not best_path.exists():
            return 0.0

        try:
            data = json.loads(best_path.read_text(encoding="utf-8"))
            stats = data.get("stats", data)
            trades = int(stats.get("total_trades", 0))
            if trades < self.min_trades_for_score:
                return 0.0
            wr = float(stats.get("win_rate", 0)) / 100.0
            pnl = float(stats.get("total_pnl", 0))
            score = pnl * wr * math.log1p(trades)
            return max(score, 0.0)
        except Exception:
            return 0.0

    def _compute_paper_component(self, strategy_id: str) -> float:
        """Score from paper trading ledger entries."""
        trades = self._load_ledger_trades(strategy_id, mode="paper")
        return self._score_trades(trades)

    def _compute_live_component(self, strategy_id: str) -> float:
        """Score from live trading ledger entries."""
        trades = self._load_ledger_trades(strategy_id, mode="live")
        return self._score_trades(trades)

    def _score_trades(self, trades: list[dict[str, Any]]) -> float:
        if len(trades) < self.min_trades_for_score:
            return 0.0

        total_pnl = 0.0
        wins = 0
        total_weight = 0.0

        now = datetime.now(timezone.utc)
        for t in trades:
            pnl = float(t.get("pnl", 0) or 0)
            weight = self._time_decay_weight(t, now)
            total_pnl += pnl * weight
            total_weight += weight
            if pnl > 0:
                wins += 1

        if total_weight == 0:
            return 0.0

        wr = wins / len(trades)
        avg_pnl = total_pnl / total_weight
        return round(avg_pnl * wr * math.log1p(len(trades)), 4)

    def _time_decay_weight(
        self, trade: dict[str, Any], now: datetime
    ) -> float:
        ts_str = str(trade.get("timestamp", "") or trade.get("close_time", ""))
        if not ts_str:
            return 0.5
        try:
            ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
        except ValueError:
            return 0.5
        days_ago = (now - ts).total_seconds() / 86400.0
        if days_ago < 0:
            return 1.0
        lam = math.log(2) / self.decay_half_life_days
        return round(math.exp(-lam * days_ago), 4)

    # -- data loading ----------------------------------------------------

    def _load_ledger_trades(
        self, strategy_id: str, mode: str
    ) -> list[dict[str, Any]]:
        path = self._ledger_path()
        if not path.exists():
            return []
        trades: list[dict[str, Any]] = []
        try:
            for line in path.read_text(encoding="utf-8").strip().splitlines():
                if not line.strip():
                    continue
                entry = json.loads(line)
                sid = str(entry.get("strategy_id", "") or "")
                m = str(entry.get("mode", "") or entry.get("trade_type", "") or "")
                if sid == strategy_id and m == mode:
                    trades.append(entry)
        except Exception:
            pass
        return trades

    def _discover_strategy_ids(self) -> list[str]:
        """Discover strategy IDs from backtest, sandbox, and ledger."""
        ids: set[str] = set()

        best_path = self._backtest_dir() / "best_params.json"
        if best_path.exists():
            try:
                data = json.loads(best_path.read_text(encoding="utf-8"))
                sid = str(data.get("strategy_id", "") or "")
                if sid:
                    ids.add(sid)
            except Exception:
                pass

        ledger_path = self._ledger_path()
        if ledger_path.exists():
            try:
                for line in ledger_path.read_text(encoding="utf-8").strip().splitlines():
                    if not line.strip():
                        continue
                    entry = json.loads(line)
                    sid = str(entry.get("strategy_id", "") or "")
                    if sid:
                        ids.add(sid)
            except Exception:
                pass

        if not ids:
            ids.add("ema_macd")

        return sorted(ids)
