"""Done-flow helpers and cmd_done command for Steam Backlog Enforcer."""

from __future__ import annotations

import logging

from python_pkg.steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
from python_pkg.steam_backlog_enforcer.config import Config, State, load_snapshot
from python_pkg.steam_backlog_enforcer.enforcer import (
    enforce_allowed_game,
    send_notification,
)
from python_pkg.steam_backlog_enforcer.game_install import (
    _echo,
    install_game,
    is_game_installed,
    uninstall_other_games,
)
from python_pkg.steam_backlog_enforcer.hltb import (
    fetch_hltb_confidence_cached,
    fetch_hltb_times_cached,
    load_hltb_cache,
    load_hltb_count_comp_cache,
    load_hltb_polls_cache,
    save_hltb_cache,
)
from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games
from python_pkg.steam_backlog_enforcer.scanning import (
    _confidence_fail_reasons,
    _pick_next_shortest_candidate,
    _refresh_candidate_confidence,
    pick_next_game,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient

_REASSIGN_REFRESH_LIMIT = 50
logger = logging.getLogger(__name__)


def _backfill_polls_for_finished(
    state: State,
    extra_app_id: int | None = None,
) -> dict[int, int]:
    """Lazily fetch poll counts for already-finished games missing them.

    If ``extra_app_id`` is provided and its poll count is missing, it is
    refreshed alongside finished games (used to populate polls for the
    currently-assigned game on first run after the schema upgrade).
    """
    polls_cache = load_hltb_polls_cache()
    snapshot_data = load_snapshot() or []
    name_by_id = {d["app_id"]: d["name"] for d in snapshot_data}
    candidate_ids = list(state.finished_app_ids)
    if extra_app_id is not None and polls_cache.get(extra_app_id, 0) == 0:
        candidate_ids.append(extra_app_id)
    missing = [
        (aid, name_by_id[aid])
        for aid in candidate_ids
        if aid in name_by_id and polls_cache.get(aid, 0) == 0
    ]
    if not missing:
        return polls_cache

    _echo(f"  Backfilling HLTB poll counts for {len(missing)} game(s)...")
    cache = load_hltb_cache()
    preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache}
    for aid, _name in missing:
        cache.pop(aid, None)
    save_hltb_cache(cache, polls_cache)

    fetch_hltb_confidence_cached(missing)

    refreshed_hours = load_hltb_cache()
    refreshed_polls = load_hltb_polls_cache()
    for aid, prior_hours in preserved_hours.items():
        if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0:
            refreshed_hours[aid] = prior_hours
    save_hltb_cache(refreshed_hours, refreshed_polls)
    return refreshed_polls


def _report_assigned_confidence(
    app_id: int,
    state: State,
) -> None:
    """Print HLTB poll-count confidence for the currently-assigned game."""
    polls_cache = _backfill_polls_for_finished(state, extra_app_id=app_id)
    chosen_polls = polls_cache.get(app_id, 0)

    finished_polls = [
        (polls_cache[aid], aid)
        for aid in state.finished_app_ids
        if polls_cache.get(aid, 0) > 0 and aid != app_id
    ]
    snapshot_data = load_snapshot() or []
    name_by_id = {d["app_id"]: d["name"] for d in snapshot_data}

    warning = ""
    if finished_polls:
        min_polls = min(p for p, _ in finished_polls)
        if 0 < chosen_polls < min_polls:
            warning = "  ⚠ NEW LOW — estimate may be unreliable"
        elif chosen_polls == 0:
            warning = "  ⚠ no polls recorded — estimate may be unreliable"
    elif chosen_polls == 0:
        warning = "  ⚠ no polls recorded — estimate may be unreliable"

    _echo(f"  HLTB confidence: {chosen_polls} polled completionist times{warning}")
    if finished_polls:
        min_polls, min_aid = min(finished_polls)
        min_name = name_by_id.get(min_aid, f"AppID={min_aid}")
        _echo(f"  Historical min among finished: {min_polls} ({min_name})")


def _apply_cached_hours_to_games(
    games: list[GameInfo],
    hltb_cache: dict[int, float],
) -> None:
    """Overlay cached HLTB hours onto games (including cached misses)."""
    for game in games:
        if game.app_id in hltb_cache:
            game.completionist_hours = hltb_cache[game.app_id]


def _apply_cached_confidence_to_games(games: list[GameInfo]) -> None:
    """Overlay cached confidence counters onto snapshot-backed game objects."""
    polls_cache = load_hltb_polls_cache()
    count_comp_cache = load_hltb_count_comp_cache()
    for game in games:
        if game.app_id in polls_cache:
            game.comp_100_count = polls_cache[game.app_id]
        if game.app_id in count_comp_cache:
            game.count_comp = count_comp_cache[game.app_id]


