from __future__ import annotations

"""OHD progressive display schedule builders for prog_display_mode 1..4."""

import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, List, Optional, Sequence, Tuple

from ethergold_reader import (
    EthergoldReader,
    PROGRESSIVE_GAME_IDS,
    parse_display_to_pennies,
)

# Mode 1: seconds per progressive game (clamped in load_config).
MODE_1_MIN_SEC = 10
MODE_1_MAX_SEC = 60

MAX_FLOOR_PROGRESSIVE_GAMES = 12
MAX_FLOOR_PROGRESSIVE_GAMES_WITH_BG21 = 11
BLACK_GOLD_21_GAME_ID = 15

DEFAULT_FOCUS_FRACTION = 0.75
DEFAULT_ROTATION_BATCH = 2
DEFAULT_ACTIVITY_MIN_SAMPLES = 12
DEFAULT_ACTIVITY_WINDOW_SEC = 60.0
# Thresholds are leader-minus-runner-up margin (pennies), written as decimal dollars (0.50 = 50¢).
DEFAULT_ACTIVITY_TIERS_RAW = "0.50:100, 0.40:80, 0.30:70, 0.15:60, 0.05:50"

Schedule = List[Tuple[int, float]]


def coalesce_schedule(schedule: Schedule) -> Schedule:
    """Merge adjacent segments for the same game (one continuous display, no re-fade)."""
    if not schedule:
        return []
    merged: Schedule = [schedule[0]]
    for game_id, duration in schedule[1:]:
        last_game, last_duration = merged[-1]
        if game_id == last_game:
            merged[-1] = (last_game, last_duration + float(duration))
        else:
            merged.append((game_id, float(duration)))
    return merged


@dataclass(frozen=True)
class FloorActivityRank:
    """Ranked floor games by activity score (pennies per CSV capture)."""

    ranked: Tuple[Tuple[int, float], ...]
    metric: str
    leader_game: Optional[int]
    leader_score: float
    runner_up_score: float
    margin: float


def rank_floor_activity(
    reader: EthergoldReader,
    floor_games: Sequence[int],
    min_samples: int,
) -> FloorActivityRank:
    """Rank all floor games; margin = leader score minus runner-up (for mode 4 tiers)."""
    diagnostics = reader.get_activity_diagnostics(floor_games)
    use_avg = any(
        int(diagnostics.get(game_id, {}).get("samples", 0)) >= min_samples
        for game_id in floor_games
    )
    scores: Dict[int, float] = {}
    for game_id in floor_games:
        detail = diagnostics.get(game_id, {})
        samples = int(detail.get("samples", 0))
        if use_avg and samples >= min_samples:
            scores[game_id] = float(detail.get("avg_pennies", 0.0))
        else:
            scores[game_id] = float(detail.get("last_delta_pennies", 0))
    metric = "rolling_avg" if use_avg else "latest_delta"
    ranked = tuple(
        sorted(scores.items(), key=lambda item: (-item[1], item[0]))
    )
    leader_game: Optional[int] = None
    leader_score = 0.0
    runner_up_score = 0.0
    margin = 0.0
    if ranked and ranked[0][1] > 0:
        leader_game = ranked[0][0]
        leader_score = ranked[0][1]
        runner_up_score = ranked[1][1] if len(ranked) > 1 else 0.0
        margin = max(0.0, leader_score - runner_up_score)
    return FloorActivityRank(
        ranked=ranked,
        metric=metric,
        leader_game=leader_game,
        leader_score=leader_score,
        runner_up_score=runner_up_score,
        margin=margin,
    )


@dataclass(frozen=True)
class Mode34BuildInfo:
    focus_game: Optional[int]
    focus_fraction: float
    tier_percent: Optional[int]
    strategy: str
    fallback_reason: str


@dataclass(frozen=True)
class OhdProgressiveSettings:
    floor_games: Tuple[int, ...]
    game_time_sec: float
    focus_fraction: float
    rotation_batch: int
    activity_min_samples: int
    activity_window_sec: float
    activity_tiers: Tuple[Tuple[int, int], ...]


def clamp_mode_1_display_time(seconds: int) -> int:
    return max(MODE_1_MIN_SEC, min(MODE_1_MAX_SEC, int(seconds)))


