#!/usr/bin/env python3
"""Strategy Arena — internal competition market for multi-strategy allocation.

All active strategies compete on the same recent market data.  The arena
ranks them, computes allocation weights, and flags consistent
underperformers for retirement review.

Usage::

    from strategy_arena import StrategyArena

    arena = StrategyArena()
    report = arena.run_tournament(recent_bars)
    allocations = arena.get_allocations()
"""

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

SRC_DIR = Path(__file__).resolve().parent
PROJECT = SRC_DIR.parent


@dataclass
class ArenaConfig:
    """Competition parameters for the strategy arena."""

    min_strategies: int = 2
    max_strategies: int = 8
    min_allocation_pct: float = 5.0
    retirement_streak: int = 3
    tournament_cooldown_bars: int = 288  # 24h of M5 bars
    lookback_bars: int = 2000  # ~1 week of M5 bars
    score_weight_sandbox: float = 0.50
    score_weight_live: float = 0.30
    score_weight_paper: float = 0.20


@dataclass
class ArenaStanding:
    strategy_id: str
    rank: int = 0
    sandbox_score: float = 0.0
    live_score: float = 0.0
    blended_score: float = 0.0
    allocation_pct: float = 0.0
    bottom_streak: int = 0
    retirement_flagged: bool = False
    trust_level: str = "sandbox"


