"""
Play Resolver - Resolves play outcomes based on dice rolls.

Architecture: Outcome-first design where manual resolution is primary.
- resolve_outcome(): Core resolution logic (works for both manual and auto)
- resolve_manual_play(): Wrapper for manual submissions (most games)
- resolve_auto_play(): Wrapper for PD auto mode (rare)

Author: Claude
Date: 2025-10-24
Updated: 2025-10-31 - Week 7 Task 6: Integrated RunnerAdvancement and outcome-first architecture
Updated: 2025-11-02 - Phase 3C: Added X-Check resolution logic
"""

import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from app.config import PlayOutcome, get_league_config
from app.config.common_x_check_tables import (
    CATCHER_DEFENSE_TABLE,
    INFIELD_DEFENSE_TABLE,
    OUTFIELD_DEFENSE_TABLE,
    get_error_chart_for_position,
    get_fielders_holding_runners,
)
from app.config.result_charts import (
    PdAutoResultChart,
)
from app.core.dice import dice_system
from app.core.roll_types import AbRoll
from app.core.runner_advancement import AdvancementResult, RunnerAdvancement
from app.models.game_models import (
    DefensiveDecision,
    GameState,
    ManualOutcomeSubmission,
    OffensiveDecision,
    XCheckResult,
)

if TYPE_CHECKING:
    from app.models.player_models import PdPlayer

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


@dataclass
class RunnerAdvancementData:
    """Enhanced runner advancement data with player identification for play-by-play display."""

    from_base: int  # 0=batter, 1-3=bases
    to_base: int  # 1-4=bases (4=home/scored), 0=out
    lineup_id: int  # Player's lineup ID for name lookup
    is_out: bool = False


@dataclass
class PlayResult:
    """Result of a resolved play"""

    outcome: PlayOutcome
    outs_recorded: int
    runs_scored: int
    batter_result: int | None  # None = out, 1-4 = base reached
    runners_advanced: list[RunnerAdvancementData]  # Enhanced with lineup_id
    description: str
    ab_roll: AbRoll  # Full at-bat roll for audit trail
    hit_location: str | None = (
        None  # '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C'
    )

    # Statistics
    is_hit: bool = False
    is_out: bool = False
    is_walk: bool = False

    # X-Check details (Phase 3C)
    x_check_details: XCheckResult | None = None