def _progressive_capable_game_ids() -> Tuple[int, ...]:
    return tuple(g for g in range(1, 17) if g != BLACK_GOLD_21_GAME_ID)


def sanitize_progressive_floor_games(
    game_ids: Sequence[int],
    *,
    black_gold_21_enabled: bool,
) -> Tuple[int, ...]:
    """Strip Black Gold 21 and invalid IDs. Never invents a default floor list."""
    max_count = (
        MAX_FLOOR_PROGRESSIVE_GAMES_WITH_BG21
        if black_gold_21_enabled
        else MAX_FLOOR_PROGRESSIVE_GAMES
    )
    capable = set(_progressive_capable_game_ids())
    parsed: List[int] = []
    for game_id in game_ids:
        gid = int(game_id)
        if gid == BLACK_GOLD_21_GAME_ID:
            print(
                f"WARNING - progressive_floor_games ignores game {BLACK_GOLD_21_GAME_ID} "
                "(Black Gold 21 has no progressive)",
                flush=True,
            )
            continue
        if gid not in capable:
            print(f"WARNING - progressive_floor_games skipping invalid game_id {gid}", flush=True)
            continue
        if gid not in parsed:
            parsed.append(gid)
    games = tuple(parsed)
    if len(games) > max_count:
        print(
            f"WARNING - progressive_floor_games has {len(games)} entries; "
            f"trimming to {max_count} (black_gold_21_enabled={black_gold_21_enabled})",
            flush=True,
        )
        games = games[:max_count]
    return games


def parse_progressive_floor_games(
    raw: str,
    *,
    black_gold_21_enabled: bool,
) -> Tuple[int, ...]:
    """Parse an explicit floor list. Empty input → empty tuple (no invented 1–12)."""
    if not raw.strip():
        return ()
    parsed: List[int] = []
    for part in raw.split(","):
        text = part.strip()
        if not text:
            continue
        parsed.append(int(text))
    return sanitize_progressive_floor_games(
        parsed,
        black_gold_21_enabled=black_gold_21_enabled,
    )


def parse_activity_tiers(raw: str) -> Tuple[Tuple[int, int], ...]:
    tiers: List[Tuple[int, int]] = []
    text = raw.strip() or DEFAULT_ACTIVITY_TIERS_RAW
    for part in text.split(","):
        piece = part.strip()
        if not piece:
            continue
        if ":" not in piece:
            print(
                f"WARNING - invalid activity_tiers entry {piece!r}; "
                "expected margin_cents_as_dollars:percent (0.50:100 = 50¢ margin → 100%)",
                flush=True,
            )
            continue
        dollars_text, percent_text = piece.split(":", 1)
        pennies = parse_display_to_pennies(dollars_text.strip())
        percent = int(percent_text.strip())
        percent = max(0, min(100, percent))
        tiers.append((pennies, percent))
    tiers.sort(key=lambda item: item[0], reverse=True)
    if not tiers:
        return parse_activity_tiers(DEFAULT_ACTIVITY_TIERS_RAW)
    return tuple(tiers)


def focus_percent_for_average_delta(
    average_delta_pennies: float,
    tiers: Sequence[Tuple[int, int]],
) -> int:
    for threshold, percent in tiers:
        if average_delta_pennies >= threshold:
            return percent
    return 0


def mode_1_total_cycle_sec(settings: OhdProgressiveSettings) -> float:
    return len(settings.floor_games) * float(settings.game_time_sec)


def build_mode_1_schedule(
    settings: OhdProgressiveSettings,
    rotation_index: int = 0,
) -> Tuple[Schedule, int]:
    duration = float(settings.game_time_sec)
    games = settings.floor_games
    if not games:
        return [], 0
    schedule: Schedule = []
    for offset in range(len(games)):
        game_id = games[(rotation_index + offset) % len(games)]
        schedule.append((game_id, duration))
    next_index = (rotation_index + len(games)) % len(games)
    return schedule, next_index