def _refresh_uncached_shortlist_hours(
    games: list[GameInfo],
    hltb_cache: dict[int, float],
    skip: set[int],
    *,
    upper_bound_hours: float | None = None,
) -> None:
    """Refresh likely-short uncached games to avoid stale snapshot decisions."""
    shorter_uncached = [
        (g.app_id, g.name)
        for g in sorted(
            (
                game
                for game in games
                if not game.is_complete
                and game.app_id not in skip
                and game.completionist_hours > 0
                and game.app_id not in hltb_cache
                and (
                    upper_bound_hours is None
                    or game.completionist_hours < upper_bound_hours
                )
            ),
            key=lambda game: game.completionist_hours,
        )[:_REASSIGN_REFRESH_LIMIT]
    ]
    if shorter_uncached:
        refreshed = fetch_hltb_times_cached(shorter_uncached)
        hltb_cache.update(refreshed)


def _should_reassign_candidate(
    playable: GameInfo,
    current_hours: float,
    *,
    force_reassign: bool,
) -> bool:
    """Return whether a playable candidate should trigger reassignment."""
    if force_reassign:
        return True
    if current_hours > 0:
        return playable.completionist_hours < current_hours
    return True


def _echo_reassign_decision(
    playable: GameInfo,
    current_hours: float,
    current_fail_reasons: list[str],
    *,
    force_reassign: bool,
) -> None:
    """Emit a human-readable reassignment reason."""
    if force_reassign:
        _echo(
            f"\n  Reassigning: current game confidence too low "
            f"({'; '.join(current_fail_reasons)})"
        )
        return
    if current_hours > 0:
        _echo(
            f"\n  Reassigning: {playable.name} is shorter"
            f" (~{playable.completionist_hours:.1f}h vs ~{current_hours:.1f}h)"
        )
        return
    _echo(
        f"\n  Reassigning: current game has no usable HLTB time; "
        f"picked {playable.name} (~{playable.completionist_hours:.1f}h)"
    )


def _try_reassign_shorter_game(
    hltb_cache: dict[int, float],
    app_id: int,
    hours: float,
    state: State,
    config: Config,
) -> bool:
    """Check if a shorter game is available and reassign if so."""
    snapshot_data = load_snapshot()
    if not snapshot_data:
        return False
    all_games = [GameInfo.from_snapshot(d) for d in snapshot_data]
    skip = set(config.skip_app_ids) | set(state.finished_app_ids)
    _refresh_uncached_shortlist_hours(
        all_games,
        hltb_cache,
        skip,
        upper_bound_hours=hours,
    )
    _apply_cached_hours_to_games(all_games, hltb_cache)
    _apply_cached_confidence_to_games(all_games)
    current_game = next((g for g in all_games if g.app_id == app_id), None)
    if current_game is not None and _confidence_fail_reasons(current_game):
        _refresh_candidate_confidence(current_game)
    current_fail_reasons = (
        _confidence_fail_reasons(current_game) if current_game is not None else []
    )
    force_reassign = bool(current_fail_reasons)
    candidates = [
        g
        for g in all_games
        if not g.is_complete and g.app_id not in skip and g.completionist_hours > 0
    ]
    if not force_reassign and hours > 0:
        candidates = [g for g in candidates if g.completionist_hours < hours]

    candidates.sort(key=lambda g: g.completionist_hours)
    candidates = [c for c in candidates if c.app_id != app_id]
    if not candidates:
        return False

    playable, _confidence_skipped, _linux_skipped = _pick_next_shortest_candidate(
        candidates,
    )
    if playable is None:
        return False

    if not _should_reassign_candidate(
        playable,
        hours,
        force_reassign=force_reassign,
    ):
        return False
    _echo_reassign_decision(
        playable,
        hours,
        current_fail_reasons,
        force_reassign=force_reassign,
    )
    pick_next_game(all_games, state, config)

    if state.current_app_id is not None:
        owned_ids = get_all_owned_app_ids(config)
        if owned_ids:
            hidden = hide_other_games(owned_ids, state.current_app_id)
            if hidden > 0:
                _echo(f"\n  Library: hid {hidden} games")

    return True


