"""AI personalities for CPU players in Golf."""

import logging
import os
import random
from dataclasses import dataclass
from typing import Optional
from enum import Enum

from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, Suit, get_card_value


# Debug logging configuration
# Set AI_DEBUG=1 environment variable to enable detailed AI decision logging
AI_DEBUG = os.environ.get("AI_DEBUG", "0") == "1"

# Create a dedicated logger for AI decisions
ai_logger = logging.getLogger("golf.ai")
if AI_DEBUG:
    ai_logger.setLevel(logging.DEBUG)
    # Add console handler if not already present
    if not ai_logger.handlers:
        handler = logging.StreamHandler()
        handler.setFormatter(logging.Formatter(
            "%(asctime)s [AI] %(message)s", datefmt="%H:%M:%S"
        ))
        ai_logger.addHandler(handler)


def ai_log(message: str):
    """Log AI decision info when AI_DEBUG is enabled."""
    if AI_DEBUG:
        ai_logger.debug(message)


# =============================================================================
# CPU Turn Timing Configuration (seconds)
# =============================================================================
# Centralized timing constants for all CPU turn delays.
# Adjust these values to tune the "feel" of CPU gameplay.

CPU_TIMING = {
    # Delay before CPU "looks at" the discard pile
    "initial_look": (0.5, 0.7),
    # Brief pause after draw broadcast
    "post_draw_settle": 0.05,
    # Consideration time after drawing (before swap/discard decision)
    "post_draw_consider": (0.2, 0.4),
    # Variance multiplier range for chaotic personality players
    "thinking_multiplier_chaotic": (0.6, 1.4),
    # Pause after swap/discard to let animation complete and show result
    "post_action_pause": (0.5, 0.7),
}

# Thinking time ranges by card difficulty (seconds)
THINKING_TIME = {
    # Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
    "easy_good": (0.2, 0.4),
    # Obviously bad cards (10s, Jacks, Queens) - easy pass
    "easy_bad": (0.2, 0.4),
    # Medium difficulty (3, 4, 8, 9)
    "medium": (0.2, 0.4),
    # Hardest decisions (5, 6, 7 - middle of range)
    "hard": (0.2, 0.4),
    # No discard available - quick decision
    "no_card": (0.2, 0.4),
}


# Alias for backwards compatibility - use the centralized function from game.py
def get_ai_card_value(card: Card, options: GameOptions) -> int:
    """Get card value with house rules applied for AI decisions.

    This is an alias for game.get_card_value() for backwards compatibility.
    """
    return get_card_value(card, options)


def can_make_pair(card1: Card, card2: Card) -> bool:
    """Check if two cards can form a pair."""
    return card1.rank == card2.rank


def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
    """Calculate CPU 'thinking time' based on how obvious the discard decision is.

    Easy decisions (obviously good or bad cards) = quick
    Hard decisions (medium value cards) = slower

    Returns time in seconds. Uses THINKING_TIME constants.
    """
    if not card:
        # No discard available - quick decision to draw from deck
        t = THINKING_TIME["no_card"]
        return random.uniform(t[0], t[1])

    value = get_card_value(card, options)

    # Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
    if value <= 1:
        t = THINKING_TIME["easy_good"]
        return random.uniform(t[0], t[1])

    # Obviously bad cards (easy pass): 10, J, Q (value 10)
    if value >= 10:
        t = THINKING_TIME["easy_bad"]
        return random.uniform(t[0], t[1])

    # Medium cards require more thought: 3-9
    # 5, 6, 7 are the hardest decisions (middle of the range)
    if value in (5, 6, 7):
        t = THINKING_TIME["hard"]
        return random.uniform(t[0], t[1])

    # 3, 4, 8, 9 - moderate difficulty
    t = THINKING_TIME["medium"]
    return random.uniform(t[0], t[1])


def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
    """Estimate minimum opponent score from visible cards.

    Args:
        player: The player making the estimation (excluded from opponents)
        game: The game state
        optimistic: If True, assume opponents' hidden cards are average (4.5).
                   If False, assume opponents could get lucky (lower estimate).
    """
    min_est = 999
    for p in game.players:
        if p.id == player.id:
            continue
        visible = sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up)
        hidden = sum(1 for c in p.cards if not c.face_up)

        if optimistic:
            # Assume average hidden cards
            estimate = visible + int(hidden * 4.5)
        else:
            # Assume opponents could get lucky - hidden cards might be low
            # or could complete pairs, so use lower estimate
            # Check for potential pairs in opponent's hand
            pair_potential = 0
            for col in range(3):
                top, bot = p.cards[col], p.cards[col + 3]
                # If one card is visible and the other is hidden, there's pair potential
                if top.face_up and not bot.face_up:
                    pair_potential += get_ai_card_value(top, game.options)
                elif bot.face_up and not top.face_up:
                    pair_potential += get_ai_card_value(bot, game.options)

            # Conservative estimate: assume 2.5 avg for hidden (could be low cards)
            # and subtract some pair potential (hidden cards might match visible)
            base_estimate = visible + int(hidden * 2.5)
            estimate = base_estimate - int(pair_potential * 0.25)  # 25% chance of pair

        min_est = min(min_est, estimate)
    return min_est


def get_end_game_pressure(player: Player, game: Game) -> float:
    """
    Calculate pressure level based on how close opponents are to going out.
    Returns 0.0-1.0 where higher means more pressure to improve hand NOW.

    Pressure increases when:
    - Opponents have few hidden cards (close to going out)
    - We have many hidden cards (stuck with unknown values)
    """
    my_hidden = sum(1 for c in player.cards if not c.face_up)

    # Find the opponent closest to going out
    min_opponent_hidden = 6
    for p in game.players:
        if p.id == player.id:
            continue
        opponent_hidden = sum(1 for c in p.cards if not c.face_up)
        min_opponent_hidden = min(min_opponent_hidden, opponent_hidden)

    # No pressure if opponents have lots of hidden cards
    if min_opponent_hidden >= 4:
        return 0.0

    # Pressure scales based on how close opponent is to finishing
    # 3 hidden = mild pressure (0.4), 2 hidden = medium (0.7), 1 hidden = high (0.9), 0 = max (1.0)
    base_pressure = {0: 1.0, 1: 0.9, 2: 0.7, 3: 0.4}.get(min_opponent_hidden, 0.0)

    # Increase pressure further if WE have many hidden cards (more unknowns to worry about)
    hidden_risk_bonus = (my_hidden - 2) * 0.05  # +0.05 per hidden card above 2
    hidden_risk_bonus = max(0, hidden_risk_bonus)

    return min(1.0, base_pressure + hidden_risk_bonus)


def get_standings_pressure(player: Player, game: Game) -> float:
    """
    Calculate pressure based on player's position in standings.
    Returns 0.0-1.0 where higher = more behind, needs aggressive play.

    Factors:
    - How far behind the leader in total_score
    - How late in the game (current_round / num_rounds)
    """
    if len(game.players) < 2 or game.num_rounds <= 1:
        return 0.0

    # Calculate standings gap
    scores = [p.total_score for p in game.players]
    leader_score = min(scores)  # Lower is better in golf
    my_score = player.total_score
    gap = my_score - leader_score  # Positive = behind

    # Normalize gap (assume ~10 pts/round average, 20+ behind is dire)
    gap_pressure = min(gap / 20.0, 1.0) if gap > 0 else 0.0

    # Late-game multiplier (ramps up in final third of game)
    round_progress = game.current_round / game.num_rounds
    late_game_factor = max(0, (round_progress - 0.66) * 3)  # 0 until 66%, then ramps to 1

    return min(gap_pressure * (1 + late_game_factor), 1.0)