def build_interleaved_focus_schedule(
    focus_game: int,
    settings: OhdProgressiveSettings,
    focus_fraction: float,
    rotation_index: int,
) -> Tuple[Schedule, int]:
    """Normal rotation (excluding highlight) interrupted by highlight slots."""
    active = settings.floor_games
    game_time = float(settings.game_time_sec)
    if focus_game not in active:
        return build_mode_1_schedule(settings, rotation_index)

    if focus_fraction >= 1.0:
        total = mode_1_total_cycle_sec(settings)
        return [(focus_game, total)], rotation_index

    rotation = [g for g in active if g != focus_game]
    if not rotation:
        total = mode_1_total_cycle_sec(settings)
        return [(focus_game, total)], rotation_index

    total = mode_1_total_cycle_sec(settings)
    focus_target = total * focus_fraction
    other_target = total - focus_target

    focus_remaining = focus_target
    other_remaining = other_target
    rot_idx = rotation_index
    schedule: Schedule = []
    batch_size = max(1, int(settings.rotation_batch))

    while focus_remaining > 0.01 or other_remaining > 0.01:
        batch = 0
        while other_remaining > 0.01 and batch < batch_size:
            game_id = rotation[rot_idx % len(rotation)]
            rot_idx += 1
            seg = min(game_time, other_remaining)
            if seg <= 0:
                break
            schedule.append((game_id, seg))
            other_remaining -= seg
            batch += 1

        if focus_remaining > 0.01:
            seg = min(game_time, focus_remaining)
            schedule.append((focus_game, seg))
            focus_remaining -= seg
        elif other_remaining <= 0.01:
            break

    return schedule, rot_idx % len(rotation)


