"""
Play outcome definitions for card-based resolution system.

Both SBA and PD leagues use the same resolution mechanics:
  1. Roll 1d6 → determines column (1-3: batter card, 4-6: pitcher card)
  2. Roll 2d6 → selects row 2-12 on that card
  3. Roll 1d20 → resolves split results if needed
  4. Outcome from card is a PlayOutcome enum value

League Differences:
  - PD: Card data digitized in PdBattingRating/PdPitchingRating
         Can auto-resolve using probabilities OR manual selection
  - SBA: Physical cards only (not digitized)
         Players read physical cards and manually enter outcomes

This module defines:
  - PlayOutcome enum (universal outcome types)
  - ResultChart abstract base class
  - Helper functions for hit location calculation

Author: Claude
Date: 2025-10-28, Updated 2025-10-30
"""

import logging
import random
from abc import ABC, abstractmethod
from enum import Enum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from app.core.dice import AbRoll
    from app.models.game_models import GameState
    from app.models.player_models import BasePlayer

logger = logging.getLogger(f"{__name__}")


class PlayOutcome(str, Enum):
    """
    Universal play outcome types for both SBA and PD leagues.

    These represent all possible results that can appear on player cards.
    Each outcome triggers specific game logic in the PlayResolver.

    Usage:
      - PD: Outcomes determined from PdBattingRating/PdPitchingRating data
      - SBA: Outcomes manually entered by players reading physical cards
    """

    # ==================== Outs ====================
    STRIKEOUT = "strikeout"

    # Groundballs - 3 variants for different defensive outcomes
    GROUNDBALL_A = "groundball_a"  # Double play if possible, else groundout
    GROUNDBALL_B = "groundball_b"  # Standard groundout
    GROUNDBALL_C = "groundball_c"  # Standard groundout

    # Flyouts - 4 variants for different trajectories/depths
    FLYOUT_A = "flyout_a"  # Deep - all runners advance
    FLYOUT_B = "flyout_b"  # Medium - R3 scores, R2 DECIDE, R1 holds
    FLYOUT_BQ = "flyout_bq"  # Medium-shallow (fly(b)?) - R3 DECIDE, R2 holds, R1 holds
    FLYOUT_C = "flyout_c"  # Shallow - all runners hold

    LINEOUT = "lineout"
    POPOUT = "popout"

    # ==================== Hits ====================
    # Singles - variants for different advancement rules
    SINGLE_1 = "single_1"  # Single with standard advancement
    SINGLE_2 = "single_2"  # Single with enhanced advancement
    SINGLE_UNCAPPED = "single_uncapped"  # si(cf) - pitching card, decision tree

    # Doubles - variants for batter advancement
    DOUBLE_2 = "double_2"  # Double to 2nd base
    DOUBLE_3 = "double_3"  # Double to 3rd base (extra advancement)
    DOUBLE_UNCAPPED = "double_uncapped"  # do(cf) - pitching card, decision tree

    TRIPLE = "triple"
    HOMERUN = "homerun"

    # ==================== Walks/HBP ====================
    WALK = "walk"
    HIT_BY_PITCH = "hbp"
    INTENTIONAL_WALK = "intentional_walk"

    # ==================== Errors ====================
    ERROR = "error"

    # ==================== X-Check Plays ====================
    # X-Check: Defense-dependent plays requiring range/error rolls
    # Resolution determines actual outcome (hit/out/error)
    X_CHECK = "x_check"  # Play.check_pos contains position, resolve via tables

    # ==================== Interrupt Plays ====================
    # These are logged as separate plays with Play.pa = 0
    WILD_PITCH = "wild_pitch"  # Play.wp = 1
    PASSED_BALL = "passed_ball"  # Play.pb = 1
    STOLEN_BASE = "stolen_base"  # Play.sb = 1
    CAUGHT_STEALING = "caught_stealing"  # Play.cs = 1
    BALK = "balk"  # Play.balk = 1 / Logged during steal attempt
    PICK_OFF = "pick_off"  # Play.pick_off = 1 / Runner picked off

    # ==================== Ballpark Power ====================
    BP_HOMERUN = "bp_homerun"  # Ballpark HR (Play.bphr = 1)
    BP_SINGLE = "bp_single"  # Ballpark single (Play.bp1b = 1)
    BP_FLYOUT = "bp_flyout"  # Ballpark flyout (Play.bpfo = 1)
    BP_LINEOUT = "bp_lineout"  # Ballpark lineout (Play.bplo = 1)

    # ==================== Helper Methods ====================

    def is_hit(self) -> bool:
        """Check if outcome is a hit (counts toward batting average)."""
        return self in {
            self.SINGLE_1,
            self.SINGLE_2,
            self.SINGLE_UNCAPPED,
            self.DOUBLE_2,
            self.DOUBLE_3,
            self.DOUBLE_UNCAPPED,
            self.TRIPLE,
            self.HOMERUN,
            self.BP_HOMERUN,
            self.BP_SINGLE,
        }

    def is_out(self) -> bool:
        """Check if outcome records an out."""
        return self in {
            self.STRIKEOUT,
            self.GROUNDBALL_A,
            self.GROUNDBALL_B,
            self.GROUNDBALL_C,
            self.FLYOUT_A,
            self.FLYOUT_B,
            self.FLYOUT_BQ,
            self.FLYOUT_C,
            self.LINEOUT,
            self.POPOUT,
            self.CAUGHT_STEALING,
            self.PICK_OFF,
            self.BP_FLYOUT,
            self.BP_LINEOUT,
        }

    def is_walk(self) -> bool:
        """Check if outcome is a walk."""
        return self in {self.WALK, self.INTENTIONAL_WALK}

    def is_uncapped(self) -> bool:
        """
        Check if outcome is uncapped (requires advancement decision).

        Uncapped hits only trigger decisions when on_base_code > 0.
        """
        return self in {self.SINGLE_UNCAPPED, self.DOUBLE_UNCAPPED}

    def is_interrupt(self) -> bool:
        """
        Check if outcome is an interrupt play (logged with pa=0).

        Interrupt plays don't change the batter, only advance runners.
        """
        return self in {
            self.WILD_PITCH,
            self.PASSED_BALL,
            self.STOLEN_BASE,
            self.CAUGHT_STEALING,
            self.BALK,
            self.PICK_OFF,
        }

    def is_extra_base_hit(self) -> bool:
        """Check if outcome is an extra-base hit (2B, 3B, HR)."""
        return self in {
            self.DOUBLE_2,
            self.DOUBLE_3,
            self.DOUBLE_UNCAPPED,
            self.TRIPLE,
            self.HOMERUN,
            self.BP_HOMERUN,
        }

    def is_x_check(self) -> bool:
        """Check if outcome requires x-check resolution."""
        return self == self.X_CHECK

    def get_bases_advanced(self) -> int:
        """
        Get number of bases batter advances (for standard outcomes).

        Returns:
            0 for outs/walks, 1-4 for hits

        Note: Uncapped hits return base value; actual advancement
              determined by decision tree.
        """
        if self in {self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED, self.BP_SINGLE}:
            return 1
        if self in {self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED}:
            return 2
        if self == self.TRIPLE:
            return 3
        if self in {self.HOMERUN, self.BP_HOMERUN}:
            return 4
        return 0

    def requires_hit_location(self) -> bool:
        """
        Check if this outcome requires hit location for runner advancement.

        Based on advancement charts (Infield In, Infield Back, etc.), only certain
        outcomes care about specific hit location for determining runner advancement.

        Returns:
            True if location matters for advancement logic, False otherwise
        """
        return self in {
            # Groundballs - location determines which fielder makes play
            self.GROUNDBALL_A,
            self.GROUNDBALL_B,
            self.GROUNDBALL_C,
            # Flyouts - location affects tag-up opportunities
            self.FLYOUT_A,
            self.FLYOUT_B,
            self.FLYOUT_BQ,
            self.FLYOUT_C,
            # Uncapped hits - location determines defender used in interactive play
            self.SINGLE_UNCAPPED,
            self.DOUBLE_UNCAPPED,
        }