def count_rank_in_hand(player: Player, rank: Rank) -> int:
    """Count how many cards of a given rank the player has visible."""
    return sum(1 for c in player.cards if c.face_up and c.rank == rank)


def count_visible_cards_by_rank(game: Game) -> dict[Rank, int]:
    """
    Count all visible cards of each rank across the entire table.
    Includes: all face-up player cards + top of discard pile.

    Note: Buried discard cards are NOT counted because they reshuffle
    back into the deck when it empties.
    """
    counts: dict[Rank, int] = {rank: 0 for rank in Rank}

    # Count all face-up cards in all players' hands
    for player in game.players:
        for card in player.cards:
            if card.face_up:
                counts[card.rank] += 1

    # Count top of discard pile (the only visible discard)
    discard_top = game.discard_top()
    if discard_top:
        counts[discard_top.rank] += 1

    return counts


def get_pair_viability(rank: Rank, game: Game, exclude_discard_top: bool = False) -> float:
    """
    Calculate how viable it is to pair a card of this rank.
    Returns 0.0-1.0 where higher means better odds of finding a pair.

    In a standard deck: 4 of each rank (2 Jokers).
    If you can see N cards of that rank, only (4-N) remain.

    Args:
        rank: The rank we want to pair
        exclude_discard_top: If True, don't count discard top (useful when
                            evaluating taking that card - it won't be visible after)
    """
    counts = count_visible_cards_by_rank(game)
    visible = counts.get(rank, 0)

    # Adjust if we're evaluating the discard top card itself
    if exclude_discard_top:
        discard_top = game.discard_top()
        if discard_top and discard_top.rank == rank:
            visible = max(0, visible - 1)

    # Cards in deck for this rank
    max_copies = 2 if rank == Rank.JOKER else 4
    remaining = max(0, max_copies - visible)

    # Viability scales with remaining copies
    # 4 remaining = 1.0, 3 = 0.75, 2 = 0.5, 1 = 0.25, 0 = 0.0
    return remaining / max_copies


def get_game_phase(game: Game) -> str:
    """
    Determine current game phase based on average hidden cards.
    Returns: 'early', 'mid', or 'late'
    """
    total_hidden = sum(
        sum(1 for c in p.cards if not c.face_up)
        for p in game.players
    )
    avg_hidden = total_hidden / len(game.players) if game.players else 6

    if avg_hidden >= 4.5:
        return 'early'
    elif avg_hidden >= 2.5:
        return 'mid'
    else:
        return 'late'


def has_worse_visible_card(player: Player, card_value: int, options: GameOptions) -> bool:
    """Check if player has a visible card worse than the given value.

    Used to determine if taking a card from discard makes sense -
    we should only take if we have something worse to replace.
    """
    for c in player.cards:
        if c.face_up and get_ai_card_value(c, options) > card_value:
            return True
    return False


def get_column_partner_position(pos: int) -> int:
    """Get the column partner position for a given position.

    Column pairs: (0,3), (1,4), (2,5)
    """
    return (pos + 3) % 6 if pos < 3 else pos - 3


def filter_bad_pair_positions(
    positions: list[int],
    drawn_card: Card,
    player: Player,
    options: GameOptions
) -> list[int]:
    """Filter out positions that would create wasteful pairs with negative cards.

    When placing a card (especially negative value cards like 2s or Jokers),
    we should avoid positions where the column partner is a visible card of
    the same rank - pairing negative cards wastes their value.

    Args:
        positions: List of candidate positions
        drawn_card: The card we're placing
        player: The player's hand
        options: Game options for house rules

    Returns:
        Filtered list excluding bad pair positions. If all positions are bad,
        returns the original list (we have to place somewhere).
    """
    drawn_value = get_ai_card_value(drawn_card, options)

    # Only filter if the drawn card has negative value (2s, Jokers, super_kings Kings)
    # Pairing positive cards is fine - it turns their value to 0
    if drawn_value >= 0:
        return positions

    # Exception: Eagle Eye makes pairing Jokers GOOD (-4 instead of 0)
    if options.eagle_eye and drawn_card.rank == Rank.JOKER:
        return positions

    # Exception: Negative Pairs Keep Value makes pairing negative cards GOOD
    if options.negative_pairs_keep_value:
        return positions

    filtered = []
    for pos in positions:
        partner_pos = get_column_partner_position(pos)
        partner = player.cards[partner_pos]

        # If partner is face-up and same rank, this would create a wasteful pair
        if partner.face_up and partner.rank == drawn_card.rank:
            continue  # Skip this position

        filtered.append(pos)

    # If all positions were filtered out, return original (must place somewhere)
    return filtered if filtered else positions


@dataclass
class CPUProfile:
    """Pre-defined CPU player profile with personality traits."""
    name: str
    style: str  # Brief description shown to players
    # Tipping point: swap if card value is at or above this (4-8)
    swap_threshold: int
    # How likely to hold high cards hoping for pairs (0.0-1.0)
    pair_hope: float
    # Screw your neighbor: tendency to go out early (0.0-1.0)
    aggression: float
    # Wildcard factor: chance of unexpected plays (0.0-0.3)
    unpredictability: float

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "style": self.style,
        }


# Pre-defined CPU profiles (3 female, 3 male, 2 non-binary)
CPU_PROFILES = [
    # Female profiles
    CPUProfile(
        name="Sofia",
        style="Calculated & Patient",
        swap_threshold=4,
        pair_hope=0.2,
        aggression=0.2,
        unpredictability=0.02,
    ),
    CPUProfile(
        name="Maya",
        style="Aggressive Closer",
        swap_threshold=6,
        pair_hope=0.4,
        aggression=0.85,
        unpredictability=0.1,
    ),
    CPUProfile(
        name="Priya",
        style="Pair Hunter",
        swap_threshold=7,
        pair_hope=0.8,
        aggression=0.5,
        unpredictability=0.05,
    ),
    # Male profiles
    CPUProfile(
        name="Marcus",
        style="Steady Eddie",
        swap_threshold=5,
        pair_hope=0.35,
        aggression=0.4,
        unpredictability=0.03,
    ),
    CPUProfile(
        name="Kenji",
        style="Risk Taker",
        swap_threshold=8,
        pair_hope=0.7,
        aggression=0.75,
        unpredictability=0.12,
    ),
    CPUProfile(
        name="Diego",
        style="Chaotic Gambler",
        swap_threshold=6,
        pair_hope=0.5,
        aggression=0.6,
        unpredictability=0.28,
    ),
    # Non-binary profiles
    CPUProfile(
        name="River",
        style="Adaptive Strategist",
        swap_threshold=5,
        pair_hope=0.45,
        aggression=0.55,
        unpredictability=0.08,
    ),
    CPUProfile(
        name="Sage",
        style="Sneaky Finisher",
        swap_threshold=5,
        pair_hope=0.3,
        aggression=0.9,
        unpredictability=0.15,
    ),
]

# Track profiles per room (room_code -> set of used profile names)
_room_used_profiles: dict[str, set[str]] = {}
# Track cpu_id -> (room_code, profile) mapping
_cpu_profiles: dict[str, tuple[str, CPUProfile]] = {}