class OhdProgressiveScheduler:
    def __init__(self, settings: OhdProgressiveSettings, reader: EthergoldReader):
        self.settings = settings
        self.reader = reader
        self._rotation_index = 0
        self._last_build_info: Optional[Mode34BuildInfo] = None
        self._last_schedule: Schedule = []
        self.apply_reader_settings()

    def update_settings(self, settings: OhdProgressiveSettings) -> None:
        self.settings = settings
        self.apply_reader_settings()

    def apply_reader_settings(self) -> None:
        self.reader.configure_activity_averaging(
            self.settings.activity_min_samples,
            self.settings.activity_window_sec,
        )

    def _resolve_focus_game(
        self, floor_games: Sequence[int]
    ) -> Tuple[Optional[int], str]:
        rank = rank_floor_activity(
            self.reader, floor_games, self.settings.activity_min_samples
        )
        if rank.leader_game is not None:
            return rank.leader_game, f"rank_{rank.metric}"
        return None, "none"

    def _resolve_mode4_focus_fraction(
        self,
        focus_game: int,
        settings: OhdProgressiveSettings,
        rank: Optional[FloorActivityRank] = None,
    ) -> Tuple[float, Optional[int], str]:
        if rank is None:
            rank = rank_floor_activity(
                self.reader, settings.floor_games, settings.activity_min_samples
            )
        tiers = settings.activity_tiers
        margin = rank.margin
        active_games = sum(1 for _, score in rank.ranked if score > 0)

        if rank.leader_score > 0 and active_games == 1:
            return (
                1.0,
                100,
                f"sole_active_lock({rank.metric})",
            )

        tier_pct = focus_percent_for_average_delta(margin, tiers)
        reason_suffix = f"margin_tier(margin={margin:.1f}c,{rank.metric})"

        if tier_pct > 0:
            return (
                tier_pct / 100.0,
                tier_pct,
                reason_suffix,
            )

        if rank.leader_score <= 0:
            return (
                settings.focus_fraction,
                None,
                "warmup_default_focus",
            )

        return 0.0, 0, "margin_below_lowest_tier"

    def format_mode4_diagnosis(self, mode: int) -> str:
        """One JSON line: live activity weights and last schedule build (on-demand dump)."""
        settings = self.settings
        floor = list(settings.floor_games)
        rank = rank_floor_activity(self.reader, floor, settings.activity_min_samples)
        diagnostics = self.reader.get_activity_diagnostics(floor)

        games_detail: Dict[str, Dict[str, float | int]] = {}
        for game_id in floor:
            detail = diagnostics.get(game_id, {})
            samples = int(detail.get("samples", 0))
            avg = float(detail.get("avg_pennies", 0.0))
            delta = float(detail.get("last_delta_pennies", 0))
            score = avg if rank.metric == "rolling_avg" and samples >= settings.activity_min_samples else delta
            games_detail[str(game_id)] = {
                "samples": samples,
                "avg_c": round(avg, 2),
                "delta_c": int(delta),
                "score_c": round(score, 2),
                "rank": next(
                    (idx + 1 for idx, (gid, _) in enumerate(rank.ranked) if gid == game_id),
                    0,
                ),
            }

        last = self._last_build_info
        payload: Dict[str, object] = {
            "utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
            "mode": mode,
            "floor_games": floor,
            "min_samples": settings.activity_min_samples,
            "min_samples_note": "~12 master broadcasts at ~5s (~60s) before rolling avg rank",
            "window_sec": settings.activity_window_sec,
            "tiers_c": [{"margin_thresh_c": t, "pct": p} for t, p in settings.activity_tiers],
            "rank_metric": rank.metric,
            "leader": rank.leader_game,
            "leader_score_c": round(rank.leader_score, 2),
            "runner_up_score_c": round(rank.runner_up_score, 2),
            "margin_c": round(rank.margin, 2),
            "margin_tier_pct": focus_percent_for_average_delta(
                rank.margin, settings.activity_tiers
            ),
            "active_games": sum(1 for _, score in rank.ranked if score > 0),
            "sole_active_lock": rank.leader_score > 0
            and sum(1 for _, score in rank.ranked if score > 0) == 1,
            "ranked": [
                {"game": gid, "score_c": round(score, 2)}
                for gid, score in rank.ranked[:5]
            ],
            "games": games_detail,
            "last_build": None
            if last is None
            else {
                "focus": last.focus_game,
                "focus_frac": round(last.focus_fraction, 3),
                "tier_pct": last.tier_percent,
                "strategy": last.strategy,
                "reason": last.fallback_reason,
            },
        }
        if self._last_schedule:
            counts: Dict[int, float] = {}
            for game_id, duration in self._last_schedule:
                counts[game_id] = counts.get(game_id, 0.0) + duration
            payload["last_schedule_sec"] = {
                str(game_id): round(secs, 1) for game_id, secs in counts.items()
            }
        return json.dumps(payload, separators=(",", ":"))

    def build_schedule(self, mode: int) -> Schedule:
        settings = self.settings
        settings = OhdProgressiveSettings(
            floor_games=settings.floor_games,
            game_time_sec=float(clamp_mode_1_display_time(int(settings.game_time_sec))),
            focus_fraction=settings.focus_fraction,
            rotation_batch=settings.rotation_batch,
            activity_min_samples=settings.activity_min_samples,
            activity_window_sec=settings.activity_window_sec,
            activity_tiers=settings.activity_tiers,
        )

        if mode == 1:
            schedule, self._rotation_index = build_mode_1_schedule(
                settings, self._rotation_index
            )
            return schedule

        if mode == 2:
            focus_game = self.reader.get_highest_value_game(settings.floor_games)
            if focus_game is None:
                schedule, self._rotation_index = build_mode_1_schedule(
                    settings, self._rotation_index
                )
                return schedule
            schedule, self._rotation_index = build_interleaved_focus_schedule(
                focus_game,
                settings,
                settings.focus_fraction,
                self._rotation_index,
            )
            return schedule

        if mode in (3, 4):
            rank = rank_floor_activity(
                self.reader, settings.floor_games, settings.activity_min_samples
            )
            focus_game = rank.leader_game
            focus_source = f"rank_{rank.metric}" if focus_game is not None else "none"
            if focus_game is None:
                self._last_build_info = Mode34BuildInfo(
                    None, 0.0, None, "mode1_fallback", "no_activity"
                )
                schedule, self._rotation_index = build_mode_1_schedule(
                    settings, self._rotation_index
                )
                self._last_schedule = schedule
                return schedule

            if mode == 3:
                focus_fraction = settings.focus_fraction
                tier_percent: Optional[int] = int(round(focus_fraction * 100))
                build_reason = f"mode3_{focus_source}"
            else:
                focus_fraction, tier_percent, tier_reason = self._resolve_mode4_focus_fraction(
                    focus_game, settings, rank=rank
                )
                build_reason = f"mode4_{focus_source}_{tier_reason}"
                if focus_fraction <= 0:
                    self._last_build_info = Mode34BuildInfo(
                        focus_game, 0.0, tier_percent, "mode1_fallback", build_reason
                    )
                    schedule, self._rotation_index = build_mode_1_schedule(
                        settings, self._rotation_index
                    )
                    self._last_schedule = schedule
                    return schedule

            schedule, self._rotation_index = build_interleaved_focus_schedule(
                focus_game,
                settings,
                focus_fraction,
                self._rotation_index,
            )
            self._last_build_info = Mode34BuildInfo(
                focus_game,
                focus_fraction,
                tier_percent,
                "interleaved_focus",
                build_reason,
            )
            self._last_schedule = schedule
            return schedule

        schedule, self._rotation_index = build_mode_1_schedule(
            settings, self._rotation_index
        )
        self._last_schedule = schedule
        return schedule