# ============================================================================
# HIT LOCATION HELPER
# ============================================================================


def calculate_hit_location(
    outcome: PlayOutcome,
    batter_handedness: str,
) -> str | None:
    """
    Calculate hit location based on outcome and batter handedness.

    Uses pull rates and handedness to determine which fielder makes the play.
    Location is critical for runner advancement logic (Task 4).

    Args:
        outcome: The play outcome
        batter_handedness: 'L' or 'R'

    Returns:
        Position string ('1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C') or None

    Pull Rate Distribution:
        - 45% pull side (RHB left, LHB right)
        - 35% center
        - 20% opposite field

    Groundball Locations: P, C, 1B, 2B, SS, 3B (infield)
    Fly Ball Locations: LF, CF, RF (outfield)
    """
    # Check if location matters for this outcome
    if not outcome.requires_hit_location():
        return None

    # Determine if groundball or flyball
    is_groundball = outcome in {
        PlayOutcome.GROUNDBALL_A,
        PlayOutcome.GROUNDBALL_B,
        PlayOutcome.GROUNDBALL_C,
    }

    # Roll for distribution
    roll = random.random()  # 0.0 to 1.0

    if is_groundball:
        # Infield locations: 1B, 2B, SS, 3B, P, C
        if roll < 0.45:  # Pull side
            if batter_handedness == "R":
                # RHB pulls left (3B, SS)
                return random.choice(["3B", "SS"])
            # LHB
            # LHB pulls right (1B, 2B)
            return random.choice(["1B", "2B"])
        if roll < 0.80:  # Center (45% + 35% = 80%)
            # Up the middle (2B, SS, P, C)
            return random.choice(["2B", "SS", "P", "C"])
        # Opposite field (20%)
        if batter_handedness == "R":
            # RHB opposite is right (1B, 2B)
            return random.choice(["1B", "2B"])
        # LHB
        # LHB opposite is left (3B, SS)
        return random.choice(["3B", "SS"])
    # Fly ball locations: LF, CF, RF
    if roll < 0.45:  # Pull side
        if batter_handedness == "R":
            # RHB pulls left
            return "LF"
        # LHB
        # LHB pulls right
        return "RF"
    if roll < 0.80:  # Center
        return "CF"
    # Opposite field
    if batter_handedness == "R":
        # RHB opposite is right
        return "RF"
    # LHB
    # LHB opposite is left
    return "LF"