def _finalize_completion(
    config: Config,
    state: State,
    game_name: str,
    app_id: int,
) -> None:
    """Mark game complete, pick next, hide non-assigned games, notify."""
    _echo(f"\n  COMPLETED: {game_name}!")
    state.finished_app_ids.append(app_id)

    snapshot_data = load_snapshot()
    _echo("\nPicking next game...")
    if not snapshot_data:
        _echo("  No snapshot found. Run 'scan' first.")
        state.current_app_id = None
        state.current_game_name = ""
        state.save()
        return

    games = [GameInfo.from_snapshot(d) for d in snapshot_data]
    hltb_cache = load_hltb_cache()
    skip = set(config.skip_app_ids) | set(state.finished_app_ids)
    _refresh_uncached_shortlist_hours(games, hltb_cache, skip)
    _apply_cached_hours_to_games(games, hltb_cache)
    pick_next_game(games, state, config)

    if state.current_app_id is None:
        _echo("  No more games to assign!")
        return

    owned_ids = get_all_owned_app_ids(config)
    if owned_ids:
        hidden = hide_other_games(owned_ids, state.current_app_id)
        if hidden > 0:
            _echo(f"\n  Library: hid {hidden} games")

    if not is_game_installed(state.current_app_id):
        logger.info(
            "Assigned game still missing after library reconciliation; "
            "re-triggering install"
        )
        _echo(
            "\n  Assigned game still missing after library reconciliation; "
            "re-triggering install..."
        )
        _echo(f"\n  Auto-installing {state.current_game_name}...")
        install_game(
            state.current_app_id,
            state.current_game_name,
            config.steam_id,
            use_steam_protocol=True,
        )

    send_notification(
        "Game Complete!",
        f"Finished {game_name}! Now playing: {state.current_game_name}",
    )
    _echo(f"\nAll done! Go play {state.current_game_name}!")


def _enforce_on_done(config: Config, state: State) -> None:
    """Run a single enforcement pass during the 'done' command.

    Kills unauthorized game processes, uninstalls unauthorized games,
    and ensures the assigned game is installed.
    """
    if state.current_app_id is None:
        return

    if config.kill_unauthorized_games:
        violations = enforce_allowed_game(
            state.current_app_id,
            kill_unauthorized=True,
        )
        for pid, app_id in violations:
            _echo(f"  Killed unauthorized game: AppID={app_id} (PID={pid})")

    if config.uninstall_other_games:
        count = uninstall_other_games(state.current_app_id)
        if count:
            _echo(f"  Uninstalled {count} unauthorized game(s)")

    if not is_game_installed(state.current_app_id):
        _echo(f"  Re-installing {state.current_game_name}...")
        install_game(
            state.current_app_id,
            state.current_game_name,
            config.steam_id,
            use_steam_protocol=True,
        )

    # Reconcile library: hide non-assigned games and unhide the assigned one.
    # Without this, an interrupted earlier completion can leave the new
    # assigned game hidden and stale games visible.
    owned_ids = get_all_owned_app_ids(config)
    if owned_ids:
        hidden = hide_other_games(owned_ids, state.current_app_id)
        if hidden > 0:
            _echo(f"  Library: hid {hidden} games")


def cmd_done(config: Config, state: State) -> None:
    """Check completion, pick next game, uninstall & hide.

    All-in-one command for after finishing a game:
    1. Verify 100% achievements on Steam.
    2. Pick the next game (shortest HLTB leisure+dlc time).
    3. Uninstall all non-assigned games.
    4. Hide all non-assigned games in the Steam library.
    5. Install the newly assigned game.
    """
    if state.current_app_id is None:
        _echo("No game currently assigned. Run 'scan' first.")
        return

    client = SteamAPIClient(config.steam_api_key, config.steam_id)
    game_name = state.current_game_name
    app_id = state.current_app_id

    _echo(f"Checking {game_name} (AppID={app_id})...")
    game = client.refresh_single_game(app_id, game_name)
    if game is None:
        _echo("  Could not fetch achievement data from Steam.")
        return

    _echo(
        f"  Progress: {game.unlocked_achievements}/{game.total_achievements}"
        f" ({game.completion_pct:.1f}%)"
    )

    hltb_cache = load_hltb_cache()
    hours = hltb_cache.get(app_id, -1.0)
    if hours < 0:
        hltb_cache = fetch_hltb_times_cached([(app_id, game_name)])
        hours = hltb_cache.get(app_id, -1.0)
    if hours > 0:
        _echo(f"  HLTB leisure+dlc estimate: {hours:.1f} hours")
    _report_assigned_confidence(app_id, state)

    if _try_reassign_shorter_game(hltb_cache, app_id, hours, state, config):
        return

    if not game.is_complete:
        remaining = game.total_achievements - game.unlocked_achievements
        _echo(f"\n  NOT COMPLETE: {remaining} achievements remaining. Keep going!")
        _enforce_on_done(config, state)
        return

    _finalize_completion(config, state, game_name, app_id)