def summarize_schedule(mode: int, schedule: Schedule, settings: OhdProgressiveSettings) -> str:
    total = sum(duration for _, duration in schedule)
    counts: Dict[int, float] = {}
    for game_id, duration in schedule:
        counts[game_id] = counts.get(game_id, 0.0) + duration
    focus_bits = ", ".join(
        f"g{game_id}={secs:.1f}s"
        for game_id, secs in sorted(counts.items(), key=lambda item: -item[1])[:4]
    )
    return (
        f"OHD prog mode {mode}: {len(settings.floor_games)} floor games, "
        f"{len(schedule)} segments, {total:.1f}s cycle ({focus_bits}…)"
    )


def load_ohd_progressive_settings(
    config,
    *,
    floor_games_override: Optional[Sequence[int]] = None,
    black_gold_21_override: Optional[bool] = None,
) -> OhdProgressiveSettings:
    if black_gold_21_override is not None:
        black_gold_21_enabled = black_gold_21_override
    else:
        black_gold_21_enabled = (
            config.getboolean("TOPBOX", "BLACK_GOLD_21_ENABLED")
            if config.has_option("TOPBOX", "BLACK_GOLD_21_ENABLED")
            else False
        )
    if floor_games_override is not None:
        floor_games = sanitize_progressive_floor_games(
            floor_games_override,
            black_gold_21_enabled=black_gold_21_enabled,
        )
    else:
        floor_raw = (
            config.get("TOPBOX", "PROGRESSIVE_FLOOR_GAMES")
            if config.has_option("TOPBOX", "PROGRESSIVE_FLOOR_GAMES")
            else ""
        )
        floor_games = parse_progressive_floor_games(
            floor_raw,
            black_gold_21_enabled=black_gold_21_enabled,
        )
    focus_fraction = (
        config.getfloat("TOPBOX", "OHD_FOCUS_FRACTION")
        if config.has_option("TOPBOX", "OHD_FOCUS_FRACTION")
        else DEFAULT_FOCUS_FRACTION
    )
    rotation_batch = (
        config.getint("TOPBOX", "OHD_ROTATION_BATCH")
        if config.has_option("TOPBOX", "OHD_ROTATION_BATCH")
        else DEFAULT_ROTATION_BATCH
    )
    activity_min_samples = (
        config.getint("TOPBOX", "ACTIVITY_AVG_MIN_SAMPLES")
        if config.has_option("TOPBOX", "ACTIVITY_AVG_MIN_SAMPLES")
        else DEFAULT_ACTIVITY_MIN_SAMPLES
    )
    activity_window_sec = (
        config.getfloat("TOPBOX", "ACTIVITY_AVG_WINDOW_SEC")
        if config.has_option("TOPBOX", "ACTIVITY_AVG_WINDOW_SEC")
        else DEFAULT_ACTIVITY_WINDOW_SEC
    )
    activity_tiers_raw = (
        config.get("TOPBOX", "ACTIVITY_TIERS")
        if config.has_option("TOPBOX", "ACTIVITY_TIERS")
        else DEFAULT_ACTIVITY_TIERS_RAW
    )

    return OhdProgressiveSettings(
        floor_games=floor_games,
        game_time_sec=float(clamp_mode_1_display_time(config.getint("TOPBOX", "MODE_1_DISPLAY_TIME"))),
        focus_fraction=max(0.0, min(1.0, focus_fraction)),
        rotation_batch=max(1, rotation_batch),
        activity_min_samples=max(1, activity_min_samples),
        activity_window_sec=max(1.0, activity_window_sec),
        activity_tiers=parse_activity_tiers(activity_tiers_raw),
    )