# ============================================================================
# RESULT CHART ABSTRACTION
# ============================================================================


class ResultChart(ABC):
    """
    Abstract base class for result chart implementations.

    Result charts determine play outcomes and hit locations. Different
    implementations exist for manual mode (humans read cards) and
    auto mode (system uses digitized ratings).

    Manual Mode (SBA + PD manual):
        - Humans roll dice and read physical cards
        - Players submit PlayOutcome + optional location
        - ResultChart not used (direct submission)

    Auto Mode (PD only):
        - System uses PdBattingRating/PdPitchingRating
        - Flips coin to choose batting or pitching card
        - Rolls against cumulative distribution
        - Returns outcome + calculated location
    """

    @abstractmethod
    def get_outcome(
        self,
        roll: "AbRoll",
        state: "GameState",
        batter: "BasePlayer",
        pitcher: "BasePlayer",
    ) -> tuple[PlayOutcome, str | None]:
        """
        Determine play outcome and hit location.

        Args:
            roll: Dice roll (column_d6, row_2d6, chaos_d20)
            state: Current game state
            batter: Batting player
            pitcher: Pitching player

        Returns:
            tuple: (outcome, hit_location)
                - outcome: PlayOutcome enum value
                - hit_location: Position string or None
        """
        pass


class ManualResultChart(ResultChart):
    """
    Result chart for manual mode (SBA + PD manual).

    In manual mode, humans read physical cards and submit outcomes directly
    via WebSocket. This class exists for interface completeness but should
    not be called - manual mode doesn't use result charts.

    Manual Flow:
        1. System rolls dice and presents to players
        2. Players read physical cards
        3. Players submit ManualOutcomeSubmission via WebSocket
        4. System validates and processes

    Note: This class is not actually used. Manual outcomes come through
          WebSocket handlers, not through ResultChart.get_outcome().
    """

    def get_outcome(
        self,
        roll: "AbRoll",
        state: "GameState",
        batter: "BasePlayer",
        pitcher: "BasePlayer",
    ) -> tuple[PlayOutcome, str | None]:
        """
        Not implemented for manual mode.

        Manual mode receives outcomes from humans via WebSocket, not from
        result chart calculations.

        Raises:
            NotImplementedError: Always - manual mode doesn't use this method
        """
        raise NotImplementedError(
            "Manual mode receives outcomes from humans via WebSocket. "
            "This method should not be called. Use ManualOutcomeSubmission instead."
        )