class StrategyArena:
    """Multi-strategy competition engine.

    Runs tournaments, computes allocations, and tracks persistent
    bottom-ranking streaks that trigger retirement review.
    """

    def __init__(self, config: ArenaConfig | None = None):
        self._config = config or ArenaConfig()
        self._standings: dict[str, ArenaStanding] = {}
        self._tournament_history: list[dict[str, Any]] = []
        self._load_state()

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

    def _state_path(self) -> Path:
        return config_manager.get_logs_dir() / "arena_state.json"

    def _history_path(self) -> Path:
        return config_manager.get_logs_dir() / "arena_history.ndjson"

    # -- persistence -----------------------------------------------------

    def _load_state(self) -> None:
        path = self._state_path()
        if not path.exists():
            return
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            for sid, raw in data.get("standings", {}).items():
                self._standings[sid] = ArenaStanding(**raw)
        except Exception:
            pass

    def _save_state(self) -> None:
        path = self._state_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            "updated_at": datetime.now(timezone.utc).isoformat(),
            "standings": {
                sid: {
                    "strategy_id": s.strategy_id,
                    "rank": s.rank,
                    "sandbox_score": s.sandbox_score,
                    "live_score": s.live_score,
                    "blended_score": s.blended_score,
                    "allocation_pct": s.allocation_pct,
                    "bottom_streak": s.bottom_streak,
                    "retirement_flagged": s.retirement_flagged,
                    "trust_level": s.trust_level,
                }
                for sid, s in self._standings.items()
            },
        }
        path.write_text(
            json.dumps(payload, indent=2, ensure_ascii=False),
            encoding="utf-8",
        )

    def _append_history(self, entry: dict[str, Any]) -> None:
        path = self._history_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(path, "a", encoding="utf-8") as f:
            f.write(json.dumps(entry, ensure_ascii=False) + "\n")

    # -- main tournament -------------------------------------------------

    def run_tournament(
        self,
        bars: list[dict[str, Any]],
        *,
        strategy_ids: list[str] | None = None,
    ) -> dict[str, Any]:
        """Run a tournament: sandbox all strategies on the same bars, rank by score.

        Returns a report with standings, allocations, and retirement flags.
        """
        ids = strategy_ids or self._discover_strategies()
        if len(ids) < self._config.min_strategies:
            return {
                "tournament_id": _tournament_id(),
                "ran_at": datetime.now(timezone.utc).isoformat(),
                "standings": [],
                "allocations": {},
                "retirement_flagged": [],
                "error": f"Need at least {self._config.min_strategies} strategies, found {len(ids)}",
            }

        # Run sandbox for each strategy
        results: dict[str, dict[str, Any]] = {}
        for sid in ids:
            try:
                report = self._sandbox_strategy(sid, bars)
                results[sid] = report
            except Exception:
                results[sid] = {"score": 0.0, "error": "sandbox failed"}

        # Blend sandbox score with live/paper PnL scores
        blended = self._blend_scores(results)

        # Rank by blended score (descending)
        ranked = sorted(blended.items(), key=lambda x: x[1], reverse=True)

        # Compute allocations
        allocations = self._compute_allocations(ranked)

        # Update standings with streaks
        standings = self._update_standings(ranked, allocations)

        tournament = {
            "tournament_id": _tournament_id(),
            "ran_at": datetime.now(timezone.utc).isoformat(),
            "strategies_entered": len(ids),
            "standings": [
                {
                    "strategy_id": sid,
                    "rank": s.rank,
                    "blended_score": round(s.blended_score, 4),
                    "allocation_pct": round(s.allocation_pct, 2),
                    "bottom_streak": s.bottom_streak,
                    "retirement_flagged": s.retirement_flagged,
                }
                for sid, s in standings.items()
            ],
            "allocations": {
                sid: round(s.allocation_pct, 2)
                for sid, s in standings.items()
            },
            "retirement_flagged": [
                sid for sid, s in standings.items() if s.retirement_flagged
            ],
        }

        self._append_history(tournament)
        self._save_state()
        return tournament

    # -- sandbox runner --------------------------------------------------

    def _sandbox_strategy(
        self, strategy_id: str, bars: list[dict[str, Any]]
    ) -> dict[str, Any]:
        from strategy_sdk.dynamic_registry import get_dynamic_strategy_registry
        from strategy_sdk.sandbox import SandboxParams, StrategySandbox

        registry = get_dynamic_strategy_registry()
        plugin = registry.instantiate(strategy_id)
        sandbox = StrategySandbox(plugin, bars, SandboxParams())
        return sandbox.run()

    # -- score blending --------------------------------------------------

    def _blend_scores(
        self, sandbox_results: dict[str, dict[str, Any]]
    ) -> dict[str, float]:
        from src.pnl_feedback_scorer import PnLFeedbackScorer

        scorer = PnLFeedbackScorer()
        cfg = self._config

        blended: dict[str, float] = {}
        for sid, sb in sandbox_results.items():
            sb_score = self._normalize_sandbox_score(sb.get("score", 0.0))

            live_data = scorer.score_strategy(sid)
            live_score = live_data.get("live_score", 0.0)
            paper_score = live_data.get("paper_score", 0.0)

            total = (
                sb_score * cfg.score_weight_sandbox
                + live_score * cfg.score_weight_live
                + paper_score * cfg.score_weight_paper
            )
            blended[sid] = total

        return blended

    @staticmethod
    def _normalize_sandbox_score(raw_score: float) -> float:
        """Squash raw PnL-based score into [0, 1] range for blending."""
        if raw_score <= 0:
            return 0.0
        return min(1.0, math.log1p(abs(raw_score)) / 10.0)

    # -- allocations -----------------------------------------------------

    def _compute_allocations(
        self, ranked: list[tuple[str, float]]
    ) -> dict[str, float]:
        """Compute allocation weights proportional to score with a floor."""
        if not ranked:
            return {}

        scores = {sid: max(s, 0.0) for sid, s in ranked}
        total = sum(scores.values())
        if total <= 0:
            equal = 100.0 / len(ranked)
            return {sid: equal for sid, _ in ranked}

        floor = self._config.min_allocation_pct / 100.0
        raw = {sid: max(s / total, floor) for sid, s in scores.items()}

        # Re-normalize so they sum to 1
        raw_total = sum(raw.values())
        normalized = {
            sid: (w / raw_total) * 100.0 for sid, w in raw.items()
        }
        return normalized

    # -- standings -------------------------------------------------------

    def _update_standings(
        self,
        ranked: list[tuple[str, float]],
        allocations: dict[str, float],
    ) -> dict[str, ArenaStanding]:
        bottom_cutoff = max(2, len(ranked) // 3)
        bottom_ids = {sid for sid, _ in ranked[-bottom_cutoff:]}

        standings: dict[str, ArenaStanding] = {}
        for rank, (sid, score) in enumerate(ranked, 1):
            prev = self._standings.get(sid)
            bottom_streak = (prev.bottom_streak + 1 if prev else 1) if sid in bottom_ids else 0

            standings[sid] = ArenaStanding(
                strategy_id=sid,
                rank=rank,
                blended_score=score,
                allocation_pct=allocations.get(sid, 0.0),
                bottom_streak=bottom_streak,
                retirement_flagged=bottom_streak >= self._config.retirement_streak,
                trust_level=prev.trust_level if prev else "sandbox",
            )

        self._standings = standings
        return standings

    # -- query -----------------------------------------------------------

    def get_allocations(self) -> dict[str, float]:
        return {
            sid: round(s.allocation_pct, 2)
            for sid, s in self._standings.items()
        }

    def get_leaderboard(self) -> list[dict[str, Any]]:
        ranked = sorted(
            self._standings.values(),
            key=lambda s: (s.rank, -s.blended_score),
        )
        return [
            {
                "strategy_id": s.strategy_id,
                "rank": s.rank,
                "blended_score": round(s.blended_score, 4),
                "allocation_pct": round(s.allocation_pct, 2),
                "bottom_streak": s.bottom_streak,
                "retirement_flagged": s.retirement_flagged,
                "trust_level": s.trust_level,
            }
            for s in ranked
        ]

    def get_retirement_candidates(self) -> list[str]:
        return [
            sid
            for sid, s in self._standings.items()
            if s.retirement_flagged
        ]

    def get_top_strategies(self, n: int = 3) -> list[str]:
        ranked = sorted(
            self._standings.values(),
            key=lambda s: (s.rank, -s.blended_score),
        )
        return [s.strategy_id for s in ranked[:n]]

    # -- helpers ---------------------------------------------------------

    def _discover_strategies(self) -> list[str]:
        from strategy_sdk.dynamic_registry import get_dynamic_strategy_registry

        registry = get_dynamic_strategy_registry()
        specs = registry.list_manifests()

        # Only compete strategies at paper level or above
        active_levels = {"sandbox", "paper", "live_reduced", "live_full", "proven"}
        ids = [
            s["strategy_id"]
            for s in specs
            if s.get("trust_level", "sandbox") in active_levels
        ]
        return ids[: self._config.max_strategies]

    def reset(self) -> None:
        self._standings.clear()
        self._tournament_history.clear()
        self._save_state()


def _tournament_id() -> str:
    return datetime.now(timezone.utc).strftime("T%Y%m%d-%H%M%S")