class PlayResolver:
    """
    Resolves play outcomes based on dice rolls and game state.

    Architecture: Outcome-first design
    - Manual mode (primary): Players submit outcomes after reading physical cards
    - Auto mode (rare): System generates outcomes from digitized ratings (PD only)

    Args:
        league_id: 'sba' or 'pd'
        auto_mode: If True, use result charts to auto-generate outcomes
                   Only supported for leagues with digitized card data

    Raises:
        ValueError: If auto_mode requested for league that doesn't support it
    """

    def __init__(
        self, league_id: str, auto_mode: bool = False, state_manager: Any | None = None
    ):
        self.league_id = league_id
        self.auto_mode = auto_mode
        self.runner_advancement = RunnerAdvancement()
        self.state_manager = state_manager  # Phase 3E-Main: For X-Check defender lookup

        # Get league config for validation
        league_config = get_league_config(league_id)

        # Validate auto mode support
        if auto_mode and not league_config.supports_auto_mode():
            raise ValueError(
                f"Auto mode not supported for {league_id} league. "
                f"This league does not have digitized card data."
            )

        # Initialize result chart for auto mode only
        if auto_mode:
            self.result_chart = PdAutoResultChart()
            logger.info(f"PlayResolver initialized in AUTO mode for {league_id}")
        else:
            self.result_chart = None
            logger.info(f"PlayResolver initialized in MANUAL mode for {league_id}")

    # ========================================
    # PUBLIC METHODS - Primary API
    # ========================================

    def resolve_manual_play(
        self,
        submission: ManualOutcomeSubmission,
        state: GameState,
        defensive_decision: DefensiveDecision,
        offensive_decision: OffensiveDecision,
        ab_roll: AbRoll,
    ) -> PlayResult:
        """
        Resolve a manually submitted play (SBA + PD manual mode).

        This is the PRIMARY method for most games. Players read physical cards
        and submit the outcome they see via WebSocket.

        Args:
            submission: Player's submitted outcome + optional hit location
            state: Current game state
            defensive_decision: Defensive team's choices
            offensive_decision: Offensive team's choices
            ab_roll: Server-rolled dice for audit trail

        Returns:
            PlayResult with complete outcome
        """
        logger.info(
            f"Resolving manual play - {submission.outcome} at {submission.hit_location}"
        )

        # Convert string to PlayOutcome enum
        outcome = PlayOutcome(submission.outcome)

        # Delegate to core resolution
        return self.resolve_outcome(
            outcome=outcome,
            hit_location=submission.hit_location,
            state=state,
            defensive_decision=defensive_decision,
            offensive_decision=offensive_decision,
            ab_roll=ab_roll,
        )

    def resolve_auto_play(
        self,
        state: GameState,
        batter: "PdPlayer",
        pitcher: "PdPlayer",
        defensive_decision: DefensiveDecision,
        offensive_decision: OffensiveDecision,
    ) -> PlayResult:
        """
        Resolve an auto-generated play (PD auto mode only).

        This is RARE - only used for PD games with auto mode enabled.
        System generates outcome from digitized player ratings.

        Args:
            state: Current game state
            batter: Batting player (PdPlayer with ratings)
            pitcher: Pitching player (PdPlayer with ratings)
            defensive_decision: Defensive team's choices
            offensive_decision: Offensive team's choices

        Returns:
            PlayResult with complete outcome

        Raises:
            ValueError: If called when not in auto mode
        """
        if not self.auto_mode:
            raise ValueError("resolve_auto_play() can only be called in auto mode")

        logger.info(f"Resolving auto play - {batter.name} vs {pitcher.name}")

        # Check if there are runners on base (affects chaos check)
        runners_on_base = bool(state.on_first or state.on_second or state.on_third)

        # Roll dice
        ab_roll = dice_system.roll_ab(
            league_id=state.league_id,
            game_id=state.game_id,
            runners_on_base=runners_on_base,
        )

        # Generate outcome from ratings
        outcome, hit_location = self.result_chart.get_outcome(  # type: ignore
            roll=ab_roll, state=state, batter=batter, pitcher=pitcher
        )

        # Delegate to core resolution
        return self.resolve_outcome(
            outcome=outcome,
            hit_location=hit_location,
            state=state,
            defensive_decision=defensive_decision,
            offensive_decision=offensive_decision,
            ab_roll=ab_roll,
        )

    def resolve_outcome(
        self,
        outcome: PlayOutcome,
        hit_location: str | None,
        state: GameState,
        defensive_decision: DefensiveDecision,
        offensive_decision: OffensiveDecision,
        ab_roll: AbRoll,
        forced_xcheck_result: str | None = None,
        forced_xcheck_error: str | None = None,
    ) -> PlayResult:
        """
        CORE resolution method - all play resolution logic lives here.

        This method handles all outcome types and delegates to RunnerAdvancement
        for groundball outcomes. Works for both manual and auto modes.

        Args:
            outcome: The play outcome (from card or auto-generated)
            hit_location: Where ball was hit ('1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C') or None
            state: Current game state
            defensive_decision: Defensive team's positioning/strategy
            offensive_decision: Offensive team's strategy
            ab_roll: Dice roll for audit trail
            forced_xcheck_result: For testing - force X-Check converted result (G1, G2, SI2, DO2, etc.)
            forced_xcheck_error: For testing - force X-Check error result (NO, E1, E2, E3, RP)

        Returns:
            PlayResult with complete outcome, runner movements, and statistics
        """
        logger.info(
            f"Resolving {outcome.value} - Inning {state.inning} {state.half}, {state.outs} outs"
        )

        # ==================== Strikeout ====================
        if outcome == PlayOutcome.STRIKEOUT:
            return PlayResult(
                outcome=outcome,
                outs_recorded=1,
                runs_scored=0,
                batter_result=None,
                runners_advanced=[],
                description="Strikeout looking",
                ab_roll=ab_roll,
                hit_location=None,
                is_out=True,
            )

        # ==================== Popout ====================
        if outcome == PlayOutcome.POPOUT:
            return PlayResult(
                outcome=outcome,
                outs_recorded=1,
                runs_scored=0,
                batter_result=None,
                runners_advanced=[],
                description="Popout to infield",
                ab_roll=ab_roll,
                hit_location=hit_location,
                is_out=True,
            )

        # ==================== Groundballs ====================
        if outcome in [
            PlayOutcome.GROUNDBALL_A,
            PlayOutcome.GROUNDBALL_B,
            PlayOutcome.GROUNDBALL_C,
        ]:
            # Business rule: hit_location only matters when there are runners on base
            # AND less than 2 outs (for fielding choices and runner advancement)
            has_runners = (
                state.on_first is not None
                or state.on_second is not None
                or state.on_third is not None
            )
            needs_hit_location = has_runners and state.outs < 2

            if needs_hit_location and not hit_location:
                raise ValueError(
                    f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. "
                    f"Current situation: {state.outs} outs, runners on: "
                    f"{'1B ' if state.on_first else ''}"
                    f"{'2B ' if state.on_second else ''}"
                    f"{'3B' if state.on_third else ''}"
                )

            # Delegate to RunnerAdvancement for all groundball outcomes
            advancement_result = self.runner_advancement.advance_runners(
                outcome=outcome,
                hit_location=hit_location
                or "SS",  # Default to SS if location not specified
                state=state,
                defensive_decision=defensive_decision,
            )

            # Convert RunnerMovement list to RunnerAdvancementData for PlayResult
            runners_advanced = [
                RunnerAdvancementData(
                    from_base=movement.from_base,
                    to_base=movement.to_base,
                    lineup_id=movement.lineup_id,
                    is_out=movement.is_out,
                )
                for movement in advancement_result.movements
                if movement.from_base > 0  # Exclude batter, include only runners
            ]

            # Extract batter result from movements
            batter_movement = next(
                (m for m in advancement_result.movements if m.from_base == 0), None
            )
            batter_result = (
                batter_movement.to_base
                if batter_movement and not batter_movement.is_out
                else None
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=advancement_result.outs_recorded,
                runs_scored=advancement_result.runs_scored,
                batter_result=batter_result,
                runners_advanced=runners_advanced,
                description=advancement_result.description,
                ab_roll=ab_roll,
                hit_location=hit_location,
                is_out=(advancement_result.outs_recorded > 0),
            )

        # ==================== Flyouts ====================
        if outcome in [
            PlayOutcome.FLYOUT_A,
            PlayOutcome.FLYOUT_B,
            PlayOutcome.FLYOUT_BQ,
            PlayOutcome.FLYOUT_C,
        ]:
            # Business rule: hit_location only matters for FLYOUT_B and FLYOUT_BQ
            # when there are runners on base AND less than 2 outs (for tag-up decisions)
            if outcome in [PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ]:
                has_runners = (
                    state.on_first is not None
                    or state.on_second is not None
                    or state.on_third is not None
                )
                needs_hit_location = has_runners and state.outs < 2

                if needs_hit_location and not hit_location:
                    raise ValueError(
                        f"Hit location required for {outcome.value} when runners are on base with less than 2 outs. "
                        f"Current situation: {state.outs} outs, runners on: "
                        f"{'1B ' if state.on_first else ''}"
                        f"{'2B ' if state.on_second else ''}"
                        f"{'3B' if state.on_third else ''}"
                    )

            # Delegate to RunnerAdvancement for all flyball outcomes
            advancement_result = self.runner_advancement.advance_runners(
                outcome=outcome,
                hit_location=hit_location
                or "CF",  # Default to CF if location not specified
                state=state,
                defensive_decision=defensive_decision,
            )

            # Convert RunnerMovement list to RunnerAdvancementData for PlayResult
            runners_advanced = [
                RunnerAdvancementData(
                    from_base=movement.from_base,
                    to_base=movement.to_base,
                    lineup_id=movement.lineup_id,
                    is_out=movement.is_out,
                )
                for movement in advancement_result.movements
                if movement.from_base > 0  # Exclude batter, include only runners
            ]

            # Extract batter result from movements (always out for flyouts)
            batter_movement = next(
                (m for m in advancement_result.movements if m.from_base == 0), None
            )
            batter_result = (
                batter_movement.to_base
                if batter_movement and not batter_movement.is_out
                else None
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=advancement_result.outs_recorded,
                runs_scored=advancement_result.runs_scored,
                batter_result=batter_result,
                runners_advanced=runners_advanced,
                description=advancement_result.description,
                ab_roll=ab_roll,
                hit_location=hit_location,
                is_out=(advancement_result.outs_recorded > 0),
            )

        # ==================== Lineout ====================
        if outcome == PlayOutcome.LINEOUT:
            return PlayResult(
                outcome=outcome,
                outs_recorded=1,
                runs_scored=0,
                batter_result=None,
                runners_advanced=[],
                description="Lineout",
                ab_roll=ab_roll,
                is_out=True,
            )

        if outcome == PlayOutcome.WALK:
            # Walk - batter to first, runners advance if forced
            runners_advanced = self._advance_on_walk(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=1,
                runners_advanced=runners_advanced,
                description="Walk",
                ab_roll=ab_roll,
                is_walk=True,
            )

        if outcome == PlayOutcome.HIT_BY_PITCH:
            # HBP - identical to walk: batter to first, runners advance if forced
            runners_advanced = self._advance_on_walk(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=1,
                runners_advanced=runners_advanced,
                description="Hit by pitch",
                ab_roll=ab_roll,
                # Note: HBP is NOT classified as a walk for statistics purposes
            )

        # ==================== Singles ====================
        if outcome == PlayOutcome.SINGLE_1:
            # Single with standard advancement
            runners_advanced = self._advance_on_single_1(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=1,
                runners_advanced=runners_advanced,
                description="Single to left field",
                ab_roll=ab_roll,
                is_hit=True,
            )

        if outcome == PlayOutcome.SINGLE_2:
            # Single with enhanced advancement (more aggressive)
            runners_advanced = self._advance_on_single_2(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=1,
                runners_advanced=runners_advanced,
                description="Single to right field",
                ab_roll=ab_roll,
                is_hit=True,
            )

        if outcome == PlayOutcome.SINGLE_UNCAPPED:
            # Business rule: hit_location only matters when there is a runner on 1st, 2nd, or both
            has_runner_on_scoring_bases = state.on_first is not None or state.on_second is not None

            if has_runner_on_scoring_bases and not hit_location:
                raise ValueError(
                    f"Hit location required for {outcome.value} when runner on 1st or 2nd. "
                    f"Current situation: runners on: "
                    f"{'1B ' if state.on_first else ''}"
                    f"{'2B ' if state.on_second else ''}"
                    f"{'3B' if state.on_third else ''}"
                )

            # TODO Phase 3: Implement uncapped hit decision tree
            # For now, treat as SINGLE_1
            runners_advanced = self._advance_on_single_1(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=1,
                runners_advanced=runners_advanced,
                description="Single to center (uncapped)",
                ab_roll=ab_roll,
                is_hit=True,
            )

        # ==================== Doubles ====================
        if outcome == PlayOutcome.DOUBLE_2:
            # Double to 2nd base
            runners_advanced = self._advance_on_double_2(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=2,
                runners_advanced=runners_advanced,
                description="Double to left-center",
                ab_roll=ab_roll,
                is_hit=True,
            )

        if outcome == PlayOutcome.DOUBLE_3:
            # Double with extra runner advancement (runners advance 3 bases)
            runners_advanced = self._advance_on_double_3(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=2,  # Batter reaches 2B (it's a double)
                runners_advanced=runners_advanced,
                description="Double to right-center gap (runners advance 3 bases)",
                ab_roll=ab_roll,
                is_hit=True,
            )

        if outcome == PlayOutcome.DOUBLE_UNCAPPED:
            # Business rule: hit_location only matters when there is a runner on 1st
            has_runner_on_first = state.on_first is not None

            if has_runner_on_first and not hit_location:
                raise ValueError(
                    f"Hit location required for {outcome.value} when runner on 1st. "
                    f"Current situation: runners on: "
                    f"{'1B ' if state.on_first else ''}"
                    f"{'2B ' if state.on_second else ''}"
                    f"{'3B' if state.on_third else ''}"
                )

            # TODO Phase 3: Implement uncapped hit decision tree
            # For now, treat as DOUBLE_2
            runners_advanced = self._advance_on_double_2(state)
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=2,
                runners_advanced=runners_advanced,
                description="Double (uncapped)",
                ab_roll=ab_roll,
                is_hit=True,
            )

        if outcome == PlayOutcome.TRIPLE:
            # All runners score
            runners_advanced = [
                RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id)
                for base, runner in state.get_all_runners()
            ]
            runs_scored = len(runners_advanced)

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=3,
                runners_advanced=runners_advanced,
                description="Triple to right-center gap",
                ab_roll=ab_roll,
                is_hit=True,
            )

        if outcome == PlayOutcome.HOMERUN:
            # Everyone scores
            runners_advanced = [
                RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id)
                for base, runner in state.get_all_runners()
            ]
            runs_scored = len(runners_advanced) + 1

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=4,
                runners_advanced=runners_advanced,
                description="Home run to left field",
                ab_roll=ab_roll,
                is_hit=True,
            )

        if outcome == PlayOutcome.WILD_PITCH:
            # Runners advance one base
            runners_advanced = [
                RunnerAdvancementData(
                    from_base=base, to_base=min(base + 1, 4), lineup_id=runner.lineup_id
                )
                for base, runner in state.get_all_runners()
            ]
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=None,  # Batter stays at plate
                runners_advanced=runners_advanced,
                description="Wild pitch - runners advance",
                ab_roll=ab_roll,
            )

        if outcome == PlayOutcome.PASSED_BALL:
            # Runners advance one base
            runners_advanced = [
                RunnerAdvancementData(
                    from_base=base, to_base=min(base + 1, 4), lineup_id=runner.lineup_id
                )
                for base, runner in state.get_all_runners()
            ]
            runs_scored = sum(
                1 for adv in runners_advanced if adv.to_base == 4
            )

            return PlayResult(
                outcome=outcome,
                outs_recorded=0,
                runs_scored=runs_scored,
                batter_result=None,  # Batter stays at plate
                runners_advanced=runners_advanced,
                description="Passed ball - runners advance",
                ab_roll=ab_roll,
            )

        # ==================== X-Check ====================
        if outcome == PlayOutcome.X_CHECK:
            # X-Check requires position in hit_location
            if not hit_location:
                raise ValueError("X-Check outcome requires hit_location (position)")

            # Resolve X-Check with defense table and error chart lookups
            return self._resolve_x_check(
                position=hit_location,
                state=state,
                defensive_decision=defensive_decision,
                ab_roll=ab_roll,
                forced_result=forced_xcheck_result,
                forced_error=forced_xcheck_error,
            )

        raise ValueError(f"Unhandled outcome: {outcome}")

    def _advance_on_walk(self, state: GameState) -> list[RunnerAdvancementData]:
        """Calculate runner advancement on walk"""
        advances: list[RunnerAdvancementData] = []

        # Only forced runners advance
        if state.on_first:
            # First occupied - check second
            if state.on_second:
                # Bases loaded scenario
                if state.on_third:
                    # Bases loaded - force runner home
                    advances.append(RunnerAdvancementData(
                        from_base=3, to_base=4, lineup_id=state.on_third.lineup_id
                    ))
                advances.append(RunnerAdvancementData(
                    from_base=2, to_base=3, lineup_id=state.on_second.lineup_id
                ))
            advances.append(RunnerAdvancementData(
                from_base=1, to_base=2, lineup_id=state.on_first.lineup_id
            ))

        return advances

    def _advance_on_single_1(self, state: GameState) -> list[RunnerAdvancementData]:
        """Calculate runner advancement on single (simplified)"""
        advances: list[RunnerAdvancementData] = []

        if state.on_third:
            # Runner on third scores
            advances.append(RunnerAdvancementData(
                from_base=3, to_base=4, lineup_id=state.on_third.lineup_id
            ))
        if state.on_second:
            advances.append(RunnerAdvancementData(
                from_base=2, to_base=3, lineup_id=state.on_second.lineup_id
            ))
        if state.on_first:
            advances.append(RunnerAdvancementData(
                from_base=1, to_base=2, lineup_id=state.on_first.lineup_id
            ))

        return advances

    def _advance_on_single_2(self, state: GameState) -> list[RunnerAdvancementData]:
        """Calculate runner advancement on single (simplified)"""
        advances: list[RunnerAdvancementData] = []

        if state.on_third:
            # Runner on third scores
            advances.append(RunnerAdvancementData(
                from_base=3, to_base=4, lineup_id=state.on_third.lineup_id
            ))
        if state.on_second:
            # Runner on second scores
            advances.append(RunnerAdvancementData(
                from_base=2, to_base=4, lineup_id=state.on_second.lineup_id
            ))
        if state.on_first:
            # Runner on first to third
            advances.append(RunnerAdvancementData(
                from_base=1, to_base=3, lineup_id=state.on_first.lineup_id
            ))

        return advances

    def _advance_on_double_2(self, state: GameState) -> list[RunnerAdvancementData]:
        """Calculate runner advancement on DOUBLE2 - all runners advance exactly 2 bases"""
        advances: list[RunnerAdvancementData] = []

        # Runners advance 2 bases:
        # 1st -> 3rd, 2nd -> home, 3rd -> home
        for base, runner in state.get_all_runners():
            final_base = min(base + 2, 4)
            advances.append(RunnerAdvancementData(
                from_base=base, to_base=final_base, lineup_id=runner.lineup_id
            ))

        return advances

    def _advance_on_double_3(self, state: GameState) -> list[RunnerAdvancementData]:
        """Calculate runner advancement on DOUBLE3 - all runners advance exactly 3 bases"""
        advances: list[RunnerAdvancementData] = []

        # Runners advance 3 bases (all score from any base)
        # 1st -> home (1+3=4), 2nd -> home (2+3=5→4), 3rd -> home
        for base, runner in state.get_all_runners():
            final_base = min(base + 3, 4)
            advances.append(RunnerAdvancementData(
                from_base=base, to_base=final_base, lineup_id=runner.lineup_id
            ))

        return advances

    # ========================================================================
    # X-CHECK RESOLUTION (Phase 3C - 2025-11-02)
    # ========================================================================

    def _resolve_x_check(
        self,
        position: str,
        state: GameState,
        defensive_decision: DefensiveDecision,
        ab_roll: AbRoll,
        forced_result: str | None = None,
        forced_error: str | None = None,
    ) -> PlayResult:
        """
        Resolve X-Check play with defense range and error tables.

        Process:
            1. Get defender and their ratings
            2. Roll 1d20 + 3d6 (or use forced values)
            3. Adjust range if playing in
            4. Look up base result from defense table (or use forced_result)
            5. Apply SPD test if needed
            6. Apply G2#/G3# conversion if applicable
            7. Look up error result from error chart (or use forced_error)
            8. Determine final outcome
            9. Get runner advancement
            10. Create Play record

        Args:
            position: Position being checked (SS, LF, 3B, etc.)
            state: Current game state
            defensive_decision: Defensive positioning
            ab_roll: Dice roll for audit trail
            forced_result: For testing - force the converted result (G1, G2, SI2, DO2, etc.)
            forced_error: For testing - force the error result (NO, E1, E2, E3, RP)

        Returns:
            PlayResult with x_check_details populated

        Raises:
            ValueError: If defender has no position rating
        """
        logger.info(f"Resolving X-Check to {position}")
        if forced_result:
            logger.info(
                f"🎯 Forcing X-Check result: {forced_result} + {forced_error or 'NO'}"
            )

        # Check league config
        league_config = get_league_config(state.league_id)
        supports_ratings = league_config.supports_position_ratings()

        # Step 1: Get defender from lineup cache and use position ratings
        defender = None
        if self.state_manager:
            defender = state.get_defender_for_position(position, self.state_manager)

        if defender and supports_ratings and defender.position_rating:
            # Use actual ratings from PD league player
            defender_range = defender.position_rating.range
            defender_error_rating = defender.position_rating.error
            defender_id = defender.lineup_id
            logger.debug(
                f"Using defender {defender_id} (card {defender.card_id}) ratings: "
                f"range={defender_range}, error={defender_error_rating}"
            )
        elif defender:
            # Defender found but no ratings (SBA or missing data)
            logger.info(
                f"Defender found at {position} but no ratings available "
                f"(league={state.league_id}, supports_ratings={supports_ratings})"
            )
            defender_range = 3  # Average range
            defender_error_rating = 15  # Average error
            defender_id = defender.lineup_id
        else:
            # No defender found (shouldn't happen in valid game)
            logger.warning(f"No defender found at {position}, using defaults")
            defender_range = 3
            defender_error_rating = 15
            defender_id = 0

        # Step 2: Roll dice using proper fielding roll (includes audit trail)
        fielding_roll = dice_system.roll_fielding(
            position=position, league_id=state.league_id, game_id=state.game_id
        )
        d20_roll = fielding_roll.d20
        d6_roll = fielding_roll.error_total

        logger.debug(
            f"X-Check rolls: d20={d20_roll}, 3d6={d6_roll} (roll_id={fielding_roll.roll_id})"
        )

        # Step 3: Adjust range if playing in
        adjusted_range = self._adjust_range_for_defensive_position(
            base_range=defender_range,
            position=position,
            defensive_decision=defensive_decision,
        )

        # Initialize SPD test variables (used in both forced and normal paths)
        spd_test_roll = None
        spd_test_target = None
        spd_test_passed = None

        # Step 4: Look up base result (or use forced)
        if forced_result:
            # Use forced result, skip table lookup
            base_result = forced_result
            converted_result = forced_result  # Skip SPD test and G2#/G3# conversion
            logger.debug(f"Using forced result: {forced_result}")
        else:
            # Normal flow: look up from defense table
            base_result = self._lookup_defense_table(
                position=position, d20_roll=d20_roll, defense_range=adjusted_range
            )

            logger.debug(f"Base result from defense table: {base_result}")

            # Step 5: Apply SPD test if needed
            converted_result = base_result

            if base_result == "SPD":
                # TODO: Need batter for SPD test - placeholder for now
                converted_result = "G3"  # Default to G3 if SPD test fails
                logger.debug(f"SPD test defaulted to fail → {converted_result}")

            # Step 6: Apply G2#/G3# conversion if applicable
            if converted_result in ["G2#", "G3#"]:
                converted_result = self._apply_hash_conversion(
                    result=converted_result,
                    position=position,
                    adjusted_range=adjusted_range,
                    base_range=defender_range,
                    state=state,
                    batter_hand="R",  # Placeholder
                )

        # Step 7: Look up error result (or use forced)
        if forced_error:
            # Use forced error, skip chart lookup
            error_result = forced_error
            logger.debug(f"Using forced error: {forced_error}")
        else:
            # Normal flow: look up from error chart
            error_result = self._lookup_error_chart(
                position=position, error_rating=defender_error_rating, d6_roll=d6_roll
            )

            logger.debug(f"Error result: {error_result}")

        # Step 8: Determine final outcome
        final_outcome, hit_type = self._determine_final_x_check_outcome(
            converted_result=converted_result, error_result=error_result
        )

        # Step 9: Create XCheckResult
        x_check_details = XCheckResult(
            position=position,
            d20_roll=d20_roll,
            d6_roll=d6_roll,
            defender_range=adjusted_range,
            defender_error_rating=defender_error_rating,
            defender_id=defender_id,
            base_result=base_result,
            converted_result=converted_result,
            error_result=error_result,
            final_outcome=final_outcome,
            hit_type=hit_type,
            spd_test_roll=spd_test_roll,
            spd_test_target=spd_test_target,
            spd_test_passed=spd_test_passed,
        )

        # Step 10: Get runner advancement
        defender_in = adjusted_range > defender_range

        # Call appropriate x_check function based on converted_result
        advancement = self._get_x_check_advancement(
            converted_result=converted_result,
            error_result=error_result,
            state=state,
            defender_in=defender_in,
            hit_location=position,
            defensive_decision=defensive_decision,
        )

        # Convert AdvancementResult to RunnerAdvancementData for PlayResult
        runners_advanced = [
            RunnerAdvancementData(
                from_base=movement.from_base,
                to_base=movement.to_base,
                lineup_id=movement.lineup_id,
                is_out=movement.is_out,
            )
            for movement in advancement.movements
            if movement.from_base > 0  # Exclude batter, include only runners
        ]

        # Extract batter result from movements
        batter_movement = next(
            (m for m in advancement.movements if m.from_base == 0), None
        )
        batter_result = (
            batter_movement.to_base
            if batter_movement and not batter_movement.is_out
            else None
        )

        runs_scored = advancement.runs_scored
        outs_recorded = advancement.outs_recorded

        # Step 11: Create PlayResult
        return PlayResult(
            outcome=final_outcome,
            outs_recorded=outs_recorded,
            runs_scored=runs_scored,
            batter_result=batter_result,
            runners_advanced=runners_advanced,
            description=f"X-Check {position}: {base_result} → {converted_result} + {error_result} = {final_outcome.value}",
            ab_roll=ab_roll,
            hit_location=position,
            is_hit=final_outcome.is_hit(),
            is_out=final_outcome.is_out(),
            x_check_details=x_check_details,
        )

    def _adjust_range_for_defensive_position(
        self, base_range: int, position: str, defensive_decision: DefensiveDecision
    ) -> int:
        """
        Adjust defense range for defensive positioning.

        If defender is playing in, range increases by 1 (max 5).

        Args:
            base_range: Defender's base range (1-5)
            position: Position code
            defensive_decision: Current defensive positioning

        Returns:
            Adjusted range (1-5)
        """
        playing_in = False

        if (
            defensive_decision.infield_depth == "corners_in"
            and position in ["1B", "3B", "P", "C"]
            or defensive_decision.infield_depth == "infield_in"
            and position in ["1B", "2B", "3B", "SS", "P", "C"]
        ):
            playing_in = True

        if playing_in:
            adjusted = min(base_range + 1, 5)
            logger.debug(f"{position} playing in: range {base_range} → {adjusted}")
            return adjusted

        return base_range

    def _lookup_defense_table(
        self, position: str, d20_roll: int, defense_range: int
    ) -> str:
        """
        Look up base result from defense table.

        Args:
            position: Position code (determines which table)
            d20_roll: 1-20 (row selector)
            defense_range: 1-5 (column selector)

        Returns:
            Base result code (G1, F2, SI2, SPD, etc.)
        """
        # Determine which table to use
        if position in ["P", "C", "1B", "2B", "3B", "SS"]:
            if position == "C":
                table = CATCHER_DEFENSE_TABLE
            else:
                table = INFIELD_DEFENSE_TABLE
        else:  # LF, CF, RF
            table = OUTFIELD_DEFENSE_TABLE

        # Lookup (0-indexed)
        row = d20_roll - 1
        col = defense_range - 1

        result = table[row][col]
        logger.debug(f"Defense table[{d20_roll}][{defense_range}] = {result}")

        return result

    def _apply_hash_conversion(
        self,
        result: str,
        position: str,
        adjusted_range: int,
        base_range: int,
        state: GameState,
        batter_hand: str,
    ) -> str:
        """
        Convert G2# or G3# to SI2 if conditions are met.

        Conversion happens if:
        a) Infielder is playing in (range was adjusted), OR
        b) Infielder is responsible for holding a runner

        Args:
            result: 'G2#' or 'G3#'
            position: Position code
            adjusted_range: Range after playing-in adjustment
            base_range: Original range
            state: Current game state
            batter_hand: 'L' or 'R'

        Returns:
            'SI2' if converted, otherwise original result without # ('G2' or 'G3')
        """
        # Check condition (a): playing in
        if adjusted_range > base_range:
            logger.debug(f"{result} → SI2 (defender playing in)")
            return "SI2"

        # Check condition (b): holding runner
        runner_bases = [base for base, _ in state.get_all_runners()]

        holding_positions = get_fielders_holding_runners(runner_bases, batter_hand)

        if position in holding_positions:
            logger.debug(f"{result} → SI2 (defender holding runner)")
            return "SI2"

        # No conversion - remove # suffix
        base_result = result.replace("#", "")
        logger.debug(f"{result} → {base_result} (no conversion)")
        return base_result

    def _lookup_error_chart(
        self, position: str, error_rating: int, d6_roll: int
    ) -> str:
        """
        Look up error result from error chart.

        Args:
            position: Position code
            error_rating: Defender's error rating (0-25 for outfield, varies for infield)
            d6_roll: Sum of 3d6 (3-18)

        Returns:
            Error result: 'NO', 'E1', 'E2', 'E3', or 'RP'
        """
        error_chart = get_error_chart_for_position(position)

        # Get row for this error rating
        if error_rating not in error_chart:
            logger.warning(f"Error rating {error_rating} not in chart, using 0")
            error_rating = 0

        rating_row = error_chart[error_rating]

        # Check each error type in priority order
        for error_type in ["RP", "E3", "E2", "E1"]:
            if d6_roll in rating_row[error_type]:
                logger.debug(f"Error chart: 3d6={d6_roll} → {error_type}")
                return error_type

        # No error
        logger.debug(f"Error chart: 3d6={d6_roll} → NO")
        return "NO"

    def _get_x_check_advancement(
        self,
        converted_result: str,
        error_result: str,
        state: "GameState",
        defender_in: bool,
        hit_location: str,
        defensive_decision: "DefensiveDecision",
    ) -> "AdvancementResult":
        """
        Get runner advancement for X-Check result.

        Calls appropriate x_check function based on result type:
        - G1, G2, G3: Groundball advancement (uses x_check tables)
        - F1, F2, F3: Flyball advancement (uses x_check tables)
        - SI1, SI2, DO2, DO3, TR3: Hit advancement (uses existing methods + error bonuses)
        - FO, PO: Out advancement (error overrides out, so just error advancement)

        Args:
            converted_result: Result after SPD test and hash conversion
            error_result: Error type (NO, E1, E2, E3, RP)
            state: Current game state (for runner positions)
            defender_in: Whether defender was playing in
            hit_location: Position where ball was hit (fielder's position)
            defensive_decision: Defensive positioning decision

        Returns:
            AdvancementResult with runner movements

        Raises:
            ValueError: If result type is not recognized
        """
        from app.core.runner_advancement import (
            x_check_f1,
            x_check_f2,
            x_check_f3,
            x_check_g1,
            x_check_g2,
            x_check_g3,
        )

        on_base_code = state.current_on_base_code

        # Groundball results
        if converted_result == "G1":
            return x_check_g1(
                on_base_code,
                defender_in,
                error_result,
                state,
                hit_location,
                defensive_decision,
            )
        if converted_result == "G2":
            return x_check_g2(
                on_base_code,
                defender_in,
                error_result,
                state,
                hit_location,
                defensive_decision,
            )
        if converted_result == "G3":
            return x_check_g3(
                on_base_code,
                defender_in,
                error_result,
                state,
                hit_location,
                defensive_decision,
            )

        # Flyball results
        if converted_result == "F1":
            return x_check_f1(on_base_code, error_result, state, hit_location)
        if converted_result == "F2":
            return x_check_f2(on_base_code, error_result, state, hit_location)
        if converted_result == "F3":
            return x_check_f3(on_base_code, error_result, state, hit_location)

        # Hit results - use existing advancement methods + error bonuses
        if converted_result in ["SI1", "SI2", "DO2", "DO3", "TR3"]:
            return self._get_hit_advancement_with_error(
                converted_result, error_result, state
            )

        # Out results - error overrides out, so just error advancement
        if converted_result in ["FO", "PO"]:
            return self._get_out_advancement_with_error(error_result, state)

        raise ValueError(f"Unknown X-Check result type: {converted_result}")

    def _get_hit_advancement_with_error(
        self, hit_type: str, error_result: str, state: "GameState"
    ) -> "AdvancementResult":
        """
        Get runner advancement for X-Check hit with error.

        Uses existing advancement methods and adds error bonuses:
        - NO: No bonus
        - E1: +1 base
        - E2: +2 bases
        - E3: +3 bases
        - RP: Treat as E3

        Args:
            hit_type: SI1, SI2, DO2, DO3, or TR3
            error_result: Error type
            state: Current game state (for runner positions)

        Returns:
            AdvancementResult with movements
        """
        from app.core.runner_advancement import AdvancementResult, RunnerMovement

        # Get base advancement (without error)

        if hit_type == "SI1":
            base_advances = self._advance_on_single_1(state)
            batter_reaches = 1
        elif hit_type == "SI2":
            base_advances = self._advance_on_single_2(state)
            batter_reaches = 1
        elif hit_type == "DO2":
            base_advances = self._advance_on_double_2(state)
            batter_reaches = 2
        elif hit_type == "DO3":
            base_advances = self._advance_on_double_3(state)
            batter_reaches = (
                2  # DO = double (batter to 2B), 3 = runners advance 3 bases
            )
        elif hit_type == "TR3":
            base_advances = self._advance_on_triple(state)
            batter_reaches = 3
        else:
            raise ValueError(f"Unknown hit type: {hit_type}")

        # Apply error bonus
        error_bonus = {"NO": 0, "E1": 1, "E2": 2, "E3": 3, "RP": 3}.get(error_result, 0)

        movements = []
        runs_scored = 0

        # Add batter movement (with error bonus)
        batter_final = min(batter_reaches + error_bonus, 4)
        if batter_final == 4:
            runs_scored += 1
        movements.append(
            RunnerMovement(
                lineup_id=0,  # Placeholder - will be set by game engine
                from_base=0,
                to_base=batter_final,
                is_out=False,
            )
        )

        # Add runner movements (with error bonus)
        for from_base, to_base in base_advances:
            final_base = min(to_base + error_bonus, 4)
            if final_base == 4:
                runs_scored += 1
            movements.append(
                RunnerMovement(
                    lineup_id=0,  # Placeholder
                    from_base=from_base,
                    to_base=final_base,
                    is_out=False,
                )
            )

        return AdvancementResult(
            movements=movements,
            outs_recorded=0,
            runs_scored=runs_scored,
            result_type=None,
            description=f"X-Check {hit_type} + {error_result}",
        )

    def _get_out_advancement_with_error(
        self, error_result: str, state: "GameState"
    ) -> "AdvancementResult":
        """
        Get runner advancement for X-Check out with error.

        When an out has an error, the out is negated and it becomes an error play.
        Runners advance based on error severity:
        - E1: All advance 1 base
        - E2: All advance 2 bases
        - E3: All advance 3 bases
        - RP: All advance 3 bases

        Args:
            error_result: Error type (should not be 'NO' for outs)
            state: Current game state (for runner positions)

        Returns:
            AdvancementResult with movements
        """
        from app.core.runner_advancement import AdvancementResult, RunnerMovement

        if error_result == "NO":
            # No error on out - just record out
            return AdvancementResult(
                movements=[
                    RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True)
                ],
                outs_recorded=1,
                runs_scored=0,
                result_type=None,
                description="X-Check out (no error)",
            )

        # Error prevents out - batter and runners advance
        error_bonus = {"E1": 1, "E2": 2, "E3": 3, "RP": 3}[error_result]
        movements = []
        runs_scored = 0

        # Batter reaches base based on error severity
        batter_final = min(error_bonus, 4)
        if batter_final == 4:
            runs_scored += 1
        movements.append(
            RunnerMovement(lineup_id=0, from_base=0, to_base=batter_final, is_out=False)
        )

        # All runners advance by error bonus
        for base, runner in state.get_all_runners():
            final_base = min(base + error_bonus, 4)
            if final_base == 4:
                runs_scored += 1
            movements.append(
                RunnerMovement(
                    lineup_id=runner.lineup_id, from_base=base, to_base=final_base, is_out=False
                )
            )

        return AdvancementResult(
            movements=movements,
            outs_recorded=0,
            runs_scored=runs_scored,
            result_type=None,
            description=f"X-Check out + {error_result} (error overrides out)",
        )

    def _advance_on_triple(self, state: "GameState") -> list[RunnerAdvancementData]:
        """Calculate runner advancement on triple (all runners score)."""
        return [
            RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id)
            for base, runner in state.get_all_runners()
        ]

    def _determine_final_x_check_outcome(
        self, converted_result: str, error_result: str
    ) -> tuple[PlayOutcome, str]:
        """
        Determine final outcome and hit_type from converted result + error.

        Logic:
            - If Out + Error: outcome = ERROR, hit_type = '{result}_plus_error_{n}'
            - If Hit + Error: outcome = hit type, hit_type = '{result}_plus_error_{n}'
            - If No Error: outcome = base outcome, hit_type = '{result}_no_error'
            - If Rare Play: hit_type includes '_rare_play'

        Args:
            converted_result: Result after SPD/# conversions (G1, F2, SI2, etc.)
            error_result: 'NO', 'E1', 'E2', 'E3', 'RP'

        Returns:
            Tuple of (final_outcome, hit_type)
        """
        # Map result codes to PlayOutcome
        result_map = {
            "SI1": PlayOutcome.SINGLE_1,
            "SI2": PlayOutcome.SINGLE_2,
            "DO2": PlayOutcome.DOUBLE_2,
            "DO3": PlayOutcome.DOUBLE_3,
            "TR3": PlayOutcome.TRIPLE,
            "G1": PlayOutcome.GROUNDBALL_B,
            "G2": PlayOutcome.GROUNDBALL_B,
            "G3": PlayOutcome.GROUNDBALL_C,
            "F1": PlayOutcome.FLYOUT_A,
            "F2": PlayOutcome.FLYOUT_B,
            "F3": PlayOutcome.FLYOUT_C,
            "FO": PlayOutcome.LINEOUT,
            "PO": PlayOutcome.POPOUT,
        }

        base_outcome = result_map.get(converted_result)
        if not base_outcome:
            raise ValueError(f"Unknown X-Check result: {converted_result}")

        # Build hit_type string
        result_lower = converted_result.lower()

        if error_result == "NO":
            # No error
            hit_type = f"{result_lower}_no_error"
            final_outcome = base_outcome

        elif error_result == "RP":
            # Rare play
            hit_type = f"{result_lower}_rare_play"
            # Rare plays are treated like errors for stats
            final_outcome = PlayOutcome.ERROR

        else:
            # E1, E2, E3
            error_num = error_result[1]  # Extract '1', '2', or '3'
            hit_type = f"{result_lower}_plus_error_{error_num}"

            # If base was an out, error overrides to ERROR outcome
            if base_outcome.is_out():
                final_outcome = PlayOutcome.ERROR
            else:
                # Hit + error: keep hit outcome
                final_outcome = base_outcome

        logger.info(
            f"Final: {converted_result} + {error_result} → {final_outcome.value} ({hit_type})"
        )

        return final_outcome, hit_type