class PdAutoResultChart(ResultChart):
    """
    Auto-resolution result chart for PD league.

    Uses digitized PdBattingRating and PdPitchingRating data to automatically
    determine outcomes. This enables AI vs AI games and faster play.

    Process:
        1. Flip coin (50/50) to choose batting or pitching card
        2. Get rating for correct handedness matchup
        3. Build cumulative distribution from rating percentages
        4. Roll 1d100 to select outcome
        5. Calculate hit location based on handedness

    Note: Manual PD games don't use this - they follow the SBA manual flow.
    """

    def _select_card(
        self, batter: "BasePlayer", pitcher: "BasePlayer"
    ) -> tuple[dict | None, bool]:
        """
        Flip coin to choose batting or pitching card.

        Args:
            batter: Batting player (must be PdPlayer with batting_card)
            pitcher: Pitching player (must be PdPlayer with pitching_card)

        Returns:
            tuple: (rating_dict, is_batting_card)
                - rating_dict: Rating data or None if unavailable
                - is_batting_card: True if batting card selected, False if pitching
        """
        # Flip coin
        coin_flip = random.randint(1, 2)  # 1 or 2 (50/50)
        use_batting_card = coin_flip == 1

        if use_batting_card:
            # Use batting card - need pitcher handedness
            batting_card = getattr(batter, "batting_card", None)
            if not batting_card or not batting_card.ratings:
                logger.warning(
                    f"Batter {batter.name} missing batting card, falling back to pitching"
                )
                use_batting_card = False
            else:
                pitcher_hand = getattr(
                    getattr(pitcher, "pitching_card", None), "hand", "R"
                )
                rating = batting_card.ratings.get(
                    pitcher_hand, batting_card.ratings.get("R")
                )
                logger.debug(f"Selected batting card vs {pitcher_hand}HP")
                return (rating, True)

        # Use pitching card - need batter handedness
        pitching_card = getattr(pitcher, "pitching_card", None)
        if not pitching_card or not pitching_card.ratings:
            logger.warning(f"Pitcher {pitcher.name} missing pitching card")
            return (None, False)

        batter_hand = getattr(getattr(batter, "batting_card", None), "hand", "R")
        rating = pitching_card.ratings.get(batter_hand, pitching_card.ratings.get("R"))
        logger.debug(f"Selected pitching card vs {batter_hand}HB")
        return (rating, False)

    def _build_distribution(
        self, rating: dict, is_batting_card: bool
    ) -> list[tuple[float, PlayOutcome]]:
        """
        Build cumulative distribution from rating percentages.

        Args:
            rating: Rating dict (PdBattingRating or PdPitchingRating)
            is_batting_card: True if batting rating, False if pitching

        Returns:
            List of (cumulative_threshold, outcome) tuples sorted by threshold
        """
        distribution = []
        cumulative = 0.0

        # Common outcomes for both cards
        common_outcomes = [
            ("homerun", PlayOutcome.HOMERUN),
            ("bp_homerun", PlayOutcome.BP_HOMERUN),
            ("triple", PlayOutcome.TRIPLE),
            ("double_three", PlayOutcome.DOUBLE_3),
            ("double_two", PlayOutcome.DOUBLE_2),
            ("single_two", PlayOutcome.SINGLE_2),
            ("single_one", PlayOutcome.SINGLE_1),
            ("bp_single", PlayOutcome.BP_SINGLE),
            ("hbp", PlayOutcome.HIT_BY_PITCH),
            ("walk", PlayOutcome.WALK),
            ("strikeout", PlayOutcome.STRIKEOUT),
        ]

        # Add common outcomes
        for field, outcome in common_outcomes:
            percentage = getattr(rating, field, 0.0)
            if percentage > 0:
                cumulative += percentage
                distribution.append((cumulative, outcome))

        # Card-specific outcomes
        if is_batting_card:
            # Batting card specific
            batting_specific = [
                ("double_pull", PlayOutcome.DOUBLE_2),  # Map pull to double_2
                ("single_center", PlayOutcome.SINGLE_1),  # Map center to single_1
                ("lineout", PlayOutcome.LINEOUT),
                ("popout", PlayOutcome.POPOUT),
                ("flyout_a", PlayOutcome.FLYOUT_A),
                ("flyout_bq", PlayOutcome.FLYOUT_B),
                ("flyout_lf_b", PlayOutcome.FLYOUT_B),
                ("flyout_rf_b", PlayOutcome.FLYOUT_B),
                ("groundout_a", PlayOutcome.GROUNDBALL_A),
                ("groundout_b", PlayOutcome.GROUNDBALL_B),
                ("groundout_c", PlayOutcome.GROUNDBALL_C),
            ]
            for field, outcome in batting_specific:
                percentage = getattr(rating, field, 0.0)
                if percentage > 0:
                    cumulative += percentage
                    distribution.append((cumulative, outcome))
        else:
            # Pitching card specific
            pitching_specific = [
                (
                    "double_cf",
                    PlayOutcome.DOUBLE_UNCAPPED,
                ),  # Pitching has uncapped double
                (
                    "single_center",
                    PlayOutcome.SINGLE_UNCAPPED,
                ),  # Pitching has uncapped single
                ("flyout_lf_b", PlayOutcome.FLYOUT_B),
                ("flyout_cf_b", PlayOutcome.FLYOUT_B),
                ("flyout_rf_b", PlayOutcome.FLYOUT_B),
                ("groundout_a", PlayOutcome.GROUNDBALL_A),
                ("groundout_b", PlayOutcome.GROUNDBALL_B),
            ]
            for field, outcome in pitching_specific:
                percentage = getattr(rating, field, 0.0)
                if percentage > 0:
                    cumulative += percentage
                    distribution.append((cumulative, outcome))

        # Sort by cumulative value (should already be sorted, but ensure it)
        distribution.sort(key=lambda x: x[0])

        logger.debug(f"Built distribution with cumulative total: {cumulative:.1f}%")
        return distribution

    def _select_outcome(
        self, distribution: list[tuple[float, PlayOutcome]]
    ) -> PlayOutcome:
        """
        Roll 1d100 and select outcome from cumulative distribution.

        Args:
            distribution: List of (cumulative_threshold, outcome) tuples

        Returns:
            Selected PlayOutcome
        """
        if not distribution:
            logger.error("Empty distribution, defaulting to GROUNDBALL_B")
            return PlayOutcome.GROUNDBALL_B

        # Roll 0.0 to 100.0
        roll = random.uniform(0, 100)

        # Find first threshold >= roll
        for threshold, outcome in distribution:
            if roll <= threshold:
                logger.debug(f"Roll {roll:.1f} selected {outcome.value}")
                return outcome

        # Fallback (should only happen if distribution doesn't sum to 100%)
        logger.warning(f"Roll {roll:.1f} exceeded distribution, using last outcome")
        return distribution[-1][1]

    def get_outcome(
        self,
        roll: "AbRoll",
        state: "GameState",
        batter: "BasePlayer",
        pitcher: "BasePlayer",
    ) -> tuple[PlayOutcome, str | None]:
        """
        Auto-generate outcome from PD card ratings.

        Args:
            roll: Dice roll (not used in auto mode - we use probabilities)
            state: Current game state
            batter: Batting player (PdPlayer with batting_card)
            pitcher: Pitching player (PdPlayer with pitching_card)

        Returns:
            tuple: (outcome, hit_location)

        Raises:
            ValueError: If neither player has valid card data
        """
        # Select which card to use (coin flip)
        rating, is_batting_card = self._select_card(batter, pitcher)

        if rating is None:
            raise ValueError(
                f"Cannot auto-resolve: {batter.name} and {pitcher.name} both missing card data"
            )

        # Build cumulative distribution
        distribution = self._build_distribution(rating, is_batting_card)

        # Select outcome
        outcome = self._select_outcome(distribution)

        # Calculate hit location if needed
        batter_hand = getattr(getattr(batter, "batting_card", None), "hand", "R")
        location = calculate_hit_location(outcome, batter_hand)

        logger.info(
            f"{batter.name} vs {pitcher.name}: {outcome.value}"
            + (f" to {location}" if location else "")
        )

        return (outcome, location)