def get_available_profile(room_code: str) -> Optional[CPUProfile]:
    """Get a random available CPU profile for a specific room."""
    used_in_room = _room_used_profiles.get(room_code, set())
    available = [p for p in CPU_PROFILES if p.name not in used_in_room]
    if not available:
        return None
    profile = random.choice(available)
    if room_code not in _room_used_profiles:
        _room_used_profiles[room_code] = set()
    _room_used_profiles[room_code].add(profile.name)
    return profile


def release_profile(name: str, room_code: str):
    """Release a CPU profile back to the room's pool."""
    if room_code in _room_used_profiles:
        _room_used_profiles[room_code].discard(name)
        # Clean up empty room entries
        if not _room_used_profiles[room_code]:
            del _room_used_profiles[room_code]
    # Also remove from cpu_profiles by finding the cpu_id with this profile in this room
    to_remove = [
        cpu_id for cpu_id, (rc, profile) in _cpu_profiles.items()
        if profile.name == name and rc == room_code
    ]
    for cpu_id in to_remove:
        del _cpu_profiles[cpu_id]


def cleanup_room_profiles(room_code: str):
    """Clean up all profile tracking for a room when it's deleted."""
    if room_code in _room_used_profiles:
        del _room_used_profiles[room_code]
    # Remove all cpu_profiles for this room
    to_remove = [cpu_id for cpu_id, (rc, _) in _cpu_profiles.items() if rc == room_code]
    for cpu_id in to_remove:
        del _cpu_profiles[cpu_id]


def reset_all_profiles():
    """Reset all profile tracking (for cleanup)."""
    _room_used_profiles.clear()
    _cpu_profiles.clear()


def get_profile(cpu_id: str) -> Optional[CPUProfile]:
    """Get the profile for a CPU player."""
    entry = _cpu_profiles.get(cpu_id)
    return entry[1] if entry else None


def assign_profile(cpu_id: str, room_code: str) -> Optional[CPUProfile]:
    """Assign a random profile to a CPU player in a specific room."""
    profile = get_available_profile(room_code)
    if profile:
        _cpu_profiles[cpu_id] = (room_code, profile)
    return profile


def assign_specific_profile(cpu_id: str, profile_name: str, room_code: str) -> Optional[CPUProfile]:
    """Assign a specific profile to a CPU player by name in a specific room."""
    used_in_room = _room_used_profiles.get(room_code, set())
    # Check if profile exists and is available in this room
    for profile in CPU_PROFILES:
        if profile.name == profile_name and profile.name not in used_in_room:
            if room_code not in _room_used_profiles:
                _room_used_profiles[room_code] = set()
            _room_used_profiles[room_code].add(profile.name)
            _cpu_profiles[cpu_id] = (room_code, profile)
            return profile
    return None


def get_available_profiles(room_code: str) -> list[dict]:
    """Get available CPU profiles for a specific room."""
    used_in_room = _room_used_profiles.get(room_code, set())
    return [p.to_dict() for p in CPU_PROFILES if p.name not in used_in_room]


def get_all_profiles() -> list[dict]:
    """Get all CPU profiles for display."""
    return [p.to_dict() for p in CPU_PROFILES]


class GolfAI:
    """AI decision-making for Golf game."""

    @staticmethod
    def choose_initial_flips(count: int = 2) -> list[int]:
        """Choose cards to flip at the start."""
        if count == 0:
            return []
        if count == 1:
            return [random.randint(0, 5)]

        # For 2 cards, prefer different columns for pair info
        options = [
            [0, 4], [2, 4], [3, 1], [5, 1],
            [0, 5], [2, 3],
        ]
        return random.choice(options)

    @staticmethod
    def should_take_discard(discard_card: Optional[Card], player: Player,
                            profile: CPUProfile, game: Game) -> bool:
        """Decide whether to take from discard pile or deck."""
        if not discard_card:
            return False

        options = game.options
        discard_value = get_ai_card_value(discard_card, options)

        ai_log(f"--- {profile.name} considering discard: {discard_card.rank.value}{discard_card.suit.value} (value={discard_value}) ---")

        # SAFEGUARD: If we have only 1 face-down card, taking from discard
        # forces us to swap and go out. Check if that would be acceptable.
        face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
        if len(face_down) == 1:
            # Calculate projected score if we swap into the last face-down position
            projected_score = 0
            for i, c in enumerate(player.cards):
                if i == face_down[0]:
                    projected_score += discard_value
                elif c.face_up:
                    projected_score += get_ai_card_value(c, options)

            # Apply column pair cancellation
            for col in range(3):
                top_idx, bot_idx = col, col + 3
                top_card = discard_card if top_idx == face_down[0] else player.cards[top_idx]
                bot_card = discard_card if bot_idx == face_down[0] else player.cards[bot_idx]
                if top_card.rank == bot_card.rank:
                    top_val = discard_value if top_idx == face_down[0] else get_ai_card_value(player.cards[top_idx], options)
                    bot_val = discard_value if bot_idx == face_down[0] else get_ai_card_value(player.cards[bot_idx], options)
                    projected_score -= (top_val + bot_val)

            # Don't take if score would be terrible
            max_acceptable = 18 if profile.aggression > 0.6 else (16 if profile.aggression > 0.3 else 14)
            ai_log(f"  Go-out check: projected={projected_score}, max_acceptable={max_acceptable}")
            if projected_score > max_acceptable:
                # Exception: still take if it's an excellent card (Joker, 2, King, Ace)
                # and we have a visible bad card to replace instead
                if discard_value >= 0 and discard_card.rank not in (Rank.ACE, Rank.TWO, Rank.KING, Rank.JOKER):
                    ai_log(f"  >> REJECT: would force go-out with {projected_score} pts")
                    return False  # Don't take - would force bad go-out

        # Unpredictable players occasionally make random choice
        # BUT only for reasonable cards (value <= 5) - never randomly take bad cards
        if random.random() < profile.unpredictability:
            if discard_value <= 5:
                return random.choice([True, False])

        # Always take Jokers and Kings (even better with house rules)
        if discard_card.rank == Rank.JOKER:
            # Eagle Eye: If we have a visible Joker, take to pair them (doubled negative!)
            if options.eagle_eye:
                for card in player.cards:
                    if card.face_up and card.rank == Rank.JOKER:
                        ai_log(f"  >> TAKE: Joker for Eagle Eye pair")
                        return True
            ai_log(f"  >> TAKE: Joker (always take)")
            return True

        if discard_card.rank == Rank.KING:
            ai_log(f"  >> TAKE: King (always take)")
            return True

        # One-eyed Jacks: J♥ and J♠ are worth 0, always take them
        if options.one_eyed_jacks:
            if discard_card.rank == Rank.JACK and discard_card.suit in (Suit.HEARTS, Suit.SPADES):
                ai_log(f"  >> TAKE: One-eyed Jack (worth 0)")
                return True

        # Wolfpack pursuit: Take Jacks when pursuing the bonus
        if options.wolfpack and discard_card.rank == Rank.JACK:
            jack_count = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK)
            if jack_count >= 2 and profile.aggression > 0.5:
                ai_log(f"  >> TAKE: Jack for wolfpack pursuit ({jack_count} Jacks visible)")
                return True

        # Auto-take 10s when ten_penny enabled (they're worth 1)
        if discard_card.rank == Rank.TEN and options.ten_penny:
            ai_log(f"  >> TAKE: 10 (ten_penny rule)")
            return True

        # Four-of-a-kind pursuit: Take cards when building toward bonus
        if options.four_of_a_kind and profile.aggression > 0.5:
            rank_count = sum(1 for c in player.cards if c.face_up and c.rank == discard_card.rank)
            if rank_count >= 2:
                # Already have 2+ of this rank, take to pursue four-of-a-kind!
                ai_log(f"  >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
                return True

        # Take card if it could make a column pair (but NOT for negative value cards)
        # Pairing negative cards is bad - you lose the negative benefit
        if discard_value > 0:
            for i, card in enumerate(player.cards):
                pair_pos = (i + 3) % 6 if i < 3 else i - 3
                pair_card = player.cards[pair_pos]

                # Direct rank match
                if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
                    ai_log(f"  >> TAKE: can pair with visible {card.rank.value} at pos {i}")
                    return True

        # Take low cards (using house rule adjusted values)
        # Threshold adjusts by game phase - early game be picky, late game less so
        phase = get_game_phase(game)
        base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)

        if discard_value <= base_threshold:
            ai_log(f"  >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)")
            return True

        # Calculate end-game pressure from opponents close to going out
        pressure = get_end_game_pressure(player, game)

        # Under pressure, expand what we consider "worth taking"
        # When opponents are close to going out, take decent cards to avoid
        # getting stuck with unknown bad cards when the round ends
        if pressure > 0.2:
            # Scale threshold: at pressure 0.2 take 4s, at 0.5+ take 6s
            pressure_threshold = 3 + int(pressure * 6)  # 4 to 9 based on pressure
            pressure_threshold = min(pressure_threshold, 7)  # Cap at 7
            if discard_value <= pressure_threshold:
                # Only take if we have hidden cards that could be worse
                my_hidden = sum(1 for c in player.cards if not c.face_up)
                if my_hidden > 0:
                    ai_log(f"  >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}")
                    return True

        # Check if we have cards worse than the discard
        worst_visible = -999
        for card in player.cards:
            if card.face_up:
                worst_visible = max(worst_visible, get_ai_card_value(card, options))

        if worst_visible > discard_value + 1:
            # Sanity check: only take if we actually have something worse to replace
            # This prevents taking a bad card when all visible cards are better
            if has_worse_visible_card(player, discard_value, options):
                ai_log(f"  >> TAKE: have worse visible card ({worst_visible})")
                return True

        ai_log(f"  >> PASS: drawing from deck instead")
        return False

    @staticmethod
    def calculate_swap_score(
        pos: int,
        drawn_card: Card,
        drawn_value: int,
        player: Player,
        options: GameOptions,
        game: Game,
        profile: CPUProfile
    ) -> float:
        """
        Calculate a score for swapping into a specific position.
        Higher score = better swap. Weighs all incentives:
        - Pair bonus (highest priority for positive cards)
        - Point gain from replacement
        - Reveal bonus for hidden cards
        - Go-out safety check

        Personality traits affect weights:
        - pair_hope: higher = values pairing more, lower = prefers spreading
        - aggression: higher = more willing to go out, take risks
        - swap_threshold: affects how picky about card values
        """
        current_card = player.cards[pos]
        partner_pos = get_column_partner_position(pos)
        partner_card = player.cards[partner_pos]

        score = 0.0

        # Personality-based weight modifiers
        # pair_hope: 0.0-1.0, affects how much we value pairing vs spreading
        pair_weight = 1.0 + profile.pair_hope  # Range: 1.0 to 2.0
        spread_weight = 2.0 - profile.pair_hope  # Range: 1.0 to 2.0 (inverse)

        # 1. PAIR BONUS - Creating a pair
        #    pair_hope affects how much we value this
        if partner_card.face_up and partner_card.rank == drawn_card.rank:
            partner_value = get_ai_card_value(partner_card, options)

            if drawn_value >= 0:
                # Good pair! Both cards cancel to 0
                pair_bonus = drawn_value + partner_value
                score += pair_bonus * pair_weight  # Pair hunters value this more
            else:
                # Pairing negative cards
                if options.eagle_eye and drawn_card.rank == Rank.JOKER:
                    score += 8 * pair_weight  # Eagle Eye Joker pairs = -4
                elif options.negative_pairs_keep_value:
                    # Negative Pairs Keep Value: pairing 2s/Jokers is NOW good!
                    # Pair of 2s = -4, pair of Jokers = -4 (instead of 0)
                    pair_benefit = abs(drawn_value + partner_value)
                    score += pair_benefit * pair_weight
                    ai_log(f"    Negative pair keep value bonus: +{pair_benefit * pair_weight:.1f}")
                else:
                    # Standard rules: penalty for wasting negative cards
                    penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
                    score -= penalty

        # 1b. SPREAD BONUS - Not pairing good cards (spreading them out)
        #     Players with low pair_hope prefer spreading aces/2s across columns
        if not partner_card.face_up or partner_card.rank != drawn_card.rank:
            if drawn_value <= 1:  # Excellent cards (K, 2, A, Joker)
                # Small bonus for spreading - scales with spread preference
                score += spread_weight * 0.5

        # 2. POINT GAIN - Direct value improvement
        if current_card.face_up:
            current_value = get_ai_card_value(current_card, options)

            # CRITICAL: Check if current card is part of an existing column pair
            # If so, breaking the pair is usually terrible - the paired column is worth 0,
            # but after breaking it becomes (drawn_value + orphaned_partner_value)
            if partner_card.face_up and partner_card.rank == current_card.rank:
                partner_value = get_ai_card_value(partner_card, options)

                # Determine the current column value (what the pair contributes)
                if options.eagle_eye and current_card.rank == Rank.JOKER:
                    # Eagle Eye: paired jokers contribute -4 total
                    old_column_value = -4
                    # After swap: orphan joker becomes +2 (unpaired eagle_eye value)
                    new_column_value = drawn_value + 2
                    point_gain = old_column_value - new_column_value
                    ai_log(f"    Breaking Eagle Eye joker pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
                elif options.negative_pairs_keep_value and (current_value < 0 or partner_value < 0):
                    # Negative pairs keep value: column is worth sum of both values
                    old_column_value = current_value + partner_value
                    new_column_value = drawn_value + partner_value
                    point_gain = old_column_value - new_column_value
                    ai_log(f"    Breaking negative-keep pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
                else:
                    # Standard pair - column is worth 0
                    # After swap: column becomes drawn_value + partner_value
                    old_column_value = 0
                    new_column_value = drawn_value + partner_value
                    point_gain = old_column_value - new_column_value
                    ai_log(f"    Breaking standard pair at pos {pos}: column 0 -> {new_column_value}, gain={point_gain}")
            else:
                # No existing pair - normal calculation
                point_gain = current_value - drawn_value

            score += point_gain
        else:
            # Hidden card - expected value ~4.5
            expected_hidden = 4.5
            point_gain = expected_hidden - drawn_value
            # Conservative players (low swap_threshold) discount uncertain gains more
            discount = 0.5 + (profile.swap_threshold / 16)  # Range: 0.5 to 1.0
            score += point_gain * discount

        # 3. REVEAL BONUS - Value of revealing hidden cards
        #    More aggressive players want to reveal faster to go out
        if not current_card.face_up:
            hidden_count = sum(1 for c in player.cards if not c.face_up)
            reveal_bonus = min(hidden_count, 4)

            # Aggressive players get bigger reveal bonus (want to go out faster)
            aggression_multiplier = 0.8 + profile.aggression * 0.4  # Range: 0.8 to 1.2

            # Scale by card quality
            if drawn_value <= 0:  # Excellent
                score += reveal_bonus * 1.2 * aggression_multiplier
            elif drawn_value == 1:  # Great
                score += reveal_bonus * 1.0 * aggression_multiplier
            elif drawn_value <= 4:  # Good
                score += reveal_bonus * 0.6 * aggression_multiplier
            elif drawn_value <= 6:  # Medium
                score += reveal_bonus * 0.3 * aggression_multiplier
            # Bad cards: no reveal bonus

        # 4. FUTURE PAIR POTENTIAL
        #    Pair hunters value positions where both cards are hidden
        if not current_card.face_up and not partner_card.face_up:
            pair_viability = get_pair_viability(drawn_card.rank, game)
            score += pair_viability * pair_weight * 0.5

        # 4b. FOUR OF A KIND PURSUIT
        #     When four_of_a_kind rule is enabled, boost score for collecting 3rd/4th card
        if options.four_of_a_kind:
            # Count how many of this rank player already has visible (excluding current position)
            rank_count = sum(
                1 for i, c in enumerate(player.cards)
                if c.face_up and c.rank == drawn_card.rank and i != pos
            )
            if rank_count >= 2:
                # Already have 2+ of this rank, getting more is great for 4-of-a-kind
                four_kind_bonus = rank_count * 4  # 8 for 2 cards, 12 for 3 cards
                # Boost when behind in standings
                standings_pressure = get_standings_pressure(player, game)
                if standings_pressure > 0.3:
                    four_kind_bonus *= (1 + standings_pressure * 0.5)  # Up to 50% boost
                score += four_kind_bonus
                ai_log(f"    Four-of-a-kind pursuit bonus: +{four_kind_bonus:.1f}")

        # 4c. WOLFPACK PURSUIT - Aggressive players chase Jack pairs for -20 bonus
        if options.wolfpack and profile.aggression > 0.5:
            # Count Jack pairs already formed
            jack_pair_count = 0
            for col in range(3):
                top, bot = player.cards[col], player.cards[col + 3]
                if top.face_up and bot.face_up and top.rank == Rank.JACK and bot.rank == Rank.JACK:
                    jack_pair_count += 1

            # Count visible Jacks that could form pairs
            visible_jacks = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK)

            if drawn_card.rank == Rank.JACK:
                # Drawing a Jack - evaluate wolfpack potential
                if jack_pair_count == 1:
                    # Already have one pair! Second pair gives -20 bonus
                    if partner_card.face_up and partner_card.rank == Rank.JACK:
                        # Completing second Jack pair!
                        wolfpack_bonus = 15 * profile.aggression
                        score += wolfpack_bonus
                        ai_log(f"    Wolfpack pursuit: completing 2nd Jack pair! +{wolfpack_bonus:.1f}")
                    elif not partner_card.face_up:
                        # Partner unknown, Jack could pair
                        wolfpack_bonus = 6 * profile.aggression
                        score += wolfpack_bonus
                        ai_log(f"    Wolfpack pursuit: Jack with unknown partner +{wolfpack_bonus:.1f}")
                elif visible_jacks >= 1 and partner_card.face_up and partner_card.rank == Rank.JACK:
                    # Completing first Jack pair while having other Jacks
                    wolfpack_bonus = 8 * profile.aggression
                    score += wolfpack_bonus
                    ai_log(f"    Wolfpack pursuit: first Jack pair +{wolfpack_bonus:.1f}")

        # 4d. COMEBACK AGGRESSION - Boost reveal bonus when behind in late game
        standings_pressure = get_standings_pressure(player, game)
        if standings_pressure > 0.3 and not current_card.face_up:
            # Behind in standings - boost incentive to reveal and play faster
            comeback_bonus = standings_pressure * 3 * profile.aggression
            score += comeback_bonus
            ai_log(f"    Comeback aggression bonus: +{comeback_bonus:.1f} (pressure={standings_pressure:.2f})")

        # 5. GO-OUT SAFETY - Penalty for going out with bad score
        face_down_positions = [i for i, c in enumerate(player.cards) if not c.face_up]
        if len(face_down_positions) == 1 and pos == face_down_positions[0]:
            projected_score = drawn_value
            for i, c in enumerate(player.cards):
                if i != pos and c.face_up:
                    projected_score += get_ai_card_value(c, options)

            # Apply pair cancellation
            for col in range(3):
                top_idx, bot_idx = col, col + 3
                top_card = drawn_card if top_idx == pos else player.cards[top_idx]
                bot_card = drawn_card if bot_idx == pos else player.cards[bot_idx]
                if top_card.rank == bot_card.rank:
                    top_val = drawn_value if top_idx == pos else get_ai_card_value(player.cards[top_idx], options)
                    bot_val = drawn_value if bot_idx == pos else get_ai_card_value(player.cards[bot_idx], options)
                    projected_score -= (top_val + bot_val)

            # Aggressive players accept higher scores when going out
            max_acceptable = 12 + int(profile.aggression * 8)  # Range: 12 to 20
            if projected_score > max_acceptable:
                score -= 100

        return score

    @staticmethod
    def choose_swap_or_discard(drawn_card: Card, player: Player,
                                profile: CPUProfile, game: Game) -> Optional[int]:
        """
        Decide whether to swap the drawn card or discard.
        Returns position to swap with, or None to discard.

        Uses a unified scoring system that weighs all incentives:
        - Pair creation (best for positive cards, bad for negative)
        - Point gain from replacement
        - Revealing hidden cards (catching up, information)
        - Safety (don't go out with terrible score)
        """
        options = game.options
        drawn_value = get_ai_card_value(drawn_card, options)

        ai_log(f"=== {profile.name} deciding: drew {drawn_card.rank.value}{drawn_card.suit.value} (value={drawn_value}) ===")
        ai_log(f"  Personality: pair_hope={profile.pair_hope:.2f}, aggression={profile.aggression:.2f}, "
               f"swap_threshold={profile.swap_threshold}, unpredictability={profile.unpredictability:.2f}")

        # Log current hand state
        hand_str = " ".join(
            f"[{i}:{c.rank.value if c.face_up else '?'}]" for i, c in enumerate(player.cards)
        )
        ai_log(f"  Hand: {hand_str}")

        # Unpredictable players occasionally make surprising plays
        # But never discard excellent cards (Jokers, 2s, Kings, Aces)
        if random.random() < profile.unpredictability:
            if drawn_value > 1:
                face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
                if face_down and random.random() < 0.5:
                    choice = random.choice(face_down)
                    ai_log(f"  >> UNPREDICTABLE: randomly chose position {choice}")
                    return choice

        # Calculate score for each position
        position_scores: list[tuple[int, float]] = []
        for pos in range(6):
            score = GolfAI.calculate_swap_score(
                pos, drawn_card, drawn_value, player, options, game, profile
            )
            position_scores.append((pos, score))

        # Log all scores
        ai_log(f"  Position scores:")
        for pos, score in position_scores:
            card = player.cards[pos]
            partner_pos = get_column_partner_position(pos)
            partner = player.cards[partner_pos]
            card_str = card.rank.value if card.face_up else "?"
            partner_str = partner.rank.value if partner.face_up else "?"
            pair_indicator = " [PAIR]" if partner.face_up and partner.rank == drawn_card.rank else ""
            reveal_indicator = " [REVEAL]" if not card.face_up else ""
            ai_log(f"    pos {pos} ({card_str}, partner={partner_str}): {score:+.2f}{pair_indicator}{reveal_indicator}")

        # Filter to positive scores only
        positive_scores = [(p, s) for p, s in position_scores if s > 0]

        best_pos: Optional[int] = None
        best_score = 0.0

        if positive_scores:
            # Sort by score descending
            positive_scores.sort(key=lambda x: x[1], reverse=True)
            best_pos, best_score = positive_scores[0]

            # PERSONALITY TIE-BREAKER: When top options are close, let personality decide
            close_threshold = 2.0  # Options within 2 points are "close"
            close_options = [(p, s) for p, s in positive_scores if s >= best_score - close_threshold]

            if len(close_options) > 1:
                ai_log(f"  TIE-BREAKER: {len(close_options)} options within {close_threshold} pts of best ({best_score:.2f})")
                original_best = best_pos

                # Multiple close options - personality decides
                # Categorize each option
                for pos, score in close_options:
                    partner_pos = get_column_partner_position(pos)
                    partner_card = player.cards[partner_pos]
                    is_pair_move = partner_card.face_up and partner_card.rank == drawn_card.rank
                    is_reveal_move = not player.cards[pos].face_up

                    # Pair hunters prefer pair moves
                    if is_pair_move and profile.pair_hope > 0.6:
                        ai_log(f"    >> PAIR_HOPE ({profile.pair_hope:.2f}): chose pair move at pos {pos}")
                        best_pos = pos
                        break
                    # Aggressive players prefer reveal moves (to go out faster)
                    if is_reveal_move and profile.aggression > 0.7:
                        ai_log(f"    >> AGGRESSION ({profile.aggression:.2f}): chose reveal move at pos {pos}")
                        best_pos = pos
                        break
                    # Conservative players prefer safe visible card replacements
                    if not is_reveal_move and profile.swap_threshold <= 4:
                        ai_log(f"    >> CONSERVATIVE (threshold={profile.swap_threshold}): chose safe move at pos {pos}")
                        best_pos = pos
                        break

                # If still tied, add small random factor based on unpredictability
                if profile.unpredictability > 0.1 and random.random() < profile.unpredictability:
                    best_pos = random.choice([p for p, s in close_options])
                    ai_log(f"    >> RANDOM (unpredictability={profile.unpredictability:.2f}): chose pos {best_pos}")

                if best_pos != original_best:
                    ai_log(f"  Tie-breaker changed choice: {original_best} -> {best_pos}")

        # Blackjack special case: chase exactly 21
        if options.blackjack and best_pos is None:
            current_score = player.calculate_score()
            if current_score >= 15:
                for i, card in enumerate(player.cards):
                    if card.face_up:
                        potential_change = drawn_value - get_ai_card_value(card, options)
                        if current_score + potential_change == 21:
                            if random.random() < profile.aggression:
                                ai_log(f"  >> BLACKJACK: chasing 21 at position {i}")
                                return i

        # Pair hunters might hold medium cards hoping for matches
        if best_pos is not None and not player.cards[best_pos].face_up:
            if drawn_value >= 5:  # Only hold out for medium/high cards
                pair_viability = get_pair_viability(drawn_card.rank, game)
                phase = get_game_phase(game)
                pressure = get_end_game_pressure(player, game)

                effective_hope = profile.pair_hope * pair_viability
                if phase == 'late' or pressure > 0.5:
                    effective_hope *= 0.3

                ai_log(f"  Hold-for-pair check: value={drawn_value}, viability={pair_viability:.2f}, "
                       f"phase={phase}, effective_hope={effective_hope:.2f}")

                if effective_hope > 0.5 and random.random() < effective_hope:
                    ai_log(f"  >> HOLDING: discarding {drawn_card.rank.value} hoping for future pair")
                    return None  # Discard and hope for pair later

        # Log final decision
        if best_pos is not None:
            target_card = player.cards[best_pos]
            target_str = target_card.rank.value if target_card.face_up else "hidden"
            ai_log(f"  DECISION: SWAP into position {best_pos} (replacing {target_str}) [score={best_score:.2f}]")
        else:
            ai_log(f"  DECISION: DISCARD {drawn_card.rank.value} (no good swap options)")

        return best_pos

    @staticmethod
    def choose_flip_after_discard(player: Player, profile: CPUProfile) -> int:
        """Choose which face-down card to flip after discarding."""
        face_down = [i for i, c in enumerate(player.cards) if not c.face_up]

        if not face_down:
            return 0

        # Prefer flipping cards that could reveal pair info
        for i in face_down:
            pair_pos = (i + 3) % 6 if i < 3 else i - 3
            if player.cards[pair_pos].face_up:
                return i

        return random.choice(face_down)

    @staticmethod
    def should_skip_optional_flip(player: Player, profile: CPUProfile, game: Game) -> bool:
        """
        Decide whether to skip the optional flip in endgame mode.

        In endgame (Suspense) mode, the flip is optional. AI should generally
        flip for information, but may skip if:
        - Already has good information about their hand
        - Wants to keep cards hidden for suspense
        - Random unpredictability factor

        Returns True if AI should skip the flip, False if it should flip.
        """
        face_down = [i for i, c in enumerate(player.cards) if not c.face_up]

        if not face_down:
            return True  # No cards to flip

        # Very conservative players (low aggression) might skip to keep hidden
        # But information is usually valuable, so mostly flip
        skip_chance = 0.1  # Base 10% chance to skip

        # More hidden cards = more value in flipping for information
        if len(face_down) >= 3:
            skip_chance = 0.05  # Less likely to skip with many hidden cards

        # If only 1 hidden card, we might skip to keep opponents guessing
        if len(face_down) == 1:
            skip_chance = 0.2 + (1.0 - profile.aggression) * 0.2

        # Unpredictable players are more random about this
        skip_chance += profile.unpredictability * 0.15

        ai_log(f"  Optional flip decision: {len(face_down)} face-down cards, skip_chance={skip_chance:.2f}")

        if random.random() < skip_chance:
            ai_log(f"  >> SKIP: choosing not to flip (endgame mode)")
            return True

        ai_log(f"  >> FLIP: choosing to reveal for information")
        return False

    @staticmethod
    def should_knock_early(game: Game, player: Player, profile: CPUProfile) -> bool:
        """
        Decide whether to use knock_early to flip all remaining cards at once.

        Only available when knock_early house rule is enabled and player
        has 1-2 face-down cards. This is a gamble - aggressive players
        with good visible cards may take the risk.
        """
        if not game.options.knock_early:
            return False

        face_down = [c for c in player.cards if not c.face_up]
        if len(face_down) == 0 or len(face_down) > 2:
            return False

        # Calculate current visible score
        visible_score = 0
        for col in range(3):
            top_idx, bot_idx = col, col + 3
            top = player.cards[top_idx]
            bot = player.cards[bot_idx]

            # Only count if both are visible
            if top.face_up and bot.face_up:
                if top.rank == bot.rank:
                    continue  # Pair = 0
                visible_score += get_ai_card_value(top, game.options)
                visible_score += get_ai_card_value(bot, game.options)
            elif top.face_up:
                visible_score += get_ai_card_value(top, game.options)
            elif bot.face_up:
                visible_score += get_ai_card_value(bot, game.options)

        # Aggressive players with low visible scores might knock early
        # Expected value of hidden card is ~4.5
        expected_hidden_total = len(face_down) * 4.5
        projected_score = visible_score + expected_hidden_total

        # More aggressive players accept higher risk
        max_acceptable = 8 + int(profile.aggression * 10)  # Range: 8 to 18

        if projected_score <= max_acceptable:
            # Add some randomness based on aggression
            knock_chance = profile.aggression * 0.4  # Max 40% for most aggressive
            if random.random() < knock_chance:
                ai_log(f"  Knock early: taking the gamble! (projected {projected_score:.1f})")
                return True

        return False

    @staticmethod
    def should_use_flip_action(game: Game, player: Player, profile: CPUProfile) -> Optional[int]:
        """
        Decide whether to use flip-as-action instead of drawing.

        Returns card index to flip, or None to draw normally.

        Only available when flip_as_action house rule is enabled.
        Conservative players may prefer this to avoid risky deck draws.
        """
        if not game.options.flip_as_action:
            return None

        # Find face-down cards
        face_down = [(i, c) for i, c in enumerate(player.cards) if not c.face_up]
        if not face_down:
            return None  # No cards to flip

        # Check if discard has a good card we want - if so, don't use flip action
        discard_top = game.discard_top()
        if discard_top:
            discard_value = get_ai_card_value(discard_top, game.options)
            if discard_value <= 2:  # Good card available
                ai_log(f"  Flip-as-action: skipping, good discard available ({discard_value})")
                return None

        # Aggressive players prefer drawing (more action, chance to improve)
        if profile.aggression > 0.6:
            ai_log(f"  Flip-as-action: skipping, too aggressive ({profile.aggression:.2f})")
            return None

        # Consider flip action with probability based on personality
        # Conservative players (low aggression) are more likely to use it
        flip_chance = (1.0 - profile.aggression) * 0.25  # Max 25% for most conservative

        # Increase chance if we have many hidden cards (info is valuable)
        if len(face_down) >= 4:
            flip_chance *= 1.5

        if random.random() > flip_chance:
            return None

        ai_log(f"  Flip-as-action: choosing to flip instead of draw")

        # Prioritize positions where column partner is visible (pair info)
        for idx, card in face_down:
            partner_idx = idx + 3 if idx < 3 else idx - 3
            if player.cards[partner_idx].face_up:
                ai_log(f"    Flipping position {idx} (partner visible)")
                return idx

        # Random face-down card
        choice = random.choice(face_down)[0]
        ai_log(f"    Flipping position {choice} (random)")
        return choice

    @staticmethod
    def should_go_out_early(player: Player, game: Game, profile: CPUProfile) -> bool:
        """
        Decide if CPU should try to go out (reveal all cards) to screw neighbors.
        """
        options = game.options
        face_down_count = sum(1 for c in player.cards if not c.face_up)

        if face_down_count > 2:
            return False

        estimated_score = player.calculate_score()

        # Blackjack: If score is exactly 21, definitely go out (becomes 0!)
        if options.blackjack and estimated_score == 21:
            return True

        # Base threshold based on aggression
        go_out_threshold = 8 if profile.aggression > 0.7 else (12 if profile.aggression > 0.4 else 16)

        # COMEBACK MODE: Accept higher scores when significantly behind
        standings_pressure = get_standings_pressure(player, game)
        if standings_pressure > 0.5:
            # Behind and late - swing for the fences
            go_out_threshold += int(standings_pressure * 6)  # Up to +6 points tolerance
            ai_log(f"  Comeback mode: raised go-out threshold to {go_out_threshold}")

        # Knock Bonus (-5 for going out): Can afford to go out with higher score
        if options.knock_bonus:
            go_out_threshold += 5

        # Knock Penalty (+10 if not lowest): Need to be confident we're lowest
        if options.knock_penalty:
            opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
            # Conservative players require bigger lead
            safety_margin = 5 if profile.aggression < 0.4 else 2
            if estimated_score > opponent_min - safety_margin:
                # We might not have the lowest score - be cautious
                go_out_threshold -= 4

        # Tied Shame: Estimate if we might tie someone
        if options.tied_shame:
            for p in game.players:
                if p.id == player.id:
                    continue
                visible = sum(get_ai_card_value(c, options) for c in p.cards if c.face_up)
                hidden_count = sum(1 for c in p.cards if not c.face_up)
                # Rough estimate - if visible scores are close, be cautious
                if hidden_count <= 2 and abs(visible - estimated_score) <= 3:
                    go_out_threshold -= 2
                    break

        # Underdog Bonus: Minor factor - you get -3 for lowest regardless
        # This slightly reduces urgency to go out first
        if options.underdog_bonus:
            go_out_threshold -= 1

        # HIGH SCORE CAUTION: When our score is >10, be extra careful
        # Opponents' hidden cards could easily beat us with pairs or low cards
        if estimated_score > 10:
            # Get pessimistic estimate of opponent's potential score
            opponent_min_pessimistic = estimate_opponent_min_score(player, game, optimistic=False)
            opponent_min_optimistic = estimate_opponent_min_score(player, game, optimistic=True)

            ai_log(f"  High score caution: our score={estimated_score}, "
                   f"opponent estimates: optimistic={opponent_min_optimistic}, pessimistic={opponent_min_pessimistic}")

            # If opponents could potentially beat us, reduce our willingness to go out
            if opponent_min_pessimistic < estimated_score:
                # Calculate how risky this is
                risk_margin = estimated_score - opponent_min_pessimistic
                # Reduce threshold based on risk (more risk = lower threshold)
                risk_penalty = min(risk_margin, 8)  # Cap at 8 point penalty
                go_out_threshold -= risk_penalty
                ai_log(f"  Risk penalty: -{risk_penalty} (opponents could score {opponent_min_pessimistic})")

            # Additional penalty for very high scores (>15) - almost never go out
            if estimated_score > 15:
                extra_penalty = (estimated_score - 15) * 2
                go_out_threshold -= extra_penalty
                ai_log(f"  Very high score penalty: -{extra_penalty}")

        ai_log(f"  Go-out decision: score={estimated_score}, threshold={go_out_threshold}, "
               f"aggression={profile.aggression:.2f}")

        if estimated_score <= go_out_threshold:
            if random.random() < profile.aggression:
                ai_log(f"  >> GOING OUT with score {estimated_score}")
                return True

        return False


async def process_cpu_turn(
    game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
) -> None:
    """Process a complete turn for a CPU player."""
    import asyncio
    from game_log import get_logger

    profile = get_profile(cpu_player.id)
    if not profile:
        # Fallback to balanced profile
        profile = CPUProfile("CPU", "Balanced", 5, 0.4, 0.5, 0.1)

    # Get logger if game_id provided
    logger = get_logger() if game_id else None

    # Brief initial delay before CPU "looks at" the discard pile
    initial_look = CPU_TIMING["initial_look"]
    await asyncio.sleep(random.uniform(initial_look[0], initial_look[1]))

    # "Thinking" delay based on how obvious the discard decision is
    # Easy decisions (good/bad cards) are quick, medium cards take longer
    discard_top = game.discard_top()
    thinking_time = get_discard_thinking_time(discard_top, game.options)

    # Adjust for personality - chaotic players have more variance
    if profile.unpredictability > 0.2:
        chaos_mult = CPU_TIMING["thinking_multiplier_chaotic"]
        thinking_time *= random.uniform(chaos_mult[0], chaos_mult[1])

    discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
    ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
    await asyncio.sleep(thinking_time)
    ai_log(f"{cpu_player.name} done thinking, making decision")

    # Check if we should try to go out early
    GolfAI.should_go_out_early(cpu_player, game, profile)

    # Check if we should knock early (flip all remaining cards at once)
    if GolfAI.should_knock_early(game, cpu_player, profile):
        if game.knock_early(cpu_player.id):
            # Log knock early
            if logger and game_id:
                face_down_count = sum(1 for c in cpu_player.cards if not c.face_up)
                logger.log_move(
                    game_id=game_id,
                    player=cpu_player,
                    is_cpu=True,
                    action="knock_early",
                    card=None,
                    game=game,
                    decision_reason=f"knocked early, revealing {face_down_count} hidden cards",
                )
            await broadcast_callback()
            return  # Turn is over

    # Check if we should use flip-as-action instead of drawing
    flip_action_pos = GolfAI.should_use_flip_action(game, cpu_player, profile)
    if flip_action_pos is not None:
        if game.flip_card_as_action(cpu_player.id, flip_action_pos):
            # Log flip-as-action
            if logger and game_id:
                flipped_card = cpu_player.cards[flip_action_pos]
                logger.log_move(
                    game_id=game_id,
                    player=cpu_player,
                    is_cpu=True,
                    action="flip_as_action",
                    card=flipped_card,
                    position=flip_action_pos,
                    game=game,
                    decision_reason=f"used flip-as-action to reveal position {flip_action_pos}",
                )
            await broadcast_callback()
            return  # Turn is over

    # Decide whether to draw from discard or deck (discard_top already fetched above)
    take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game)

    source = "discard" if take_discard else "deck"
    drawn = game.draw_card(cpu_player.id, source)

    # Log draw decision
    if logger and game_id and drawn:
        reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
        logger.log_move(
            game_id=game_id,
            player=cpu_player,
            is_cpu=True,
            action="take_discard" if take_discard else "draw_deck",
            card=drawn,
            game=game,
            decision_reason=reason,
        )

    if not drawn:
        return

    await broadcast_callback()
    # Brief pause after draw to let the flash animation register visually
    await asyncio.sleep(CPU_TIMING["post_draw_settle"])
    # Consideration time before swap/discard decision
    consider = CPU_TIMING["post_draw_consider"]
    await asyncio.sleep(consider[0] + random.uniform(0, consider[1] - consider[0]))

    # Decide whether to swap or discard
    swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)

    # If drawn from discard, must swap (always enforced)
    if swap_pos is None and game.drawn_from_discard:
        face_down = [i for i, c in enumerate(cpu_player.cards) if not c.face_up]
        if face_down:
            # Filter out positions that would create bad pairs with negative cards
            safe_positions = filter_bad_pair_positions(face_down, drawn, cpu_player, game.options)
            swap_pos = random.choice(safe_positions)
        else:
            # All cards are face up - find worst card to replace
            # IMPORTANT: Consider effective value (cards in pairs contribute 0, not face value)
            worst_pos = 0
            worst_effective_val = -999
            for i, c in enumerate(cpu_player.cards):
                card_val = get_ai_card_value(c, game.options)
                partner_pos = get_column_partner_position(i)
                partner = cpu_player.cards[partner_pos]

                # Check if this card is part of an existing pair
                if partner.rank == c.rank:
                    # Card is paired - its effective value depends on house rules
                    if card_val >= 0 or not game.options.negative_pairs_keep_value:
                        # Standard pair: both contribute 0, so effective value is 0
                        # BUT breaking it orphans partner, so true cost is partner's value
                        effective_val = -get_ai_card_value(partner, game.options)
                    elif game.options.eagle_eye and c.rank == Rank.JOKER:
                        # Eagle eye joker pair contributes -4 total, each contributes -2 effective
                        effective_val = -2
                    else:
                        # Negative pairs keep value: each card contributes its value
                        effective_val = card_val
                else:
                    effective_val = card_val

                if effective_val > worst_effective_val:
                    worst_effective_val = effective_val
                    worst_pos = i
            swap_pos = worst_pos

            # Sanity check: warn if we're swapping out a good card for a bad one
            drawn_val = get_ai_card_value(drawn, game.options)
            if worst_val < drawn_val:
                logging.warning(
                    f"AI {cpu_player.name} forced to swap good card (value={worst_val}) "
                    f"for bad card {drawn.rank.value} (value={drawn_val})"
                )

    if swap_pos is not None:
        old_card = cpu_player.cards[swap_pos]  # Card being replaced
        game.swap_card(cpu_player.id, swap_pos)

        # Log swap decision
        if logger and game_id:
            logger.log_move(
                game_id=game_id,
                player=cpu_player,
                is_cpu=True,
                action="swap",
                card=drawn,
                position=swap_pos,
                game=game,
                decision_reason=f"swapped {drawn.rank.value} into position {swap_pos}, replaced {old_card.rank.value}",
            )
    else:
        game.discard_drawn(cpu_player.id)

        # Log discard decision
        if logger and game_id:
            logger.log_move(
                game_id=game_id,
                player=cpu_player,
                is_cpu=True,
                action="discard",
                card=drawn,
                game=game,
                decision_reason=f"discarded {drawn.rank.value}",
            )

        if game.flip_on_discard:
            # Check if flip is optional (endgame mode) and decide whether to skip
            if game.flip_is_optional:
                if GolfAI.should_skip_optional_flip(cpu_player, profile, game):
                    game.skip_flip_and_end_turn(cpu_player.id)

                    # Log skip decision
                    if logger and game_id:
                        logger.log_move(
                            game_id=game_id,
                            player=cpu_player,
                            is_cpu=True,
                            action="skip_flip",
                            card=None,
                            game=game,
                            decision_reason="skipped optional flip (endgame mode)",
                        )
                else:
                    # Choose to flip
                    flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
                    game.flip_and_end_turn(cpu_player.id, flip_pos)

                    # Log flip decision
                    if logger and game_id:
                        flipped_card = cpu_player.cards[flip_pos]
                        logger.log_move(
                            game_id=game_id,
                            player=cpu_player,
                            is_cpu=True,
                            action="flip",
                            card=flipped_card,
                            position=flip_pos,
                            game=game,
                            decision_reason=f"flipped card at position {flip_pos} (chose to flip in endgame mode)",
                        )
            else:
                # Mandatory flip (always mode)
                flip_pos = GolfAI.choose_flip_after_discard(cpu_player, profile)
                game.flip_and_end_turn(cpu_player.id, flip_pos)

                # Log flip decision
                if logger and game_id:
                    flipped_card = cpu_player.cards[flip_pos]
                    logger.log_move(
                        game_id=game_id,
                        player=cpu_player,
                        is_cpu=True,
                        action="flip",
                        card=flipped_card,
                        position=flip_pos,
                        game=game,
                        decision_reason=f"flipped card at position {flip_pos}",
                    )

    await broadcast_callback()

    # Pause to let client animation complete and show result before next turn
    post_action = CPU_TIMING["post_action_pause"]
    await asyncio.sleep(random.uniform(post_action[0], post_action[1]))
