import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS, DealerRelativeRole, AIMove } from './types';
import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole, RandomSource } from './engine';
import { CardTracker } from './card-tracker';

export type { AIMove };

export interface AIDecisionProgress {
  difficulty: Difficulty;
  progress: number;
  elapsedMs: number;
  budgetMs: number;
  batchesCompleted: number;
  cardsRemaining?: number;
  sampleCount?: number;
  maxDepth?: number;
  completedDepth?: number;
  rootMoveCount?: number;
  timedOut?: boolean;
  aspirationExpansions?: number;
}

interface MasterProgressDetails {
  cardsRemaining: number;
  sampleCount: number;
  maxDepth: number;
  completedDepth: number;
  rootMoveCount: number;
  timedOut: boolean;
  aspirationExpansions: number;
}

interface SearchProfile {
  timeBudgetMs: number;
  sampleCount: number;
  maxDepth: number;
  batchSize: number;
}

export interface AISearchProfileOverride {
  timeBudgetMs?: number;
  sampleCount?: number;
  maxDepth?: number;
  batchSize?: number;
}

export interface AITimingSource {
  now(): number;
  advance?(elapsedMs: number): number;
  isSimulated?: boolean;
}

export interface AIChooseMoveOptions {
  rng?: RandomSource;
  profileOverride?: AISearchProfileOverride;
  timingSource?: AITimingSource;
}

interface DealerRoleContext {
  role: DealerRelativeRole;
  onDealerSide: boolean;
  defendingDealerAdvantage: boolean;
  attackingDealerAdvantage: boolean;
  aggressionBias: number;
  controlBias: number;
  pairPreservingBias: number;
  pairBreakingBias: number;
  tablePressureBias: number;
}

interface RankResidueSnapshot {
  unseenSameRankCounts: number[];
  hasSingletonResidue: boolean[];
  hasPairedResidue: boolean[];
}

interface SearchTimingContext {
  now(): number;
  checkpoint(costMs?: number): number;
  yieldToHost(): Promise<void>;
}

const DEALER_ROLE_WEIGHTS: Record<DealerRelativeRole, Omit<DealerRoleContext, 'role' | 'onDealerSide' | 'defendingDealerAdvantage' | 'attackingDealerAdvantage'>> = {
  'first-hand': {
    aggressionBias: 1.28,
    controlBias: 0.9,
    pairPreservingBias: 0.88,
    pairBreakingBias: 1.26,
    tablePressureBias: 1.3,
  },
  'second-hand': {
    aggressionBias: 1,
    controlBias: 1.08,
    pairPreservingBias: 1.12,
    pairBreakingBias: 0.96,
    tablePressureBias: 1,
  },
  'third-hand': {
    aggressionBias: 1.16,
    controlBias: 0.94,
    pairPreservingBias: 0.94,
    pairBreakingBias: 1.16,
    tablePressureBias: 1.12,
  },
  dealer: {
    aggressionBias: 0.84,
    controlBias: 1.32,
    pairPreservingBias: 1.34,
    pairBreakingBias: 0.82,
    tablePressureBias: 0.78,
  },
};

const SEARCH_PROFILES: Record<Difficulty, SearchProfile> = {
  beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 },
  advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 },
  master: { timeBudgetMs: 4300, sampleCount: 8, maxDepth: 5, batchSize: 2 },
};

const REAL_TIME_SOURCE: AITimingSource = {
  now: () => Date.now(),
};

const UPCOMING_TABLE_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const;

const REPRESENTATIVE_CARD_BY_VALUE = (() => {
  const cardsByValue = new Map<number, Card>();
  for (const card of buildDeck()) {
    if (!cardsByValue.has(card.value)) {
      cardsByValue.set(card.value, card);
    }
  }
  return cardsByValue;
})();

const SIMULATED_SEARCH_NODE_COST_MS = 48;
const SIMULATED_ROOT_MOVE_COST_MS = 12;
const SIMULATED_YIELD_COST_MS = 1;

function createSearchTimingContext(timingSource?: AITimingSource): SearchTimingContext {
  const source = timingSource ?? REAL_TIME_SOURCE;

  return {
    now: () => source.now(),
    checkpoint: (costMs = 0) => {
      if (source.advance && costMs > 0) {
        return source.advance(costMs);
      }
      return source.now();
    },
    yieldToHost: () => {
      if (source.advance) {
        source.advance(SIMULATED_YIELD_COST_MS);
        return Promise.resolve();
      }
      return new Promise(resolve => setTimeout(resolve, 0));
    },
  };
}

// ---------------------------------------------------------------------------
// Helpers shared across all difficulty levels
// ---------------------------------------------------------------------------

function nextPlayer(p: PlayerIndex): PlayerIndex {
  return ((p + 1) % 4) as PlayerIndex;
}
function partnerOf(p: PlayerIndex): PlayerIndex {
  return ((p + 2) % 4) as PlayerIndex;
}
function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean {
  return teamOf(me) !== teamOf(other);
}
function primieraVal(card: Card): number {
  return PRIMIERA_VALUES[card.value] ?? 0;
}
function gamePhase(state: GameState): number {
  const totalCards = state.players.reduce((s, p) => s + p.hand.length, 0);
  return 1 - totalCards / 40;
}

function getTeamPile(state: GameState, playerIdx: PlayerIndex): Card[] {
  return [...state.players[playerIdx].pile, ...state.players[partnerOf(playerIdx)].pile];
}

/** Is this the very last play of the round? (all hands have 0 or 1 cards, and it's this player's turn) */
function isLastPlay(state: GameState, playerIdx: PlayerIndex): boolean {
  for (let i = 0; i < 4; i++) {
    if (i === playerIdx) {
      if (state.players[i].hand.length !== 1) return false;
    } else {
      if (state.players[i].hand.length !== 0) return false;
    }
  }
  return true;
}

/** Count how many cards in hand match a given value (anchor candidates) */
function countValueInHand(hand: Card[], value: number): number {
  let n = 0;
  for (const c of hand) if (c.value === value) n++;
  return n;
}

function getDealerRoleContext(state: GameState, playerIdx: PlayerIndex): DealerRoleContext {
  const role = getDealerRelativeRole(state.dealer, playerIdx);
  const onDealerSide = role === 'dealer' || role === 'second-hand';
  return {
    role,
    onDealerSide,
    defendingDealerAdvantage: onDealerSide,
    attackingDealerAdvantage: !onDealerSide,
    ...DEALER_ROLE_WEIGHTS[role],
  };
}

function getRankResidueSnapshot(
  tracker: CardTracker | undefined,
  myHand: Card[],
  table: Card[],
): RankResidueSnapshot | null {
  if (!tracker) return null;

  const unseenSameRankCounts = Array.from({ length: 11 }, () => 0);
  const hasSingletonResidue = Array.from({ length: 11 }, () => false);
  const hasPairedResidue = Array.from({ length: 11 }, () => false);
  const summary = tracker.getValueRankResidueSummary(myHand, table);

  for (const residue of summary) {
    unseenSameRankCounts[residue.value] = residue.unseenCount;
    hasSingletonResidue[residue.value] = residue.hasSingletonUnseenRankResidue;
    hasPairedResidue[residue.value] = residue.hasPairedUnseenRankResidue;
  }

  return { unseenSameRankCounts, hasSingletonResidue, hasPairedResidue };
}

function countRankResidueValuesOnTable(
  afterTable: Card[],
  rankResidue: RankResidueSnapshot | null,
): { singletonValues: number; pairedValues: number } {
  if (!rankResidue || afterTable.length === 0) {
    return { singletonValues: 0, pairedValues: 0 };
  }

  let singletonValues = 0;
  let pairedValues = 0;
  const seenValues = new Set<number>();

  for (const card of afterTable) {
    if (seenValues.has(card.value)) continue;
    seenValues.add(card.value);
    if (rankResidue.hasSingletonResidue[card.value]) singletonValues++;
    else if (rankResidue.hasPairedResidue[card.value]) pairedValues++;
  }

  return { singletonValues, pairedValues };
}

function getExposedTableCardWeight(
  card: Card,
  race: RaceState,
  tableSize: number,
): number {
  let weight = 52 + primieraVal(card) * 2.5;

  if (card.suit === 'denara') {
    weight += race.behindInDenari ? 150 : 95;
  }

  if (card.value === 7) {
    weight += race.need7s ? 190 : 120;
  }

  if (card.suit === 'denara' && card.value === 7) {
    weight += 220;
  }

  if (tableSize === 1) weight += 150;
  else if (tableSize === 2) weight += 70;

  return weight;
}

function scoreExposedTableCards(
  afterTable: Card[],
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: CardTracker | undefined,
  myHand: Card[],
  race: RaceState,
): number {
  if (afterTable.length === 0) return 0;

  const next = nextPlayer(playerIdx);
  const partner = partnerOf(playerIdx);
  const nextHandSize = state.players[next].hand.length;
  const partnerHandSize = state.players[partner].hand.length;
  const nextIsOpp = isOpponent(playerIdx, next);
  const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0);
  const tableHasDenari = afterTable.some(card => card.suit === 'denara');
  const tableHasSeven = afterTable.some(card => card.value === 7);
  let score = 0;

  for (const tableCard of afterTable) {
    const weight = getExposedTableCardWeight(tableCard, race, afterTable.length);

    if (nextIsOpp && nextHandSize > 0) {
      const nextProb = handLikelyHasValue(
        tableCard.value,
        nextHandSize,
        state,
        playerIdx,
        tracker,
        myHand,
        afterTable,
      );
      score -= Math.round(nextProb * weight);
    }

    if (!nextIsOpp && partnerHandSize > 0) {
      const partnerProb = handLikelyHasValue(
        tableCard.value,
        partnerHandSize,
        state,
        playerIdx,
        tracker,
        myHand,
        afterTable,
      );
      score += Math.round(partnerProb * weight * 0.55);
    }
  }

  if (nextIsOpp && afterTable.length === 1) {
    score -= 380;
  } else if (nextIsOpp && afterTable.length === 2) {
    score -= tableSum <= 10 ? 180 : 110;
    if (tableHasDenari) score -= race.behindInDenari ? 130 : 60;
    if (tableHasSeven) score -= race.need7s ? 170 : 80;
  } else if (nextIsOpp && afterTable.length >= 5 && tableSum >= 24) {
    if (tableHasDenari) score += 70;
    if (tableHasSeven) score += 55;
  }

  return score;
}

function scoreRoleTablePlan(
  afterTable: Card[],
  roleContext: DealerRoleContext,
  nextIsOpp: boolean,
): number {
  if (afterTable.length === 0) return 0;

  const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0);
  let score = 0;

  if (roleContext.role === 'first-hand') {
    if (afterTable.length >= 2) score += 22 * roleContext.tablePressureBias;
    if (tableSum >= 8 && tableSum <= 15) score += 18 * roleContext.aggressionBias;
  }

  if (roleContext.role === 'third-hand') {
    if (afterTable.length >= 2) score += 14 * roleContext.tablePressureBias;
    if (tableSum >= 10) score += 10 * roleContext.aggressionBias;
  }

  if (roleContext.role === 'second-hand') {
    if (nextIsOpp && tableSum >= 11) score += 16 * roleContext.controlBias;
    if (!nextIsOpp && tableSum <= 10) score += 10 * roleContext.tablePressureBias;
  }

  if (roleContext.role === 'dealer') {
    if (tableSum >= 11) score += 28 * roleContext.controlBias;
    if (tableSum <= 10 && nextIsOpp) score -= 24 * roleContext.controlBias;
    if (afterTable.length === 1 && nextIsOpp) score -= 16 * roleContext.controlBias;
  }

  return Math.round(score);
}

function scoreRankResidueTableState(
  afterTable: Card[],
  rankResidue: RankResidueSnapshot | null,
  roleContext: DealerRoleContext,
  nextIsOpp: boolean,
): number {
  const { singletonValues, pairedValues } = countRankResidueValuesOnTable(afterTable, rankResidue);
  if (singletonValues === 0 && pairedValues === 0) return 0;

  let score = 0;
  if (roleContext.defendingDealerAdvantage) {
    score += pairedValues * 18 * roleContext.controlBias;
    score -= singletonValues * 22 * roleContext.controlBias;
    if (nextIsOpp) score += pairedValues * 8 - singletonValues * 10;
  } else {
    score += singletonValues * 20 * roleContext.tablePressureBias;
    score -= pairedValues * 10;
    if (nextIsOpp) score += singletonValues * 12;
  }

  return Math.round(score);
}

function scoreCaptureRankResiduePlan(
  played: Card,
  captured: Card[],
  afterTable: Card[],
  rankResidue: RankResidueSnapshot | null,
  roleContext: DealerRoleContext,
  nextIsOpp: boolean,
): number {
  if (!rankResidue || captured.length === 0) return 0;

  let score = 0;
  const directCapture = captured.length === 1 && captured[0].value === played.value;

  if (directCapture) {
    const unseenCount = rankResidue.unseenSameRankCounts[played.value] ?? 0;
    const base = rankResidue.hasPairedResidue[played.value] ? 58 : 30;
    score += base * roleContext.pairPreservingBias;
    if (roleContext.defendingDealerAdvantage && unseenCount > 0) score += 18 * roleContext.controlBias;
  } else {
    let pairBreaks = 0;
    let singletonTargets = 0;
    const seenValues = new Set<number>();

    for (const card of captured) {
      if (seenValues.has(card.value)) continue;
      seenValues.add(card.value);
      if ((rankResidue.unseenSameRankCounts[card.value] ?? 0) > 0) pairBreaks++;
      if (rankResidue.hasSingletonResidue[card.value]) singletonTargets++;
    }

    const disruption = pairBreaks * 20 + singletonTargets * 18 + Math.max(0, captured.length - 1) * 12;
    score += disruption * roleContext.pairBreakingBias;
    if (roleContext.defendingDealerAdvantage) score -= 18 * roleContext.controlBias;
  }

  score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp);
  return Math.round(score);
}

function scoreDumpRankResiduePlan(
  card: Card,
  afterTable: Card[],
  rankResidue: RankResidueSnapshot | null,
  roleContext: DealerRoleContext,
  nextIsOpp: boolean,
): number {
  if (!rankResidue) return 0;

  let score = scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp);
  if (rankResidue.hasSingletonResidue[card.value]) {
    score += roleContext.attackingDealerAdvantage ? 18 * roleContext.tablePressureBias : -20 * roleContext.controlBias;
  }
  if (rankResidue.hasPairedResidue[card.value]) {
    score += roleContext.defendingDealerAdvantage ? 14 * roleContext.pairPreservingBias : 6;
  }

  return Math.round(score);
}

function applySearchProfileOverride(
  profile: SearchProfile,
  profileOverride?: AISearchProfileOverride,
): SearchProfile {
  if (!profileOverride) return profile;

  return {
    timeBudgetMs: profileOverride.timeBudgetMs ?? profile.timeBudgetMs,
    sampleCount: profileOverride.sampleCount ?? profile.sampleCount,
    maxDepth: profileOverride.maxDepth ?? profile.maxDepth,
    batchSize: profileOverride.batchSize ?? profile.batchSize,
  };
}

function getSearchProfile(
  state: GameState,
  difficulty: Difficulty,
  profileOverride?: AISearchProfileOverride,
): SearchProfile {
  if (difficulty !== 'master') {
    return applySearchProfileOverride(SEARCH_PROFILES[difficulty], profileOverride);
  }

  const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0);
  if (cardsRemaining <= 4) {
    return applySearchProfileOverride(
      { timeBudgetMs: 3200, sampleCount: 4, maxDepth: cardsRemaining, batchSize: 1 },
      profileOverride,
    );
  }
  if (cardsRemaining <= 6) {
    return applySearchProfileOverride(
      { timeBudgetMs: 3600, sampleCount: 6, maxDepth: cardsRemaining, batchSize: 1 },
      profileOverride,
    );
  }
  if (cardsRemaining <= 8) {
    return applySearchProfileOverride(
      { timeBudgetMs: 3900, sampleCount: 8, maxDepth: cardsRemaining, batchSize: 1 },
      profileOverride,
    );
  }
  if (cardsRemaining <= 12) {
    return applySearchProfileOverride(
      { timeBudgetMs: 4200, sampleCount: 8, maxDepth: 8, batchSize: 1 },
      profileOverride,
    );
  }
  if (cardsRemaining <= 20) {
    return applySearchProfileOverride(
      { timeBudgetMs: 4350, sampleCount: 12, maxDepth: 5, batchSize: 2 },
      profileOverride,
    );
  }
  return applySearchProfileOverride(SEARCH_PROFILES.master, profileOverride);
}

function reportDecisionProgress(
  onProgress: ((progress: AIDecisionProgress) => void) | undefined,
  difficulty: Difficulty,
  startedAt: number,
  timing: SearchTimingContext,
  budgetMs: number,
  progress: number,
  batchesCompleted: number,
  masterDetails?: MasterProgressDetails,
): void {
  if (!onProgress) return;

  onProgress({
    difficulty,
    progress: Math.max(0, Math.min(1, progress)),
    elapsedMs: timing.now() - startedAt,
    budgetMs,
    batchesCompleted,
    ...(masterDetails ?? {}),
  });
}

function handLikelyHasValue(
  value: number,
  handSize: number,
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: CardTracker | undefined,
  myHand: Card[],
  table: Card[],
): number {
  if (handSize <= 0) return 0;

  if (tracker) {
    return tracker.probabilityHandHasValue(value, handSize, myHand, table);
  }

  const unseen = getUnseenCardsForEstimate(state, playerIdx, myHand, table, tracker);
  let unseenWithValue = 0;
  for (const card of unseen) {
    if (card.value === value) unseenWithValue++;
  }

  if (unseenWithValue === 0 || unseen.length === 0) return 0;
  const probNone = hypergeometricNone(unseen.length, unseenWithValue, handSize);
  return 1 - probNone;
}

/** Check if partner likely holds a card of given value (via tracker inference) */
function partnerLikelyHolds(
  value: number, playerIdx: PlayerIndex, state: GameState,
  tracker: CardTracker | undefined, myHand: Card[], table: Card[],
): number {
  const partner = partnerOf(playerIdx);
  return handLikelyHasValue(value, state.players[partner].hand.length, state, playerIdx, tracker, myHand, table);
}

/** Race state: who's winning each scoring category */
interface RaceState {
  myCards: number; oppCards: number;
  myDenari: number; oppDenari: number;
  mySettebello: boolean; oppSettebello: boolean;
  my7s: number; opp7s: number;
  myScope: number; oppScope: number;
  behindInCards: boolean;
  behindInDenari: boolean;
  denariRaceLive: boolean;
  needSettebello: boolean;
  need7s: boolean;
  sevenRaceLive: boolean;
  aheadOverall: boolean;
}

function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState {
  const myTeam = teamOf(playerIdx);
  const mine = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
  const opps = myTeam === 0 ? [state.players[1], state.players[3]] : [state.players[0], state.players[2]];
  const myPile = mine.flatMap(p => p.pile);
  const oppPile = opps.flatMap(p => p.pile);
  const myCards = myPile.length, oppCards = oppPile.length;
  const myDenari = myPile.filter(c => c.suit === 'denara').length;
  const oppDenari = oppPile.filter(c => c.suit === 'denara').length;
  const mySettebello = myPile.some(c => c.suit === 'denara' && c.value === 7);
  const oppSettebello = oppPile.some(c => c.suit === 'denara' && c.value === 7);
  const my7s = myPile.filter(c => c.value === 7).length;
  const opp7s = oppPile.filter(c => c.value === 7).length;
  const myScope = mine.reduce((s, p) => s + p.scope, 0);
  const oppScope = opps.reduce((s, p) => s + p.scope, 0);
  const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0);

  // Simple overall advantage estimate
  let myAdv = 0;
  if (myCards > oppCards) myAdv++; else if (oppCards > myCards) myAdv--;
  if (myDenari > oppDenari) myAdv++; else if (oppDenari > myDenari) myAdv--;
  if (mySettebello) myAdv++; else if (oppSettebello) myAdv--;
  myAdv += myScope - oppScope;

  return {
    myCards, oppCards, myDenari, oppDenari, mySettebello, oppSettebello,
    my7s, opp7s, myScope, oppScope,
    behindInCards: myCards < oppCards,
    behindInDenari: myDenari < oppDenari,
    denariRaceLive: cardsRemaining > 0 && myDenari < 6 && oppDenari < 6 && Math.abs(myDenari - oppDenari) <= 1,
    needSettebello: !mySettebello && !oppSettebello,
    need7s: my7s <= opp7s,
    sevenRaceLive: cardsRemaining > 0 && my7s < 3 && opp7s < 3 && Math.abs(my7s - opp7s) <= 1,
    aheadOverall: myAdv > 0,
  };
}

/**
 * Count scopa threats: how many unseen cards can clear a given table.
 * Uses probabilistic assessment per-player based on hand sizes.
 */
function countScopaThreats(
  afterTable: Card[],
  myHand: Card[],
  tracker: CardTracker | undefined,
  state: GameState,
  playerIdx: PlayerIndex,
): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean; partnerCanScopa: boolean } {
  if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false, partnerCanScopa: false };

  const unseen = tracker
    ? tracker.getUnseenCards(myHand, afterTable)
    : getUnseenWithoutTracker(state, playerIdx);

  // Count every unseen card that has at least one capture clearing the full table
  let totalThreats = 0;
  const threatCardIds = new Set<string>();
  for (const uc of unseen) {
    const caps = findCaptures(uc, afterTable);
    for (const cap of caps) {
      if (cap.length === afterTable.length) {
        totalThreats++;
        threatCardIds.add(uc.id);
        break;
      }
    }
  }

  // Probabilistic check for each player
  const next = nextPlayer(playerIdx);
  const second = nextPlayer(next);
  const third = nextPlayer(second); // = partner
  const unseenCount = unseen.length;

  let nextOppCanScopa = false;
  let secondOppCanScopa = false;
  let partnerCanScopa = false;

  if (totalThreats > 0 && unseenCount > 0) {
    for (const other of [next, second, third]) {
      const hs = state.players[other].hand.length;
      if (hs === 0) continue;
      const probNone = hypergeometricNone(unseenCount, totalThreats, hs);
      const prob = 1 - probNone;
      if (isOpponent(playerIdx, other)) {
        if (other === next) nextOppCanScopa = prob > 0.20;
        else secondOppCanScopa = prob > 0.20;
      } else if (other !== playerIdx) {
        partnerCanScopa = prob > 0.30;
      }
    }
  }

  return { totalThreats, nextOppCanScopa, secondOppCanScopa, partnerCanScopa };
}

interface ScopaThreatSummary {
  totalThreats: number;
  nextOppCanScopa: boolean;
  secondOppCanScopa: boolean;
  partnerCanScopa: boolean;
}

interface TacticalPriorityLadder {
  scopa: number;
  settebello: number;
  antiScopa: number;
  partnerSetup: number;
  sevenDenial: number;
  denariDenial: number;
  material: number;
}

const TACTICAL_PRIORITY_WEIGHTS = {
  scopa: 120000000,
  settebello: 20000000,
  antiScopa: 5000000,
  partnerSetup: 45000,
  sevenDenial: 2101,
  denariDenial: 101,
} as const;

function clampPriorityBand(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, Math.round(value)));
}

function sumCardValues(cards: Card[]): number {
  return cards.reduce((sum, card) => sum + card.value, 0);
}

function getPriorityThreatSummary(
  afterTable: Card[],
  myHand: Card[],
  tracker: CardTracker | undefined,
  state: GameState,
  playerIdx: PlayerIndex,
): ScopaThreatSummary | null {
  if (afterTable.length === 0 || sumCardValues(afterTable) > 10) {
    return null;
  }

  return countScopaThreats(afterTable, myHand, tracker, state, playerIdx);
}

function isImmediateTacticalConcession(
  afterTable: Card[],
  nextIsOpp: boolean,
  threats: ScopaThreatSummary | null,
): boolean {
  if (!nextIsOpp || afterTable.length === 0) return false;

  const tableSum = sumCardValues(afterTable);
  if (afterTable.length === 1 && tableSum <= 10) return true;
  if (afterTable.length === 2 && tableSum <= 10) return true;

  return Boolean(threats?.nextOppCanScopa);
}

function evaluateSafeScopaPriority(
  clearsTable: boolean,
  afterTable: Card[],
  lastPlay: boolean,
  nextIsOpp: boolean,
  threats: ScopaThreatSummary | null,
): number {
  if (!clearsTable || lastPlay) return 0;
  return isImmediateTacticalConcession(afterTable, nextIsOpp, threats) ? 1 : 2;
}

function evaluateFirstHandOpeningReleasePriority(
  card: Card,
  myHand: Card[],
  projectedHand: Card[],
  afterTable: Card[],
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: CardTracker | undefined,
  nextIsOpp: boolean,
  roleContext: DealerRoleContext,
): number {
  if (!nextIsOpp || roleContext.role !== 'first-hand' || afterTable.length !== 1) {
    return 0;
  }

  const nextHandSize = state.players[nextPlayer(playerIdx)].hand.length;
  if (nextHandSize <= 0) return 0;

  const sameValueCount = countValueInHand(myHand, card.value);
  const immediateScopaRisk = handLikelyHasValue(
    card.value,
    nextHandSize,
    state,
    playerIdx,
    tracker,
    projectedHand,
    afterTable,
  );

  let score = 0;
  score += Math.max(0, sameValueCount - 1) * 2;
  if (sameValueCount >= 3) score += 2;
  score += Math.round((0.32 - immediateScopaRisk) * 12);

  if (sameValueCount >= 2 && card.value >= 8) score += 2;
  if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 3;
  if (sameValueCount >= 2 && card.suit === 'denara') score -= 2;
  if (card.suit === 'denara') score -= 1;
  if (card.value === 7) score -= 1;
  if (sameValueCount === 1 && card.value <= 3) score -= 2;

  return clampPriorityBand(score, -8, 8);
}

function evaluateAntiScopaPriority(
  afterTable: Card[],
  nextIsOpp: boolean,
  threats: ScopaThreatSummary | null,
): number {
  if (afterTable.length === 0) return 8;

  const tableSum = sumCardValues(afterTable);
  const exposedDenari = afterTable.filter(card => card.suit === 'denara').length;
  const exposedSevens = afterTable.filter(card => card.value === 7).length;
  let score = tableSum >= 14 ? 7 : tableSum >= 11 ? 6 : 0;

  if (nextIsOpp) {
    if (tableSum <= 12) score -= 3;
    if (tableSum <= 10) score -= 6;
    if (tableSum <= 6) score -= 4;
    if (afterTable.length === 1) score -= 9;
    else if (afterTable.length === 2 && tableSum <= 12) score -= 6;
    else if (afterTable.length === 3 && tableSum <= 12) score -= 3;
    if (afterTable.length === 3 && tableSum <= 18) score -= 2;

    score -= exposedDenari * 2;
    score -= exposedSevens * 3;
    if (afterTable.length <= 2 && (exposedDenari > 0 || exposedSevens > 0)) {
      score -= 4 + exposedDenari + exposedSevens;
    }
    if (afterTable.length === 3 && tableSum <= 18 && (exposedDenari > 0 || exposedSevens > 0)) {
      score -= 4 + exposedDenari * 2 + exposedSevens * 2;
    }
    if (afterTable.length >= 4 && tableSum >= 20) score += 4;
    if (afterTable.length >= 5 && tableSum >= 24) score += 2;
  }

  if (threats) {
    if (threats.nextOppCanScopa) score -= 10;
    if (threats.secondOppCanScopa) score -= 5;
    score -= Math.min(8, threats.totalThreats);
    if (threats.partnerCanScopa) {
      score += nextIsOpp && !threats.nextOppCanScopa ? 4 : 2;
    }
  }

  if (!nextIsOpp && tableSum >= 11) score += 2;
  if (!nextIsOpp && afterTable.length >= 4 && tableSum >= 15) score += 2;

  return clampPriorityBand(score, -20, 20);
}

function evaluatePartnerSetupPriority(
  afterTable: Card[],
  nextIsOpp: boolean,
  partnerHandSize: number,
  threats: ScopaThreatSummary | null,
): number {
  if (afterTable.length === 0 || partnerHandSize === 0) return 0;

  const tableSum = sumCardValues(afterTable);
  const denariOnTable = afterTable.filter(card => card.suit === 'denara').length;
  const sevensOnTable = afterTable.filter(card => card.value === 7).length;
  let score = 0;

  if (!nextIsOpp) {
    score += 4;
    if (tableSum >= 1 && tableSum <= 10) score += threats?.partnerCanScopa ? 10 : 5;
    if (afterTable.length >= 2) score += 2;
    if (denariOnTable > 0) score += Math.min(3, denariOnTable);
    if (sevensOnTable > 0) score += 2;
  } else {
    if (tableSum >= 11) {
      score += 2;
      if (afterTable.length >= 4) score += 1;
      if (denariOnTable > 0) score += 2;
      if (sevensOnTable > 0) score += 1;
    }

    if (threats?.partnerCanScopa && !threats.nextOppCanScopa) {
      score += tableSum <= 12 ? 7 : 4;
      if (afterTable.length >= 4) score += 3;
      if (denariOnTable === 0) score += 1;
      if (sevensOnTable === 0) score += 1;
    }

    if (threats?.secondOppCanScopa) score -= 2;
  }

  return clampPriorityBand(score, -20, 20);
}

function evaluateSevenDenialPriority(
  afterTable: Card[],
  capturedCards: Card[],
  releasedCard: Card | null,
  nextIsOpp: boolean,
  need7s: boolean,
): number {
  let score = 0;
  const capturedSevens = capturedCards.filter(card => card.value === 7).length;
  const exposedSevens = afterTable.filter(card => card.value === 7).length;
  const strippedAllSevens = capturedSevens > 0 && exposedSevens === 0;

  score += capturedSevens * (need7s ? 8 : 5);
  if (capturedSevens > 0) score += need7s ? 4 : 2;
  if (strippedAllSevens) score += need7s ? 5 : 3;
  if (nextIsOpp) {
    score -= exposedSevens * (need7s ? 10 : 6);
    if (exposedSevens > 0 && afterTable.length <= 2) {
      score -= need7s ? 6 : 4;
    }
  } else {
    score += exposedSevens;
  }

  if (releasedCard?.value === 7) {
    score -= need7s ? 12 : 7;
    if (nextIsOpp && afterTable.length <= 2) score -= need7s ? 6 : 4;
  }

  return clampPriorityBand(score, -20, 20);
}

function evaluateDenariDenialPriority(
  afterTable: Card[],
  capturedCards: Card[],
  releasedCard: Card | null,
  nextIsOpp: boolean,
  behindInDenari: boolean,
): number {
  let score = 0;
  const capturedDenari = capturedCards.filter(card => card.suit === 'denara').length;
  const exposedDenari = afterTable.filter(card => card.suit === 'denara').length;
  const strippedAllDenari = capturedDenari > 0 && exposedDenari === 0;

  score += capturedDenari * (behindInDenari ? 7 : 4);
  if (capturedDenari > 0) score += behindInDenari ? 4 : 2;
  if (strippedAllDenari) score += behindInDenari ? 5 : 3;
  if (nextIsOpp) {
    score -= exposedDenari * (behindInDenari ? 10 : 6);
    if (exposedDenari > 0 && afterTable.length <= 2) {
      score -= behindInDenari ? 6 : 4;
    }
  } else {
    score += Math.min(2, exposedDenari);
  }

  if (releasedCard?.suit === 'denara') {
    score -= behindInDenari ? 11 : 6;
    if (nextIsOpp && afterTable.length <= 2) score -= behindInDenari ? 6 : 4;
  }

  return clampPriorityBand(score, -20, 20);
}

function scoreTacticalPriorityLadder(priorities: TacticalPriorityLadder): number {
  const scopa = clampPriorityBand(priorities.scopa, -2, 2);
  const settebello = clampPriorityBand(priorities.settebello, -4, 4);
  const antiScopa = clampPriorityBand(priorities.antiScopa, -20, 20);
  const partnerSetup = clampPriorityBand(priorities.partnerSetup, -20, 20);
  const sevenDenial = clampPriorityBand(priorities.sevenDenial, -20, 20);
  const denariDenial = clampPriorityBand(priorities.denariDenial, -20, 20);
  const material = clampPriorityBand(priorities.material, -200, 200);

  return (
    scopa * TACTICAL_PRIORITY_WEIGHTS.scopa
    + settebello * TACTICAL_PRIORITY_WEIGHTS.settebello
    + antiScopa * TACTICAL_PRIORITY_WEIGHTS.antiScopa
    + partnerSetup * TACTICAL_PRIORITY_WEIGHTS.partnerSetup
    + sevenDenial * TACTICAL_PRIORITY_WEIGHTS.sevenDenial
    + denariDenial * TACTICAL_PRIORITY_WEIGHTS.denariDenial
    + material
  );
}

/** P(0 threat cards drawn) using hypergeometric approx */
function hypergeometricNone(total: number, threats: number, drawn: number): number {
  if (drawn >= total) return threats > 0 ? 0 : 1;
  let p = 1;
  for (let i = 0; i < drawn; i++) {
    p *= Math.max(0, (total - threats - i)) / (total - i);
  }
  return p;
}

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

export async function chooseMove(
  state: GameState,
  playerIdx: PlayerIndex,
  difficulty: Difficulty = 'advanced',
  tracker?: CardTracker,
  onProgress?: (progress: AIDecisionProgress) => void,
  options?: AIChooseMoveOptions,
): Promise<AIMove> {
  const timing = createSearchTimingContext(options?.timingSource);
  const startedAt = timing.now();
  const profile = getSearchProfile(state, difficulty, options?.profileOverride);
  reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 0, 0);

  switch (difficulty) {
    case 'beginner': {
      const move = beginnerMove(state, playerIdx, tracker);
      reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1);
      return move;
    }
    case 'advanced': {
      const move = advancedMove(state, playerIdx, tracker);
      reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1);
      return move;
    }
    case 'master':
      return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt, timing, options?.rng ?? Math.random);
  }
}

// ===========================================================================
// BEGINNER — beatable but not stupid, basic strategy awareness
// ===========================================================================

function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
  const player = state.players[playerIdx];
  const table = state.table;
  const phase = gamePhase(state);
  const next = nextPlayer(playerIdx);
  const nextIsOpp = isOpponent(playerIdx, next);
  const lastPlay = isLastPlay(state, playerIdx);

  // 5% pure random (reduced from 8%)
  if (Math.random() < 0.05) {
    return randomMove(state, playerIdx);
  }

  let bestMove: AIMove | null = null;
  let bestScore = -Infinity;

  for (const card of player.hand) {
    const captures = findCaptures(card, table);
    if (captures.length > 0) {
      for (const captureSet of captures) {
        const base = scoreCaptureBeginner(card, captureSet, table, state, playerIdx, phase, nextIsOpp, lastPlay);
        const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.2);
        if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; }
      }
    } else {
      const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp, player.hand);
      const score = base + (Math.random() - 0.5) * Math.max(50, Math.abs(base) * 0.2);
      if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; }
    }
  }

  return bestMove!;
}

function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove {
  const hand = state.players[playerIdx].hand;
  const card = hand[Math.floor(Math.random() * hand.length)];
  const captures = findCaptures(card, state.table);
  if (captures.length > 0) {
    return { card, capture: captures[Math.floor(Math.random() * captures.length)] };
  }
  return { card, capture: [] };
}

function scoreCaptureBeginner(
  played: Card, captured: Card[], table: Card[],
  state: GameState, playerIdx: PlayerIndex, phase: number,
  nextIsOpp: boolean, lastPlay: boolean,
): number {
  const allCaptured = [played, ...captured];
  const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
  const isScopa = afterTable.length === 0;
  const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7);
  const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7);
  const threats = getPriorityThreatSummary(afterTable, state.players[playerIdx].hand, undefined, state, playerIdx);
  const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length;
  let material = 20 + captured.length * 14 + phase * captured.length * 4;

  material += allCaptured.filter(c => c.suit === 'denara').length * 8;
  material += allCaptured.filter(c => c.value === 7).length * 6;
  for (const card of allCaptured) material += primieraVal(card) * 1.2;

  if (!isScopa) {
    for (const tableCard of afterTable) {
      const dupes = countValueInHand(state.players[playerIdx].hand, tableCard.value);
      if (dupes >= 1) material += 6;
      if (dupes >= 2) material += 4;
    }
  }

  return scoreTacticalPriorityLadder({
    scopa: isScopa && !lastPlay ? 2 : isScopa ? 0 : 0,
    settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0,
    antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats),
    partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats),
    sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, false),
    denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, false),
    material,
  }) + (isScopa && lastPlay ? 30 : 0);
}

function scoreDumpBeginner(
  card: Card, table: Card[], state: GameState,
  playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean,
  hand: Card[],
): number {
  const afterTable = [...table, card];

  // NEVER dump settebello
  if (card.suit === 'denara' && card.value === 7) return -5000;
  const threats = getPriorityThreatSummary(afterTable, hand, undefined, state, playerIdx);
  const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length;
  let material = -12 + phase * 4;

  if (card.suit === 'denara') material -= 20;
  if (card.value === 7) material -= 22;
  if (card.value === 6) material -= 10;
  if (card.value === 1) material -= 8;
  if (card.value >= 8) material += 12 + card.value;

  const dupes = countValueInHand(hand, card.value);
  if (dupes >= 2) material += 18;
  if (dupes >= 3) material += 8;

  return scoreTacticalPriorityLadder({
    scopa: 0,
    settebello: 0,
    antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats),
    partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats),
    sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, false),
    denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, false),
    material,
  });
}

// ===========================================================================
// ADVANCED — strong heuristic with card counting, race tracking, cooperation
// anchor strategy, whirlwind detection, team signaling
// ===========================================================================

function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
  const player = state.players[playerIdx];
  const table = state.table;
  const phase = gamePhase(state);
  const race = getRaceState(state, playerIdx);
  const roleContext = getDealerRoleContext(state, playerIdx);
  const rankResidue = getRankResidueSnapshot(tracker, player.hand, table);
  const next = nextPlayer(playerIdx);
  const nextIsOpp = isOpponent(playerIdx, next);
  const partner = partnerOf(playerIdx);
  const partnerHandSize = state.players[partner].hand.length;
  const lastPlay = isLastPlay(state, playerIdx);

  let bestMove: AIMove | null = null;
  let bestScore = -Infinity;

  for (const card of player.hand) {
    const captures = findCaptures(card, table);
    if (captures.length > 0) {
      for (const captureSet of captures) {
        const score = scoreCaptureAdv(
          card, captureSet, table, state, playerIdx, race,
          tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue,
        );
        if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; }
      }
    } else {
      const score = scoreDumpAdv(
        card, table, state, playerIdx, race,
        tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue,
      );
      if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; }
    }
  }

  return bestMove!;
}

function scoreCaptureAdv(
  played: Card, captured: Card[], table: Card[], state: GameState,
  playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined,
  myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number,
  lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null,
): number {
  const allCaptured = [played, ...captured];
  const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
  const projectedHand = myHand.filter(card => card.id !== played.id);
  const isScopa = afterTable.length === 0;
  const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7);
  const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7);
  const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx);
  const scopaPriority = evaluateSafeScopaPriority(isScopa, afterTable, lastPlay, nextIsOpp, threats);
  const afterTableSum = sumCardValues(afterTable);
  const exposedDenariCount = afterTable.filter(card => card.suit === 'denara').length;
  const exposedSevenCount = afterTable.filter(card => card.value === 7).length;
  const capturedDenariCount = allCaptured.filter(card => card.suit === 'denara').length;
  const capturedSevenCount = allCaptured.filter(card => card.value === 7).length;
  const liveDenariPressure = race.behindInDenari || race.denariRaceLive;
  const liveSevenPressure = race.need7s || race.sevenRaceLive;
  const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext);
  const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext);
  const directSevenPrimieraSwing = scoreDirectSevenPrimieraSwing(
    played,
    captured,
    afterTable,
    myHand,
    table,
    liveSevenPressure,
  );
  const liveCardsMajorityRace = race.myCards < 21
    && race.oppCards < 21
    && Math.abs(race.myCards - race.oppCards) <= 5;
  const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards;
  const cardsMajorityDelta = scoreCardsMajorityPosition(race.myCards + allCaptured.length, race.oppCards, phase)
    - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase);
  let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6;

  material += capturedDenariCount * (race.behindInDenari ? 20 : race.denariRaceLive ? (protectingCardsLead ? 12 : 16) : protectingCardsLead ? 7 : 10);
  material += capturedSevenCount * (race.need7s ? 14 : race.sevenRaceLive ? 11 : 7);
  for (const card of allCaptured) material += primieraVal(card) * 2;
  material += Math.round((afterPairInventory - beforePairInventory) * 1.8);
  material += directSevenPrimieraSwing;
  material += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 1.2 : 0.6));
  if (protectingCardsLead && captured.length > 1) material += 96 + captured.length * 24;

  if (capturesSettebello) material += 72;
  if (tableHasSettebello && nextIsOpp && !capturesSettebello) material -= 84;
  if (
    protectingCardsLead
    && !race.behindInDenari
    && captured.length === 1
    && captured[0].suit === 'denara'
    && !capturesSettebello
  ) {
    material -= 84;
  }
  if (capturedDenariCount > 0 && nextIsOpp && exposedDenariCount === 0) material += liveDenariPressure ? 30 : 14;
  if (capturedSevenCount > 0 && nextIsOpp && exposedSevenCount === 0) material += liveSevenPressure ? 34 : 16;
  if (
    nextIsOpp
    && !isScopa
    && afterTable.length <= 2
    && afterTableSum <= 12
  ) {
    material -= 34;
    material -= exposedDenariCount * (liveDenariPressure ? 18 : 10);
    material -= exposedSevenCount * (liveSevenPressure ? 20 : 12);
  }

  const teamPile = getTeamPile(state, playerIdx);
  for (const card of allCaptured) {
    if (card.value === 7 && !teamPile.some(teamCard => teamCard.suit === card.suit && teamCard.value === 7)) {
      material += 10;
    }
  }

  material += Math.round(scoreCaptureRankResiduePlan(played, captured, afterTable, rankResidue, roleContext, nextIsOpp) / 6);
  material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8);

  if (!isScopa) {
    for (const tableCard of afterTable) {
      const dupes = countValueInHand(myHand, tableCard.value);
      if (dupes >= 1) material += 7;
      if (dupes >= 2) material += 5;

      const partnerProb = partnerLikelyHolds(tableCard.value, playerIdx, state, tracker, myHand, afterTable);
      if (partnerProb > 0.4) material += 6;
    }
  }

  if (nextIsOpp && tracker?.isSettebelloUnseen() && !capturesSettebello && afterTable.some(c => c.suit === 'denara' && c.value === 7)) {
    material -= 18;
  }

  if (tracker && !isScopa && phase > 0.5 && sumCardValues(afterTable) <= 10) {
    const confidence = Math.min(1, tracker.playedCount / 25);
    material -= Math.round(confidence * 20);
  }

  if (partnerHandSize === 0) material += captured.length * 8;
  if (race.aheadOverall && !isScopa && sumCardValues(afterTable) >= 11) material += 10;
  if (race.aheadOverall && !isScopa && sumCardValues(afterTable) <= 5 && nextIsOpp) material -= 12;
  if (roleContext.role === 'first-hand' && !isScopa && afterTable.length >= 2) material += 8;
  if (roleContext.role === 'dealer' && !isScopa && sumCardValues(afterTable) >= 11) material += 10;
  if (countValueInHand(myHand, played.value) >= 2) {
    material -= Math.round((played.value >= 8 ? 28 : 14) * roleContext.pairPreservingBias);
    if (roleContext.defendingDealerAdvantage && !isScopa) {
      material -= Math.round((played.value >= 8 ? 18 : 8) * roleContext.controlBias);
    }
  }

  return scoreTacticalPriorityLadder({
    scopa: scopaPriority,
    settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0,
    antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats),
    partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats),
    sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.need7s),
    denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.behindInDenari),
    material,
  }) + (isScopa && lastPlay ? 40 : 0);
}

function scoreDumpAdv(
  card: Card, table: Card[], state: GameState,
  playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined,
  myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number,
  lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null,
): number {
  const afterTable = [...table, card];
  const projectedHand = myHand.filter(held => held.id !== card.id);

  // --- HARD RULES ---
  if (card.suit === 'denara' && card.value === 7) return -10000;
  const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx);
  const tableSum = sumCardValues(afterTable);
  const exposedDenariCount = afterTable.filter(tableCard => tableCard.suit === 'denara').length;
  const exposedSevenCount = afterTable.filter(tableCard => tableCard.value === 7).length;
  const liveDenariPressure = race.behindInDenari || race.denariRaceLive;
  const liveSevenPressure = race.need7s || race.sevenRaceLive;
  const complement = 10 - card.value;
  const preservesHighComplementWindow = nextIsOpp
    && card.value >= 1
    && card.value <= 3
    && afterTable.length >= 4
    && afterTable.some(tableCard => tableCard.value === complement);
  const openingReleasePriority = evaluateFirstHandOpeningReleasePriority(
    card,
    myHand,
    projectedHand,
    afterTable,
    state,
    playerIdx,
    tracker,
    nextIsOpp,
    roleContext,
  );
  const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext);
  const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext);
  const openingDuplicateReleaseBias = scoreOpeningDuplicateReleaseBias(
    card,
    myHand,
    state,
    playerIdx,
    nextIsOpp,
    roleContext,
  );
  let material = -20 + phase * 6;

  if (card.suit === 'denara') material -= race.behindInDenari ? 28 : race.denariRaceLive ? 24 : 16;
  if (card.value === 7) material -= race.need7s ? 26 : race.sevenRaceLive ? 22 : 14;
  if (card.value === 6) material -= 12;
  if (card.value === 1) material -= 10;
  if (card.value >= 8) material += 14 + card.value * 2;

  const dupes = countValueInHand(myHand, card.value);
  if (dupes >= 2) material += 24;
  if (dupes >= 3) material += 10;
  material += Math.round((afterPairInventory - beforePairInventory) * 1.9);
  material += Math.round(openingDuplicateReleaseBias * 0.35);

  const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table);
  if (partnerProb > 0.4) material += 14;

  material += Math.round(scoreDumpRankResiduePlan(card, afterTable, rankResidue, roleContext, nextIsOpp) / 6);
  material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8);

  if (afterTable.length >= 4 && tableSum >= 15) material += 10;
  if (nextIsOpp && afterTable.length >= 4 && tableSum >= 24) material += 22;
  if (!nextIsOpp && card.value >= 8) material += 8;
  if (nextIsOpp && afterTable.length <= 2 && tableSum <= 12) {
    material -= 28;
    material -= exposedDenariCount * (liveDenariPressure ? 14 : 8);
    material -= exposedSevenCount * (liveSevenPressure ? 16 : 10);
  }
  if (threats?.partnerCanScopa && !threats.nextOppCanScopa) {
    material += afterTable.length >= 4 ? 34 : 22;
    if (tableSum >= 10 && tableSum <= 12) material += 26;
  }
  if (preservesHighComplementWindow) {
    material += tableSum >= 24 ? 56 : 32;
    if (card.suit !== 'denara') material += 12;
  }
  if (
    roleContext.defendingDealerAdvantage
    && beforePairInventory > 0
    && afterPairInventory === beforePairInventory
    && card.suit !== 'denara'
    && card.value <= 4
  ) {
    material += 42;
  }

  if (tracker) {
    const unseen = tracker.getUnseenCards(myHand, afterTable);
    let directThreats = 0;
    for (const unseenCard of unseen) {
      const caps = findCaptures(unseenCard, afterTable);
      for (const cap of caps) {
        if (cap.length === afterTable.length) {
          directThreats++;
          break;
        }
      }
    }
    material -= directThreats * 8;

    for (const suit of SUITS) {
      if (!tracker.hasBeenPlayed(`${suit}_7`) && nextIsOpp && afterTable.some(c => c.suit === suit && c.value === 7)) {
        material -= 10;
      }
    }

    if (phase > 0.5) {
      const confidence = Math.min(1, tracker.playedCount / 25);
      material += Math.round(material * confidence * 0.15);
    }
  }

  if (table.length === 0 && nextIsOpp) material -= 18;
  if (race.aheadOverall && sumCardValues(afterTable) >= 11) material += 8;
  if (race.aheadOverall && card.value >= 8) material += 6;
  if (roleContext.role === 'first-hand' && afterTable.length >= 2 && sumCardValues(afterTable) >= 8) material += 6;
  if (roleContext.role === 'dealer' && nextIsOpp && sumCardValues(afterTable) <= 10) material -= 8;

  return scoreTacticalPriorityLadder({
    scopa: 0,
    settebello: 0,
    antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats) + openingReleasePriority,
    partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats),
    sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, race.need7s),
    denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, race.behindInDenari),
    material,
  }) + (lastPlay ? 0 : 0);
}

// ===========================================================================
// MASTER — deep minimax, alpha-beta, determinization, endgame solver
// improved evaluation, team-aware search, last-play awareness
// ===========================================================================

function tableControlPressure(
  afterTable: Card[],
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: CardTracker | undefined,
  myHand: Card[],
  race: RaceState,
  roleContext: DealerRoleContext,
  rankResidue: RankResidueSnapshot | null,
): number {
  if (afterTable.length === 0) return 0;

  let score = 0;
  const next = nextPlayer(playerIdx);
  const partner = partnerOf(playerIdx);
  const nextHandSize = state.players[next].hand.length;
  const partnerHandSize = state.players[partner].hand.length;
  const nextIsOpp = isOpponent(playerIdx, next);
  const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0);

  if (tableSum >= 11) score += 70;
  if (tableSum <= 10 && nextIsOpp) score -= 110;
  if (afterTable.some(card => card.suit === 'denara')) {
    score += nextIsOpp
      ? (race.behindInDenari ? -110 : -45)
      : (race.behindInDenari ? 35 : 15);
  }
  if (afterTable.some(card => card.value === 7)) {
    score += nextIsOpp
      ? (race.need7s ? -150 : -55)
      : (race.need7s ? 45 : 15);
  }

  for (const tableCard of afterTable) {
    const myAnchors = countValueInHand(myHand, tableCard.value);
    if (myAnchors > 0) score += myAnchors * 18;

    const partnerProb = handLikelyHasValue(
      tableCard.value,
      partnerHandSize,
      state,
      playerIdx,
      tracker,
      myHand,
      afterTable,
    );
    score += partnerProb * (nextIsOpp ? 20 : 55);
    if (nextHandSize > 0 && nextIsOpp) {
      const nextProb = handLikelyHasValue(
        tableCard.value,
        nextHandSize,
        state,
        playerIdx,
        tracker,
        myHand,
        afterTable,
      );
      score -= nextProb * 80;
    }
  }

  if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60;
  score += scoreExposedTableCards(afterTable, state, playerIdx, tracker, myHand, race);
  score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp);
  score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp);
  return score;
}

interface MoveTacticalSummary {
  projectedTable: Card[];
  tableSum: number;
  clearsTable: boolean;
  capturedDenariCount: number;
  capturedSevenCount: number;
  capturesSettebello: boolean;
  exposedDenariCount: number;
  exposedSevenCount: number;
  highQuietRelease: boolean;
  sameValueAnchorsRemaining: number;
}

function summarizeMoveTactics(
  move: AIMove,
  hand: Card[],
  table: Card[],
): MoveTacticalSummary {
  const projectedTable = move.capture.length > 0
    ? table.filter(card => !move.capture.some(captured => captured.id === card.id))
    : [...table, move.card];
  const tableSum = projectedTable.reduce((sum, card) => sum + card.value, 0);
  const capturedCards = getMoveCollectedCards(move);
  const exposedDenariCount = projectedTable.filter(card => card.suit === 'denara').length;
  const exposedSevenCount = projectedTable.filter(card => card.value === 7).length;

  return {
    projectedTable,
    tableSum,
    clearsTable: move.capture.length > 0 && projectedTable.length === 0,
    capturedDenariCount: capturedCards.filter(card => card.suit === 'denara').length,
    capturedSevenCount: capturedCards.filter(card => card.value === 7).length,
    capturesSettebello: capturedCards.some(card => card.suit === 'denara' && card.value === 7),
    exposedDenariCount,
    exposedSevenCount,
    highQuietRelease: move.capture.length === 0 && move.card.value >= 8 && move.card.suit !== 'denara',
    sameValueAnchorsRemaining: Math.max(0, countValueInHand(hand, move.card.value) - 1),
  };
}

function scoreQuietControlWindow(
  move: AIMove,
  summary: MoveTacticalSummary,
  nextIsOpp: boolean,
): number {
  if (move.capture.length > 0 || !nextIsOpp || summary.projectedTable.length < 4) {
    return 0;
  }

  let score = 0;
  const complement = 10 - move.card.value;
  const preservesTenLine = move.card.value >= 1
    && move.card.value <= 3
    && summary.projectedTable.some(card => card.value === complement);

  if (preservesTenLine) {
    score += 44;
    if (summary.tableSum >= 24) score += 32;
  }

  if (summary.projectedTable.length >= 5) score += 18;
  if (summary.tableSum >= 24) score += 18;
  if (move.card.suit !== 'denara' && move.card.value <= 2 && summary.tableSum >= 20) score += 20;
  if (summary.exposedDenariCount <= 1) score += 8;
  if (summary.exposedSevenCount <= 1) score += 8;

  return score;
}

function scoreOpeningDuplicateReleaseBias(
  card: Card,
  hand: Card[],
  state: GameState,
  playerIdx: PlayerIndex,
  nextIsOpp: boolean,
  roleContext: DealerRoleContext,
): number {
  if (
    state.table.length > 0
    || !nextIsOpp
    || roleContext.role !== 'first-hand'
  ) {
    return 0;
  }

  const sameValueCount = countValueInHand(hand, card.value);
  if (sameValueCount >= 2 && card.value >= 8) {
    let score = card.suit === 'denara' ? -180 : 280;

    if (
      card.suit !== 'denara'
      && hand.some(held => held.id !== card.id && held.value === card.value && held.suit === 'denara')
    ) {
      score += 220;
    }

    if (sameValueCount >= 3) score += 56;
    return score;
  }

  if (sameValueCount === 1 && card.value <= 3) return -180;
  if (card.suit === 'denara' && card.value <= 8) return -60;

  return 0;
}

function scoreDirectSevenPrimieraSwing(
  played: Card,
  captured: Card[],
  afterTable: Card[],
  hand: Card[],
  table: Card[],
  liveSevenPressure: boolean,
): number {
  if (!table.some(card => card.value === 7) || !captured.some(card => card.value === 7)) {
    return 0;
  }

  const directSevenCapture = played.value === 7 && captured.length === 1 && captured[0].value === 7;
  const alternateDirectSevenAvailable = played.value !== 7
    && hand.some(card => card.id !== played.id && card.value === 7);
  let score = 0;

  if (directSevenCapture) {
    score += liveSevenPressure ? 320 : 220;
    if (afterTable.length <= 3) score += 72;
    if (!afterTable.some(card => card.value === 7)) score += liveSevenPressure ? 120 : 60;
  }

  if (alternateDirectSevenAvailable) {
    score -= liveSevenPressure ? 260 : 160;
  }

  return score;
}

function isForcingSearchMove(summary: MoveTacticalSummary, race: RaceState): boolean {
  return summary.clearsTable
    || summary.capturesSettebello
    || summary.capturedSevenCount > 0
    || summary.capturedDenariCount >= 2
    || (race.behindInDenari && summary.capturedDenariCount > 0);
}

function isPriorityControlQuietMove(
  move: AIMove,
  summary: MoveTacticalSummary,
  nextIsOpp: boolean,
  roleContext: DealerRoleContext,
): boolean {
  if (move.capture.length > 0) return false;

  if (
    roleContext.defendingDealerAdvantage
    && move.card.suit !== 'denara'
    && move.card.value <= 4
    && summary.tableSum >= 18
  ) {
    return true;
  }

  if (!summary.highQuietRelease && summary.sameValueAnchorsRemaining === 0) return false;
  if (nextIsOpp && summary.tableSum < 11) return false;
  if (nextIsOpp && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0)) return false;

  return roleContext.defendingDealerAdvantage
    || summary.sameValueAnchorsRemaining > 0
    || summary.tableSum >= 15
    || summary.projectedTable.length >= 5;
}

function scoreHandStructure(
  hand: Card[],
  table: Card[],
  roleContext: DealerRoleContext,
): number {
  if (hand.length === 0) return 0;

  const counts = Array.from({ length: 11 }, () => 0);
  let score = 0;

  for (const card of hand) {
    counts[card.value]++;
  }

  for (let value = 1; value <= 10; value++) {
    if (counts[value] >= 2) {
      score += Math.round((value >= 8 ? 32 : 18) * roleContext.pairPreservingBias);
    }
    if (counts[value] >= 3) {
      score += 14;
    }
  }

  for (const card of hand) {
    const captures = findCaptures(card, table);
    if (captures.length > 0) {
      let bestCaptureScore = 0;
      for (const capture of captures) {
        let captureScore = capture.length * 14;
        if (capture.some(captured => captured.suit === 'denara')) captureScore += 16;
        if (capture.some(captured => captured.value === 7)) captureScore += 20;
        if (capture.length === table.length) captureScore += 90;
        if (captureScore > bestCaptureScore) bestCaptureScore = captureScore;
      }
      score += bestCaptureScore;
    } else {
      if (card.value >= 8) score += 12;
      if (card.suit !== 'denara' && card.value >= 8) score += 8;
      if (card.value <= 3 && roleContext.defendingDealerAdvantage) score += 10;
    }

    if (card.suit === 'denara') score += 10;
    if (card.value === 7) score += 16;
  }

  return score;
}

function scoreProtectedPairInventory(
  hand: Card[],
  roleContext: DealerRoleContext,
): number {
  if (hand.length < 2) return 0;

  const counts = Array.from({ length: 11 }, () => 0);
  let score = 0;

  for (const card of hand) {
    counts[card.value]++;
  }

  for (let value = 1; value <= 10; value++) {
    if (counts[value] < 2) continue;

    score += value >= 8 ? 18 : 10;
    if (value === 7) score += 6;
    if (counts[value] >= 3) score += 6;
  }

  return Math.round(score * roleContext.pairPreservingBias);
}

function scorePlayerVisibleTempo(state: GameState, playerIdx: PlayerIndex): number {
  const hand = state.players[playerIdx].hand;
  if (hand.length === 0) return 0;

  const roleContext = getDealerRoleContext(state, playerIdx);
  const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
  let bestMoveScore = -Infinity;
  let safeReleaseCount = 0;
  let forcingCount = 0;

  for (const move of getLegalMoves(state, playerIdx)) {
    const summary = summarizeMoveTactics(move, hand, state.table);
    let moveScore = 0;

    if (summary.clearsTable) moveScore += 160;
    moveScore += summary.capturedDenariCount * 26;
    moveScore += summary.capturedSevenCount * 28;
    if (summary.tableSum >= 11) moveScore += 24;
    if (summary.tableSum <= 10 && nextIsOpp) moveScore -= 36;
    if (summary.highQuietRelease && summary.tableSum >= 11) moveScore += 38;
    if (summary.sameValueAnchorsRemaining > 0) moveScore += summary.sameValueAnchorsRemaining * 12;
    moveScore += scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp);

    if (moveScore > bestMoveScore) bestMoveScore = moveScore;
    if (isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext)) safeReleaseCount++;
    if (summary.clearsTable || summary.capturedDenariCount > 0 || summary.capturedSevenCount > 0) forcingCount++;
  }

  if (!Number.isFinite(bestMoveScore)) bestMoveScore = 0;

  return Math.round(
    bestMoveScore
    + safeReleaseCount * 18
    + forcingCount * 10
    + scoreHandStructure(hand, state.table, roleContext) * 0.4,
  );
}

function scoreCurrentPlayerVisibleTempo(
  state: GameState,
  perspectiveTeam: 0 | 1,
): number {
  const currentPlayer = state.currentPlayer;
  if (state.players[currentPlayer].hand.length === 0) return 0;

  const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0);
  const urgency = cardsRemaining <= 8 ? 0.92 : cardsRemaining <= 16 ? 0.82 : 0.72;
  const sign = teamOf(currentPlayer) === perspectiveTeam ? 1 : -1;

  return Math.round(scorePlayerVisibleTempo(state, currentPlayer) * urgency * sign);
}

function scoreMoveObjectiveBias(
  move: AIMove,
  state: GameState,
  playerIdx: PlayerIndex,
  rootPlayer: PlayerIndex,
  tracker: CardTracker | undefined,
): number {
  const hand = state.players[playerIdx].hand;
  const phase = gamePhase(state);
  const race = getRaceState(state, playerIdx);
  const roleContext = getDealerRoleContext(state, playerIdx);
  const rankResidue = getRankResidueSnapshot(tracker, hand, state.table);
  const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
  const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length;
  const lastPlay = isLastPlay(state, playerIdx);
  const summary = summarizeMoveTactics(move, hand, state.table);
  const projectedHand = hand.filter(card => card.id !== move.card.id);
  const capturedCards = getMoveCollectedCards(move);
  const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx);
  const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats);
  const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats);
  const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats);
  const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats);
  const openingReleasePriority = move.capture.length === 0
    ? evaluateFirstHandOpeningReleasePriority(
      move.card,
      hand,
      projectedHand,
      summary.projectedTable,
      state,
      playerIdx,
      tracker,
      nextIsOpp,
      roleContext,
    )
    : 0;
  const beforeHandStructure = scoreHandStructure(hand, state.table, roleContext);
  const afterHandStructure = scoreHandStructure(projectedHand, summary.projectedTable, roleContext);
  const beforePairInventory = scoreProtectedPairInventory(hand, roleContext);
  const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext);
  const handStructureDelta = afterHandStructure - beforeHandStructure;
  const pairInventoryDelta = afterPairInventory - beforePairInventory;
  const rankResiduePlanScore = move.capture.length > 0
    ? scoreCaptureRankResiduePlan(move.card, move.capture, summary.projectedTable, rankResidue, roleContext, nextIsOpp)
    : scoreDumpRankResiduePlan(move.card, summary.projectedTable, rankResidue, roleContext, nextIsOpp);
  const quietControlWindow = scoreQuietControlWindow(move, summary, nextIsOpp);
  const liveDenariPressure = race.behindInDenari || race.denariRaceLive;
  const liveSevenPressure = race.need7s || race.sevenRaceLive;
  const openingDuplicateReleaseBias = move.capture.length === 0
    ? scoreOpeningDuplicateReleaseBias(move.card, hand, state, playerIdx, nextIsOpp, roleContext)
    : 0;
  const directSevenPrimieraSwing = move.capture.length > 0
    ? scoreDirectSevenPrimieraSwing(move.card, move.capture, summary.projectedTable, hand, state.table, liveSevenPressure)
    : 0;
  const liveCardsMajorityRace = race.myCards < 21
    && race.oppCards < 21
    && Math.abs(race.myCards - race.oppCards) <= 5;
  const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards;
  const cardsMajorityDelta = move.capture.length > 0
    ? scoreCardsMajorityPosition(race.myCards + capturedCards.length, race.oppCards, phase)
      - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase)
    : 0;
  const directRankCapture = move.capture.length === 1 && move.capture[0].value === move.card.value;
  const directSettebelloCapture = directRankCapture
    && move.capture[0].suit === 'denara'
    && move.capture[0].value === 7;
  const exactPartnerWindow = move.capture.length === 0
    && partnerHandSize > 0
    && summary.projectedTable.length >= 4
    && summary.tableSum >= 10
    && summary.tableSum <= 12
    && summary.exposedDenariCount <= 1
    && summary.exposedSevenCount <= 1;
  const safePartnerWindow = move.capture.length === 0
    && nextIsOpp
    && threats?.partnerCanScopa
    && !threats.nextOppCanScopa;

  let bias = 0;

  bias += scopaPriority * 380;
  if (summary.clearsTable && !lastPlay) bias += 220;
  if (summary.capturesSettebello) bias += 460;
  if (directRankCapture) bias += move.card.value === 7 ? 90 : 34;
  if (directRankCapture && move.capture[0].value === 7) bias += liveSevenPressure ? 140 : 70;
  if (directRankCapture && move.capture[0].suit === 'denara') {
    bias += liveDenariPressure ? (protectingCardsLead ? 88 : 150) : protectingCardsLead ? 36 : 72;
  }
  if (
    protectingCardsLead
    && !race.behindInDenari
    && move.capture.length === 1
    && move.capture[0].suit === 'denara'
    && !summary.capturesSettebello
  ) {
    bias -= 180;
  }
  if (directSettebelloCapture) bias += 180;
  if (directSettebelloCapture && nextIsOpp) bias += 220;
  if (
    !summary.capturesSettebello
    && state.table.some(card => card.suit === 'denara' && card.value === 7)
    && nextIsOpp
  ) {
    bias -= 460;
  }

  bias += antiScopaPriority * 48;
  bias += partnerSetupPriority * 8;
  bias += evaluateSevenDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.need7s) * (race.need7s ? 42 : race.sevenRaceLive ? 40 : 36);
  bias += evaluateDenariDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.behindInDenari) * (race.behindInDenari ? 38 : race.denariRaceLive ? 36 : 32);
  bias += openingReleasePriority * 52;
  bias += openingDuplicateReleaseBias;
  bias += quietControlWindow;
  bias += directSevenPrimieraSwing;
  bias += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 3.6 : 1.8));
  if (protectingCardsLead && move.capture.length > 1) {
    bias += 220 + move.capture.length * 36;
  }
  bias += Math.round(handStructureDelta * 1.35);
  bias += Math.round(pairInventoryDelta * (roleContext.defendingDealerAdvantage ? 6.5 : 4.5));
  bias += Math.round(scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp) * 0.85);
  bias += Math.round(rankResiduePlanScore * 0.9);
  bias += Math.round(scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker) * 0.55);
  bias += Math.round(capturedCards.reduce((sum, card) => sum + primieraVal(card), 0) * 3.2);

  if (nextIsOpp) {
    bias -= Math.round(summary.projectedTable.reduce((sum, card) => sum + primieraVal(card), 0) * 1.25);
  }

  if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) {
    bias -= 720;
  }

  if (move.capture.length === 0) {
    if (summary.highQuietRelease) bias += 72;
    bias += summary.sameValueAnchorsRemaining * 44;
    if (
      nextIsOpp
      && summary.projectedTable.length >= 5
      && summary.tableSum >= 24
      && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0)
    ) {
      bias -= 96 + summary.exposedDenariCount * 54 + summary.exposedSevenCount * 68;
    }
    if (exactPartnerWindow) bias += 96;
    if (safePartnerWindow) bias += exactPartnerWindow ? 120 : 76;
    if (
      roleContext.defendingDealerAdvantage
      && move.card.suit !== 'denara'
      && move.card.value <= 4
      && summary.sameValueAnchorsRemaining > 0
    ) {
      bias += 90;
    }
    if (
      roleContext.defendingDealerAdvantage
      && beforePairInventory > 0
      && afterPairInventory === beforePairInventory
      && move.card.suit !== 'denara'
      && move.card.value <= 4
    ) {
      bias += 152;
    }
  } else if (
    nextIsOpp
    && !summary.clearsTable
    && !summary.capturesSettebello
    && summary.capturedSevenCount === 0
    && summary.projectedTable.length <= 2
    && summary.tableSum <= 12
  ) {
    bias -= 120;
  }

  if (
    move.capture.length > 0
    && roleContext.defendingDealerAdvantage
    && countValueInHand(hand, move.card.value) >= 2
    && !summary.clearsTable
  ) {
    bias -= Math.round((move.card.value >= 8 ? 180 : 80) * roleContext.pairPreservingBias);
  }

  return teamOf(playerIdx) === teamOf(rootPlayer) ? bias : -bias;
}

interface RankedRootMove {
  index: number;
  move: AIMove;
  key: string;
  quick: number;
  isCapture: boolean;
  forcing: boolean;
  priorityControlQuiet: boolean;
}

interface MasterSearchProgressState {
  evaluationsCompleted: number;
  totalEvaluations: number;
  batchesCompleted: number;
  completedDepth: number;
  aspirationExpansions: number;
  timedOut: boolean;
}

interface MasterDepthResult {
  completed: boolean;
  bestMove: AIMove;
  bestKey: string;
  bestScore: number;
}

type TranspositionBound = 'exact' | 'lower' | 'upper';

interface TranspositionEntry {
  key: string;
  bestMove: AIMove | null;
  bestMoveKey: string | null;
  depth: number;
  score: number;
  bound: TranspositionBound;
}

interface MasterRootWorkspace {
  moveScores: number[];
  orderedMoves: RankedRootMove[];
  pvMoves: RankedRootMove[];
  hashMoves: RankedRootMove[];
  forcingMoves: RankedRootMove[];
  controlQuietMoves: RankedRootMove[];
  killerHistoryQuietMoves: RankedRootMove[];
  remainingMoves: RankedRootMove[];
}

interface SampleHandAssignment {
  playerIdx: PlayerIndex;
  handSize: number;
}

interface SampleHandBucket {
  assignment: SampleHandAssignment;
  cards: Card[];
}

interface SearchHeuristics {
  killerMoves: Map<number, string[]>;
  historyScores: Map<string, number>;
}

interface AspirationWindow {
  alpha: number;
  beta: number;
}

type AspirationFailure = 'lower' | 'upper';

const ASPIRATION_BASE_WINDOW = 120;
const EARLY_TURN_ASPIRATION_BASE_WINDOW = 180;
const ASPIRATION_MAX_EXPANSIONS = 5;
const EARLY_TURN_MIN_REMAINING_BUDGET_MS = 420;
const EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION = 0.72;
const KILLER_MOVE_SLOTS = 2;
const MAX_EXACT_SAMPLE_ASSIGNMENTS = 48;
const MAX_FOCUSED_ASSIGNMENT_CARDS = 8;
const ROOT_QUICK_PRIOR_FACTOR = 0.2;

function isQuietMove(move: AIMove): boolean {
  return move.capture.length === 0;
}

function getQuietHistoryScore(
  heuristics: SearchHeuristics,
  move: AIMove,
): number {
  return heuristics.historyScores.get(moveKey(move)) ?? 0;
}

function getKillerMoveRank(
  heuristics: SearchHeuristics,
  ply: number,
  move: AIMove,
): number {
  const killers = heuristics.killerMoves.get(ply);
  if (!killers || killers.length === 0) return -1;
  return killers.indexOf(moveKey(move));
}

function compareQuietMovePriority(
  left: { move: AIMove; quick: number },
  right: { move: AIMove; quick: number },
  heuristics: SearchHeuristics,
  ply: number,
): number {
  const leftKillerRank = getKillerMoveRank(heuristics, ply, left.move);
  const rightKillerRank = getKillerMoveRank(heuristics, ply, right.move);
  const leftKillerOrder = leftKillerRank === -1 ? Number.POSITIVE_INFINITY : leftKillerRank;
  const rightKillerOrder = rightKillerRank === -1 ? Number.POSITIVE_INFINITY : rightKillerRank;

  if (leftKillerOrder !== rightKillerOrder) {
    return leftKillerOrder - rightKillerOrder;
  }

  const historyDelta = getQuietHistoryScore(heuristics, right.move) - getQuietHistoryScore(heuristics, left.move);
  if (historyDelta !== 0) return historyDelta;

  return right.quick - left.quick;
}

function recordQuietCutoff(
  heuristics: SearchHeuristics,
  move: AIMove,
  ply: number,
  depth: number,
): void {
  if (!isQuietMove(move)) return;

  const key = moveKey(move);
  const killers = heuristics.killerMoves.get(ply) ?? [];
  const updatedKillers = [key, ...killers.filter(existingKey => existingKey !== key)].slice(0, KILLER_MOVE_SLOTS);
  heuristics.killerMoves.set(ply, updatedKillers);

  const historyBonus = Math.max(1, depth) * Math.max(1, depth);
  heuristics.historyScores.set(key, (heuristics.historyScores.get(key) ?? 0) + historyBonus);
}

function createAspirationWindow(
  previousScore: number | undefined,
  depth: number,
  sampleCount: number,
  minimumHalfWindow: number,
): AspirationWindow {
  if (previousScore === undefined) {
    return { alpha: -Infinity, beta: Infinity };
  }

  const halfWindow = Math.max(
    minimumHalfWindow,
    Math.round(sampleCount * 45 + depth * 24),
  );

  return {
    alpha: previousScore - halfWindow,
    beta: previousScore + halfWindow,
  };
}

function widenAspirationWindow(
  window: AspirationWindow,
  failingBound: AspirationFailure,
  expansion: number,
): AspirationWindow {
  if (failingBound === 'lower') {
    return {
      alpha: window.alpha - expansion,
      beta: window.beta,
    };
  }

  return {
    alpha: window.alpha,
    beta: window.beta + expansion,
  };
}

function classifyAspirationFailure(
  score: number,
  window: AspirationWindow,
): AspirationFailure | undefined {
  if (score <= window.alpha) return 'lower';
  if (score >= window.beta) return 'upper';
  return undefined;
}

function searchPrincipalVariationChild(
  state: GameState,
  depth: number,
  alpha: number,
  beta: number,
  myTeam: 0 | 1,
  rootPlayer: PlayerIndex,
  phase: number,
  deadline: number,
  timing: SearchTimingContext,
  tracker: CardTracker | undefined,
  transpositionTable: Map<string, TranspositionEntry>,
  heuristics: SearchHeuristics,
  ply: number,
  isFirstMove: boolean,
  maximizing: boolean,
): number {
  if (isFirstMove) {
    return alphaBeta(
      state,
      depth,
      alpha,
      beta,
      myTeam,
      rootPlayer,
      phase,
      deadline,
      timing,
      tracker,
      transpositionTable,
      heuristics,
      ply,
    );
  }

  if (maximizing) {
    const scoutBeta = Number.isFinite(alpha) ? Math.min(beta, alpha + 1) : beta;
    if (!(scoutBeta > alpha)) {
      return alphaBeta(
        state,
        depth,
        alpha,
        beta,
        myTeam,
        rootPlayer,
        phase,
        deadline,
        timing,
        tracker,
        transpositionTable,
        heuristics,
        ply,
      );
    }

    const scoutScore = alphaBeta(
      state,
      depth,
      alpha,
      scoutBeta,
      myTeam,
      rootPlayer,
      phase,
      deadline,
      timing,
      tracker,
      transpositionTable,
      heuristics,
      ply,
    );
    if (scoutScore > alpha && scoutScore < beta && timing.now() <= deadline) {
      return alphaBeta(
        state,
        depth,
        alpha,
        beta,
        myTeam,
        rootPlayer,
        phase,
        deadline,
        timing,
        tracker,
        transpositionTable,
        heuristics,
        ply,
      );
    }
    return scoutScore;
  }

  const scoutAlpha = Number.isFinite(beta) ? Math.max(alpha, beta - 1) : alpha;
  if (!(scoutAlpha < beta)) {
    return alphaBeta(
      state,
      depth,
      alpha,
      beta,
      myTeam,
      rootPlayer,
      phase,
      deadline,
      timing,
      tracker,
      transpositionTable,
      heuristics,
      ply,
    );
  }

  const scoutScore = alphaBeta(
    state,
    depth,
    scoutAlpha,
    beta,
    myTeam,
    rootPlayer,
    phase,
    deadline,
      timing,
    tracker,
    transpositionTable,
    heuristics,
    ply,
  );
  if (scoutScore < beta && scoutScore > alpha && timing.now() <= deadline) {
    return alphaBeta(
      state,
      depth,
      alpha,
      beta,
      myTeam,
      rootPlayer,
      phase,
      deadline,
      timing,
      tracker,
      transpositionTable,
      heuristics,
      ply,
    );
  }
  return scoutScore;
}

function rankRootMoves(
  legalMoves: AIMove[],
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: CardTracker | undefined,
  race: RaceState,
  roleContext: DealerRoleContext,
  timing: SearchTimingContext,
): RankedRootMove[] {
  const hand = state.players[playerIdx].hand;
  const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
  const rankedMoves = legalMoves.map(move => {
    timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS);
    const quick = quickEval(
      move,
      state,
      playerIdx,
      playerIdx,
      tracker,
      false,
    );
    const summary = summarizeMoveTactics(move, hand, state.table);
    return {
      move,
      key: moveKey(move),
      quick,
      forcing: isForcingSearchMove(summary, race),
      priorityControlQuiet: isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext),
    };
  });

  return rankedMoves
    .sort((a, b) => b.quick - a.quick)
    .map((rankedMove, index) => ({
      ...rankedMove,
      index,
      isCapture: rankedMove.move.capture.length > 0,
    }));
}

function createMasterRootWorkspace(rootMoveCount: number): MasterRootWorkspace {
  return {
    moveScores: new Array(rootMoveCount).fill(0),
    orderedMoves: [],
    pvMoves: [],
    hashMoves: [],
    forcingMoves: [],
    controlQuietMoves: [],
    killerHistoryQuietMoves: [],
    remainingMoves: [],
  };
}

function resetMasterRootWorkspace(workspace: MasterRootWorkspace): void {
  workspace.orderedMoves.length = 0;
  workspace.pvMoves.length = 0;
  workspace.hashMoves.length = 0;
  workspace.forcingMoves.length = 0;
  workspace.controlQuietMoves.length = 0;
  workspace.killerHistoryQuietMoves.length = 0;
  workspace.remainingMoves.length = 0;
}

function appendRankedRootMoves(target: RankedRootMove[], source: RankedRootMove[]): void {
  for (const rankedMove of source) {
    target.push(rankedMove);
  }
}

function orderRootMovesForDepth(
  rankedMoves: RankedRootMove[],
  previousBestKey: string | undefined,
  ttEntry: TranspositionEntry | undefined,
  heuristics: SearchHeuristics,
  workspace: MasterRootWorkspace,
  timing: SearchTimingContext,
): RankedRootMove[] {
  if (rankedMoves.length <= 1) return rankedMoves;

  resetMasterRootWorkspace(workspace);
  const hashMoveKey = ttEntry?.bestMoveKey ?? undefined;

  for (const rankedMove of rankedMoves) {
    timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS);
    const quietMoveBoost = !rankedMove.isCapture
      && (
        getKillerMoveRank(heuristics, 0, rankedMove.move) !== -1
        || getQuietHistoryScore(heuristics, rankedMove.move) > 0
      );

    if (previousBestKey && rankedMove.key === previousBestKey) {
      workspace.pvMoves.push(rankedMove);
      continue;
    }

    if (hashMoveKey && rankedMove.key === hashMoveKey) {
      workspace.hashMoves.push(rankedMove);
      continue;
    }

    if (rankedMove.forcing) {
      workspace.forcingMoves.push(rankedMove);
      continue;
    }

    if (rankedMove.priorityControlQuiet) {
      workspace.controlQuietMoves.push(rankedMove);
      continue;
    }

    if (quietMoveBoost) {
      workspace.killerHistoryQuietMoves.push(rankedMove);
      continue;
    }

    workspace.remainingMoves.push(rankedMove);
  }

  workspace.killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, 0));

  appendRankedRootMoves(workspace.orderedMoves, workspace.pvMoves);
  appendRankedRootMoves(workspace.orderedMoves, workspace.hashMoves);
  appendRankedRootMoves(workspace.orderedMoves, workspace.forcingMoves);
  appendRankedRootMoves(workspace.orderedMoves, workspace.controlQuietMoves);
  appendRankedRootMoves(workspace.orderedMoves, workspace.killerHistoryQuietMoves);
  appendRankedRootMoves(workspace.orderedMoves, workspace.remainingMoves);

  return workspace.orderedMoves;
}

function selectBestRootMove(
  rankedMoves: RankedRootMove[],
  moveScores: number[],
): { bestMove: AIMove; bestKey: string; bestScore: number } {
  let bestRootMove = rankedMoves[0];
  let bestScore = moveScores[bestRootMove.index] ?? 0;

  for (const rankedMove of rankedMoves) {
    const totalScore = moveScores[rankedMove.index] ?? 0;
    if (totalScore > bestScore) {
      bestScore = totalScore;
      bestRootMove = rankedMove;
    }
  }

  return {
    bestMove: bestRootMove.move,
    bestKey: bestRootMove.key,
    bestScore,
  };
}

function getMasterProgress(
  progressState: MasterSearchProgressState,
  startedAt: number,
  budgetMs: number,
  timing: SearchTimingContext,
): number {
  return Math.max(
    progressState.evaluationsCompleted / progressState.totalEvaluations,
    Math.min(1, (timing.now() - startedAt) / budgetMs),
  );
}

function buildMasterProgressDetails(
  progressState: MasterSearchProgressState,
  cardsRemaining: number,
  sampleCount: number,
  maxDepth: number,
  rootMoveCount: number,
): MasterProgressDetails {
  return {
    cardsRemaining,
    sampleCount,
    maxDepth,
    completedDepth: progressState.completedDepth,
    rootMoveCount,
    timedOut: progressState.timedOut,
    aspirationExpansions: progressState.aspirationExpansions,
  };
}

function scoreControlOverrideCandidate(
  move: AIMove,
  state: GameState,
  playerIdx: PlayerIndex,
  race: RaceState,
  roleContext: DealerRoleContext,
  tracker: CardTracker | undefined,
): number {
  const hand = state.players[playerIdx].hand;
  const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
  const lastPlay = isLastPlay(state, playerIdx);
  const summary = summarizeMoveTactics(move, hand, state.table);
  const projectedHand = hand.filter(card => card.id !== move.card.id);
  const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx);
  const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length;
  const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats);
  const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats);
  const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats);
  const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats);
  const openingReleasePriority = move.capture.length === 0
    ? evaluateFirstHandOpeningReleasePriority(
      move.card,
      hand,
      projectedHand,
      summary.projectedTable,
      state,
      playerIdx,
      tracker,
      nextIsOpp,
      roleContext,
    )
    : 0;
  let score = Math.round(scoreHandStructure(projectedHand, summary.projectedTable, roleContext) * 0.55);

  score += summary.projectedTable.length * 48;
  score += summary.tableSum >= 11 ? 90 + summary.tableSum * 8 : -260;
  score += scopaPriority * 600;
  score += antiScopaPriority * 54;
  score += partnerSetupPriority * 8;

  if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) {
    score -= 720;
  }

  if (move.capture.length === 0) {
    if (summary.highQuietRelease) score += 220;
    score += openingReleasePriority * 180;
    if (move.card.suit !== 'denara' && move.card.value <= 3) score += roleContext.defendingDealerAdvantage ? 260 : 70;
    if (nextIsOpp && summary.projectedTable.length >= 5) score += 110;
    if (
      nextIsOpp
      && summary.highQuietRelease
      && summary.projectedTable.length >= 5
      && summary.tableSum >= 24
    ) {
      if (summary.exposedDenariCount === 0 && summary.exposedSevenCount === 0) {
        score += 260;
      } else {
        score -= 80 + summary.exposedDenariCount * 90 + summary.exposedSevenCount * 120;
      }
    }
  } else {
    if (!isForcingSearchMove(summary, race)) {
      score -= summary.projectedTable.length <= 2 || summary.tableSum <= 12 ? 200 : 80;
    }
    if (!summary.clearsTable && isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats)) {
      score -= 180;
    }
    if (nextIsOpp && summary.projectedTable.length <= 2) score -= 150;
    else if (nextIsOpp && summary.projectedTable.length === 3 && summary.tableSum <= 12) score -= 90;
    if (nextIsOpp) score -= summary.exposedDenariCount * 90;
    if (nextIsOpp) score -= summary.exposedSevenCount * 70;
    if (
      nextIsOpp
      && !summary.clearsTable
      && !summary.capturesSettebello
      && summary.capturedSevenCount === 0
      && summary.projectedTable.length <= 2
      && summary.tableSum < 18
    ) {
      score -= 220;
    }
  }

  if (roleContext.defendingDealerAdvantage && move.capture.length === 0 && summary.tableSum >= 18) {
    score += 180;
  }

  if (nextIsOpp && summary.projectedTable.length > 0 && summary.tableSum <= 10) {
    score -= 220;
  }

  return score;
}

function findStrategicControlOverride(
  legalMoves: AIMove[],
  state: GameState,
  playerIdx: PlayerIndex,
  race: RaceState,
  roleContext: DealerRoleContext,
  tracker: CardTracker | undefined,
): AIMove | undefined {
  if (legalMoves.length <= 1) return undefined;
  const lastPlay = isLastPlay(state, playerIdx);
  if (lastPlay) return undefined;
  if (!isOpponent(playerIdx, nextPlayer(playerIdx))) return undefined;

  let bestQuiet:
    | { move: AIMove; score: number }
    | undefined;
  let bestCapture:
    | { move: AIMove; score: number }
    | undefined;
  let bestSafeScopa:
    | { move: AIMove; score: number }
    | undefined;

  for (const move of legalMoves) {
    const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker);
    const summary = summarizeMoveTactics(move, state.players[playerIdx].hand, state.table);
    const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id);
    const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx);
    const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, true, threats);

    if (scopaPriority > 0) {
      if (!bestSafeScopa || score > bestSafeScopa.score) bestSafeScopa = { move, score };
    }

    if (move.capture.length === 0) {
      if (!bestQuiet || score > bestQuiet.score) bestQuiet = { move, score };
      continue;
    }

    if (!bestCapture || score > bestCapture.score) bestCapture = { move, score };
  }

  if (bestSafeScopa) return bestSafeScopa.move;

  if (!bestQuiet) return undefined;

  const quietSummary = summarizeMoveTactics(bestQuiet.move, state.players[playerIdx].hand, state.table);
  const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== bestQuiet.move.card.id);
  const duplicateHighValues = new Set(
    projectedHand.filter(card => projectedHand.some(other => other.id !== card.id && other.value === card.value && card.value >= 8))
      .map(card => card.value),
  ).size;
  const dealerControlQuiet = roleContext.role === 'dealer'
    && bestCapture !== undefined
    && bestQuiet.score >= bestCapture.score + 220
    && bestQuiet.move.card.suit !== 'denara'
    && bestQuiet.move.card.value <= 3
    && quietSummary.projectedTable.length >= 5
    && quietSummary.tableSum >= 18
    && duplicateHighValues > 0;

  if (dealerControlQuiet) {
    return bestQuiet.move;
  }

  if (!bestCapture) return undefined;

  const captureSummary = summarizeMoveTactics(bestCapture.move, state.players[playerIdx].hand, state.table);
  const antiScopaControlQuiet = bestQuiet.score >= bestCapture.score + 120
    && bestQuiet.move.card.suit !== 'denara'
    && bestQuiet.move.card.value >= 8
    && quietSummary.projectedTable.length >= 5
    && quietSummary.tableSum >= 24
    && state.table.some(card => card.suit === 'denara' || card.value === 7)
    && bestCapture.move.card.value <= 5
    && captureSummary.capturedSevenCount === 0
    && !captureSummary.clearsTable
    && !captureSummary.capturesSettebello
    && captureSummary.projectedTable.length <= 3;

  return antiScopaControlQuiet ? bestQuiet.move : undefined;
}

async function evaluateMasterDepth(
  state: GameState,
  samples: GameState[],
  orderedMoves: RankedRootMove[],
  depth: number,
  aspirationWindow: AspirationWindow,
  playerIdx: PlayerIndex,
  myTeam: 0 | 1,
  phase: number,
  deadline: number,
  tracker: CardTracker | undefined,
  onProgress: ((progress: AIDecisionProgress) => void) | undefined,
  profile: SearchProfile,
  startedAt: number,
  timing: SearchTimingContext,
  progressState: MasterSearchProgressState,
  transpositionTable: Map<string, TranspositionEntry>,
  heuristics: SearchHeuristics,
  rootWorkspace: MasterRootWorkspace,
  cardsRemaining: number,
  sampleCount: number,
  rootMoveCount: number,
): Promise<MasterDepthResult> {
  const moveScores = rootWorkspace.moveScores;
  moveScores.fill(0);
  for (const orderedMove of orderedMoves) {
    moveScores[orderedMove.index] = orderedMove.quick * ROOT_QUICK_PRIOR_FACTOR;
  }

  for (let start = 0; start < samples.length; start += profile.batchSize) {
    if (timing.now() > deadline) {
      progressState.timedOut = true;
      return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) };
    }

    const end = Math.min(start + profile.batchSize, samples.length);
    for (let sampleIdx = start; sampleIdx < end; sampleIdx++) {
      const sample = samples[sampleIdx];
      let sampleAlpha = aspirationWindow.alpha;
      const sampleBeta = aspirationWindow.beta;
      let isFirstRootMove = true;

      for (const orderedMove of orderedMoves) {
        timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS);
        if (timing.now() > deadline) {
          progressState.timedOut = true;
          return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) };
        }

        const result = applyMove(
          sample,
          playerIdx,
          orderedMove.move.card,
          orderedMove.move.capture.length > 0 ? orderedMove.move.capture : undefined,
        );
        const score = searchPrincipalVariationChild(
          result.nextState,
          depth - 1,
          sampleAlpha,
          sampleBeta,
          myTeam,
          playerIdx,
          phase,
          deadline,
          timing,
          tracker,
          transpositionTable,
          heuristics,
          1,
          isFirstRootMove,
          true,
        );
        moveScores[orderedMove.index] += score;
        if (score > sampleAlpha) {
          sampleAlpha = score;
        }
        progressState.evaluationsCompleted++;
        isFirstRootMove = false;
      }
    }

    progressState.batchesCompleted++;
    reportDecisionProgress(
      onProgress,
      'master',
      startedAt,
      timing,
      profile.timeBudgetMs,
      getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing),
      progressState.batchesCompleted,
      buildMasterProgressDetails(
        progressState,
        cardsRemaining,
        sampleCount,
        profile.maxDepth,
        rootMoveCount,
      ),
    );

    if (end < samples.length && timing.now() < deadline) {
      await timing.yieldToHost();
    }
  }

  return { completed: true, ...selectBestRootMove(orderedMoves, moveScores) };
}

async function masterMove(
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: CardTracker | undefined,
  onProgress: ((progress: AIDecisionProgress) => void) | undefined,
  profile: SearchProfile,
  startedAt: number,
  timing: SearchTimingContext,
  rng: RandomSource,
): Promise<AIMove> {
  const myTeam = teamOf(playerIdx);
  const phase = gamePhase(state);
  const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0);
  const legalMoves = getLegalMoves(state, playerIdx);
  const rootMoveCount = legalMoves.length;
  if (legalMoves.length === 1) {
    reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, {
      cardsRemaining,
      sampleCount: 1,
      maxDepth: 1,
      completedDepth: 1,
      rootMoveCount,
      timedOut: false,
      aspirationExpansions: 0,
    });
    return legalMoves[0];
  }

  const deadline = startedAt + profile.timeBudgetMs;
  const progressState: MasterSearchProgressState = {
    evaluationsCompleted: 0,
    totalEvaluations: Math.max(1, rootMoveCount * profile.maxDepth),
    batchesCompleted: 0,
    completedDepth: 0,
    aspirationExpansions: 0,
    timedOut: false,
  };

  const race = getRaceState(state, playerIdx);
  const roleContext = getDealerRoleContext(state, playerIdx);
  const rankedMoves = rankRootMoves(
    legalMoves,
    state,
    playerIdx,
    tracker,
    race,
    roleContext,
    timing,
  );
  let bestPreSearchMove = rankedMoves[0].move;
  if (timing.now() > deadline) {
    progressState.timedOut = true;
    reportDecisionProgress(
      onProgress,
      'master',
      startedAt,
      timing,
      profile.timeBudgetMs,
      getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing),
      progressState.batchesCompleted,
      buildMasterProgressDetails(progressState, cardsRemaining, 0, profile.maxDepth, rankedMoves.length),
    );
    return bestPreSearchMove;
  }

  const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount, rng, timing);
  const sampleCount = samples.length;
  progressState.totalEvaluations = Math.max(1, samples.length * rankedMoves.length * profile.maxDepth);
  if (timing.now() > deadline) {
    progressState.timedOut = true;
    reportDecisionProgress(
      onProgress,
      'master',
      startedAt,
      timing,
      profile.timeBudgetMs,
      getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing),
      progressState.batchesCompleted,
      buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length),
    );
    return bestPreSearchMove;
  }

  const transpositionTable = new Map<string, TranspositionEntry>();
  const heuristics: SearchHeuristics = {
    killerMoves: new Map<number, string[]>(),
    historyScores: new Map<string, number>(),
  };
  const rootWorkspace = createMasterRootWorkspace(rankedMoves.length);
  const rootStateKey = buildSearchStateKey(state);

  reportDecisionProgress(
    onProgress,
    'master',
    startedAt,
    timing,
    profile.timeBudgetMs,
    0,
    0,
    buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length),
  );

  let previousBestKey: string | undefined;
  let lastCompletedDepth: MasterDepthResult | undefined;
  let lastCompletedDepthElapsedMs: number | undefined;

  for (let depth = 1; depth <= profile.maxDepth; depth++) {
    const depthStartedAt = timing.now();
    if (depthStartedAt > deadline) {
      progressState.timedOut = true;
      break;
    }

    if (cardsRemaining > 20) {
      const remainingBudgetMs = deadline - depthStartedAt;
      if (remainingBudgetMs < EARLY_TURN_MIN_REMAINING_BUDGET_MS) {
        break;
      }

      if (
        lastCompletedDepthElapsedMs !== undefined
        && lastCompletedDepthElapsedMs >= profile.timeBudgetMs * EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION
      ) {
        break;
      }
    }

    const aspirationHalfWindowFloor = cardsRemaining > 20 || rootMoveCount >= 8
      ? EARLY_TURN_ASPIRATION_BASE_WINDOW
      : ASPIRATION_BASE_WINDOW;
    let aspirationWindow = createAspirationWindow(
      lastCompletedDepth?.bestScore,
      depth,
      samples.length,
      aspirationHalfWindowFloor,
    );
    let depthResult: MasterDepthResult | undefined;

    for (let expansion = 0; expansion <= ASPIRATION_MAX_EXPANSIONS; expansion++) {
      if (timing.now() > deadline) {
        progressState.timedOut = true;
        break;
      }

      const rootEntry = transpositionTable.get(rootStateKey);
      const orderedMoves = orderRootMovesForDepth(rankedMoves, previousBestKey, rootEntry, heuristics, rootWorkspace, timing);
      bestPreSearchMove = orderedMoves[0]?.move ?? bestPreSearchMove;
      if (timing.now() > deadline) {
        progressState.timedOut = true;
        break;
      }

      depthResult = await evaluateMasterDepth(
        state,
        samples,
        orderedMoves,
        depth,
        aspirationWindow,
        playerIdx,
        myTeam,
        phase,
        deadline,
        tracker,
        onProgress,
        profile,
        startedAt,
        timing,
        progressState,
        transpositionTable,
        heuristics,
        rootWorkspace,
        cardsRemaining,
        sampleCount,
        rankedMoves.length,
      );

      if (!depthResult.completed) {
        break;
      }

      const failingBound = classifyAspirationFailure(depthResult.bestScore, aspirationWindow);
      if (!failingBound) {
        lastCompletedDepth = depthResult;
        lastCompletedDepthElapsedMs = timing.now() - depthStartedAt;
        previousBestKey = depthResult.bestKey;
        progressState.completedDepth = depth;
        break;
      }

      progressState.aspirationExpansions++;
      const windowWidth = Number.isFinite(aspirationWindow.alpha) && Number.isFinite(aspirationWindow.beta)
        ? aspirationWindow.beta - aspirationWindow.alpha
        : aspirationHalfWindowFloor;
      aspirationWindow = widenAspirationWindow(
        aspirationWindow,
        failingBound,
        Math.max(aspirationHalfWindowFloor, windowWidth * 2),
      );
    }

    if (!depthResult?.completed || lastCompletedDepth !== depthResult) {
      break;
    }

    if (depth < profile.maxDepth && timing.now() < deadline) {
      await timing.yieldToHost();
    }
  }

  const bestMove = lastCompletedDepth?.bestMove ?? bestPreSearchMove;

  reportDecisionProgress(
    onProgress,
    'master',
    startedAt,
    timing,
    profile.timeBudgetMs,
    1,
    progressState.batchesCompleted,
    buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length),
  );
  return bestMove;
}

function quickEval(
  move: AIMove,
  state: GameState,
  playerIdx: PlayerIndex,
  rootPlayer: PlayerIndex,
  tracker: CardTracker | undefined,
  allowHiddenHands: boolean,
): number {
  const result = applyMove(state, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
  const nextState = result.nextState;

  return evaluateTeamPosition(
    nextState,
    teamOf(rootPlayer),
    gamePhase(nextState),
    tracker,
    rootPlayer,
    allowHiddenHands,
  ) + scoreMoveObjectiveBias(move, state, playerIdx, rootPlayer, tracker);
}

function moveKey(move: AIMove): string {
  const capIds = move.capture.map(c => c.id).sort().join(',');
  return `${move.card.id}|${capIds}`;
}

function getMoveCollectedCards(move: AIMove): Card[] {
  if (move.capture.length === 0) return [];
  return [move.card, ...move.capture];
}

function stableCardCollectionKey(cards: Card[]): string {
  return cards.map(card => card.id).sort().join(',');
}

function buildSearchStateKey(state: GameState): string {
  const playerKeys = state.players.map((player, index) => {
    const handKey = stableCardCollectionKey(player.hand);
    const pileKey = stableCardCollectionKey(player.pile);
    return `p${index}h:${handKey}|p${index}p:${pileKey}|p${index}s:${player.scope}`;
  }).join('|');

  return [
    `cp:${state.currentPlayer}`,
    `d:${state.dealer}`,
    `l:${state.lastCapturTeam ?? 'null'}`,
    `t:${stableCardCollectionKey(state.table)}`,
    playerKeys,
  ].join('|');
}

function getValidHashMove(
  moves: AIMove[],
  entry: TranspositionEntry | undefined,
): AIMove | undefined {
  if (!entry?.bestMoveKey) return undefined;

  return moves.find(move => moveKey(move) === entry.bestMoveKey);
}

function orderSearchMoves(
  moves: AIMove[],
  state: GameState,
  playerIdx: PlayerIndex,
  rootPlayer: PlayerIndex,
  tracker: CardTracker | undefined,
  pvMove: AIMove | undefined,
  hashMove: AIMove | undefined,
  heuristics: SearchHeuristics,
  ply: number,
): AIMove[] {
  if (moves.length <= 1) return moves;

  const race = getRaceState(state, playerIdx);
  const roleContext = getDealerRoleContext(state, playerIdx);
  const hand = state.players[playerIdx].hand;
  const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx));
  const maximizingNode = teamOf(playerIdx) === teamOf(rootPlayer);
  const rankedMoves = moves
    .map(move => ({
      move,
      key: moveKey(move),
      quick: quickEval(move, state, playerIdx, rootPlayer, tracker, true),
    }))
    .sort((a, b) => maximizingNode ? b.quick - a.quick : a.quick - b.quick);

  const pvMoveKey = pvMove ? moveKey(pvMove) : undefined;
  const hashMoveKey = hashMove ? moveKey(hashMove) : undefined;
  const pvMoves: typeof rankedMoves = [];
  const hashMoves: typeof rankedMoves = [];
  const forcingMoves: typeof rankedMoves = [];
  const controlQuietMoves: typeof rankedMoves = [];
  const killerHistoryQuietMoves: typeof rankedMoves = [];
  const remainingMoves: typeof rankedMoves = [];

  for (const rankedMove of rankedMoves) {
    const moveSummary = summarizeMoveTactics(rankedMove.move, hand, state.table);
    const quietMoveBoost = isQuietMove(rankedMove.move)
      && (
        getKillerMoveRank(heuristics, ply, rankedMove.move) !== -1
        || getQuietHistoryScore(heuristics, rankedMove.move) > 0
      );

    if (pvMoveKey && rankedMove.key === pvMoveKey) {
      pvMoves.push(rankedMove);
      continue;
    }

    if (hashMoveKey && rankedMove.key === hashMoveKey) {
      hashMoves.push(rankedMove);
      continue;
    }

    if (isForcingSearchMove(moveSummary, race)) {
      forcingMoves.push(rankedMove);
      continue;
    }

    if (isPriorityControlQuietMove(rankedMove.move, moveSummary, nextIsOpp, roleContext)) {
      controlQuietMoves.push(rankedMove);
      continue;
    }

    if (quietMoveBoost) {
      killerHistoryQuietMoves.push(rankedMove);
      continue;
    }

    remainingMoves.push(rankedMove);
  }

  killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, ply));

  return [
    ...pvMoves,
    ...hashMoves,
    ...forcingMoves,
    ...controlQuietMoves,
    ...killerHistoryQuietMoves,
    ...remainingMoves,
  ].map(rankedMove => rankedMove.move);
}

function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] {
  const moves: AIMove[] = [];
  const player = state.players[playerIdx];
  const table = state.table;
  for (const card of player.hand) {
    const captures = findCaptures(card, table);
    if (captures.length > 0) {
      for (const captureSet of captures) moves.push({ card, capture: captureSet });
    } else {
      moves.push({ card, capture: [] });
    }
  }
  return moves;
}

function createSampleHandAssignments(state: GameState, playerIdx: PlayerIndex): SampleHandAssignment[] {
  const assignments: SampleHandAssignment[] = [];
  let cur = nextPlayer(playerIdx);

  for (let step = 0; step < 3; step++) {
    const handSize = state.players[cur].hand.length;
    if (handSize > 0) {
      assignments.push({
        playerIdx: cur,
        handSize,
      });
    }
    cur = nextPlayer(cur);
  }

  return assignments;
}

function getUnseenCardPriority(card: Card, table: Card[]): number {
  let priority = card.value;

  if (card.suit === 'denara' && card.value === 7) {
    priority += 20000;
  } else if (card.value === 7) {
    priority += 12000;
  } else if (card.suit === 'denara') {
    priority += 6000;
  }

  if (card.value === 6) priority += 900;
  if (card.value === 1) priority += 700;
  priority += primieraVal(card) * 25;

  if (canCapture(card, table)) {
    priority += 800;
    const captures = findCaptures(card, table);
    let bestCapturePriority = 0;
    for (const capture of captures) {
      let capturePriority = capture.length * 140;
      for (const capturedCard of capture) {
        if (capturedCard.suit === 'denara') capturePriority += 90;
        if (capturedCard.value === 7) capturePriority += 160;
      }
      if (capture.length === table.length) capturePriority += 500;
      if (capturePriority > bestCapturePriority) bestCapturePriority = capturePriority;
    }
    priority += bestCapturePriority;
  }

  return priority;
}

function prioritizeUnseenCards(unseen: Card[], table: Card[]): Card[] {
  return [...unseen].sort((left, right) => {
    const priorityDelta = getUnseenCardPriority(right, table) - getUnseenCardPriority(left, table);
    if (priorityDelta !== 0) return priorityDelta;
    return left.id.localeCompare(right.id);
  });
}

function rotateValues<T>(values: T[], offset: number): T[] {
  if (values.length <= 1) return values;

  const normalizedOffset = ((offset % values.length) + values.length) % values.length;
  if (normalizedOffset === 0) return [...values];
  return [...values.slice(normalizedOffset), ...values.slice(0, normalizedOffset)];
}

function getAssignmentOrderVariants(assignments: SampleHandAssignment[]): SampleHandAssignment[][] {
  if (assignments.length <= 1) return [assignments];

  if (assignments.length === 2) {
    return [
      assignments,
      [assignments[1], assignments[0]],
    ];
  }

  return [
    assignments,
    [assignments[0], assignments[2], assignments[1]],
    [assignments[1], assignments[0], assignments[2]],
    [assignments[2], assignments[0], assignments[1]],
    [assignments[1], assignments[2], assignments[0]],
    [assignments[2], assignments[1], assignments[0]],
  ];
}

function buildSampleAssignmentKey(assignments: SampleHandBucket[]): string {
  return assignments
    .map(({ assignment, cards }) => `${assignment.playerIdx}:${stableCardCollectionKey(cards)}`)
    .join('|');
}

function combinationCount(n: number, k: number): number {
  if (k < 0 || k > n) return 0;
  if (k === 0 || k === n) return 1;

  let result = 1;
  const boundedK = Math.min(k, n - k);
  for (let index = 1; index <= boundedK; index++) {
    result = (result * (n - boundedK + index)) / index;
    if (result > MAX_EXACT_SAMPLE_ASSIGNMENTS) {
      return result;
    }
  }

  return result;
}

function getHiddenAssignmentCount(unseenCount: number, assignments: SampleHandAssignment[]): number {
  let remaining = unseenCount;
  let totalAssignments = 1;

  for (const assignment of assignments) {
    totalAssignments *= combinationCount(remaining, assignment.handSize);
    if (totalAssignments > MAX_EXACT_SAMPLE_ASSIGNMENTS) {
      return totalAssignments;
    }
    remaining -= assignment.handSize;
  }

  return totalAssignments;
}

function assignBucketsToSample(
  sample: GameState,
  assignments: SampleHandBucket[],
): void {
  for (const { assignment, cards } of assignments) {
    sample.players[assignment.playerIdx].hand = cards.slice();
  }
}

function buildExactSampleStates(
  state: GameState,
  prioritizedUnseen: Card[],
  assignments: SampleHandAssignment[],
  timing: SearchTimingContext,
): GameState[] {
  const samples: GameState[] = [];
  const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] }));

  const visitAssignment = (assignmentIndex: number, remainingCards: Card[]): boolean => {
    if (samples.length >= MAX_EXACT_SAMPLE_ASSIGNMENTS) {
      return true;
    }

    if (assignmentIndex >= buckets.length) {
      const sample = cloneState(state);
      assignBucketsToSample(sample, buckets);
      samples.push(sample);
      return false;
    }

    const bucket = buckets[assignmentIndex];
    const targetSize = bucket.assignment.handSize;
    const chosenIndices: number[] = [];

    const chooseCards = (startIndex: number): boolean => {
      if (chosenIndices.length === targetSize) {
        bucket.cards = chosenIndices.map(index => remainingCards[index]);
        const nextRemaining = remainingCards.filter((_, index) => !chosenIndices.includes(index));
        return visitAssignment(assignmentIndex + 1, nextRemaining);
      }

      const needed = targetSize - chosenIndices.length;
      const maxStart = remainingCards.length - needed;
      for (let index = startIndex; index <= maxStart; index++) {
        timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS);
        chosenIndices.push(index);
        if (chooseCards(index + 1)) return true;
        chosenIndices.pop();
      }

      return false;
    };

    return chooseCards(0);
  };

  visitAssignment(0, prioritizedUnseen);
  return samples;
}

function scoreUnseenCardTablePressure(card: Card, table: Card[]): number {
  let score = 0;
  const captures = findCaptures(card, table);

  for (const capture of captures) {
    let captureScore = capture.length * 28;
    if (capture.some(captured => captured.suit === 'denara')) captureScore += 22;
    if (capture.some(captured => captured.value === 7)) captureScore += 28;
    if (capture.length === table.length) captureScore += 140;
    if (captureScore > score) score = captureScore;
  }

  if (table.some(tableCard => tableCard.value === card.value)) score += 40;
  if (card.suit === 'denara') score += 20;
  if (card.value === 7) score += 28;
  if (card.value >= 8 && captures.length === 0) score += 12;

  return score;
}

function scoreSampleAssignmentCandidate(
  card: Card,
  assignment: SampleHandAssignment,
  state: GameState,
  rootPlayer: PlayerIndex,
  rankResidue: RankResidueSnapshot | null,
): number {
  const next = nextPlayer(rootPlayer);
  const partner = partnerOf(rootPlayer);
  const assignmentIsOpp = isOpponent(rootPlayer, assignment.playerIdx);
  const playsNext = assignment.playerIdx === next;
  const isPartner = assignment.playerIdx === partner;
  const assignmentRole = getDealerRoleContext(state, assignment.playerIdx);
  const pressureScore = scoreUnseenCardTablePressure(card, state.table);
  let score = assignment.handSize * 2;

  if (playsNext) {
    score += pressureScore * (assignmentIsOpp ? 2.5 : 1.6);
  } else if (assignmentIsOpp) {
    score += pressureScore * 1.25;
  } else if (isPartner) {
    score += pressureScore * 1.1;
  } else {
    score += pressureScore * 0.85;
  }

  if (rankResidue) {
    if (rankResidue.hasSingletonResidue[card.value]) {
      score += playsNext ? 30 : 12;
    }
    if (rankResidue.hasPairedResidue[card.value]) {
      score += assignmentRole.defendingDealerAdvantage ? 20 : 8;
    }
  }

  if (card.suit === 'denara') {
    score += playsNext && assignmentIsOpp ? 26 : assignmentRole.onDealerSide ? 14 : 8;
  }
  if (card.value === 7) {
    score += assignmentIsOpp ? 24 : 14;
  }
  if (card.value >= 8 && !canCapture(card, state.table)) {
    score += assignmentRole.defendingDealerAdvantage ? 16 : 8;
  }

  return score;
}

function selectSampleBucketForCard(
  card: Card,
  buckets: SampleHandBucket[],
  state: GameState,
  rootPlayer: PlayerIndex,
  rankResidue: RankResidueSnapshot | null,
  sampleIndex: number,
): SampleHandBucket | undefined {
  const availableBuckets = buckets.filter(bucket => bucket.cards.length < bucket.assignment.handSize);
  if (availableBuckets.length === 0) return undefined;

  const rankedBuckets = availableBuckets
    .map(bucket => ({
      bucket,
      score: scoreSampleAssignmentCandidate(card, bucket.assignment, state, rootPlayer, rankResidue),
    }))
    .sort((left, right) => right.score - left.score);

  const topBucketCount = Math.min(2, rankedBuckets.length);
  const selectedIndex = topBucketCount === 1 ? 0 : sampleIndex % topBucketCount;
  return rankedBuckets[selectedIndex]?.bucket;
}

function buildStratifiedSampleBuckets(
  state: GameState,
  playerIdx: PlayerIndex,
  prioritizedUnseen: Card[],
  assignments: SampleHandAssignment[],
  rankResidue: RankResidueSnapshot | null,
  sampleIndex: number,
  rng: RandomSource,
  timing: SearchTimingContext,
): SampleHandBucket[] {
  const orderVariants = getAssignmentOrderVariants(assignments);
  const assignmentOrder = orderVariants[sampleIndex % orderVariants.length];
  const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] }));
  const bucketByPlayer = new Map(buckets.map(bucket => [bucket.assignment.playerIdx, bucket]));

  const focusedCards = prioritizedUnseen.slice(0, Math.min(MAX_FOCUSED_ASSIGNMENT_CARDS, prioritizedUnseen.length));
  const focusedCardIds = new Set(focusedCards.map(card => card.id));
  const remainingCards = shuffleArray(
    prioritizedUnseen.filter(card => !focusedCardIds.has(card.id)),
    rng,
  );

  for (let index = 0; index < focusedCards.length; index++) {
    timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS);
    const preferredBucket = selectSampleBucketForCard(
      focusedCards[index],
      buckets,
      state,
      playerIdx,
      rankResidue,
      sampleIndex + index,
    );
    if (preferredBucket) {
      preferredBucket.cards.push(focusedCards[index]);
    }
  }

  let remainingIndex = 0;
  for (const assignment of assignmentOrder) {
    const bucket = bucketByPlayer.get(assignment.playerIdx);
    if (!bucket) continue;
    while (bucket.cards.length < assignment.handSize && remainingIndex < remainingCards.length) {
      timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS);
      bucket.cards.push(remainingCards[remainingIndex]);
      remainingIndex++;
    }
  }

  return buckets;
}

function generateSamples(
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: CardTracker | undefined,
  count: number,
  rng: RandomSource,
  timing: SearchTimingContext,
): GameState[] {
  const myHand = state.players[playerIdx].hand;
  const unseen = tracker
    ? tracker.getUnseenCards(myHand, state.table)
    : getUnseenWithoutTracker(state, playerIdx);
  const assignments = createSampleHandAssignments(state, playerIdx);

  if (assignments.length === 0 || unseen.length === 0) {
    return [cloneState(state)];
  }

  const prioritizedUnseen = prioritizeUnseenCards(unseen, state.table);
  const rankResidue = getRankResidueSnapshot(tracker, myHand, state.table);
  const hiddenAssignmentCount = getHiddenAssignmentCount(prioritizedUnseen.length, assignments);
  if (
    prioritizedUnseen.length <= 8
    && hiddenAssignmentCount <= MAX_EXACT_SAMPLE_ASSIGNMENTS
  ) {
    return buildExactSampleStates(state, prioritizedUnseen, assignments, timing);
  }

  const samples: GameState[] = [];
  const seenAssignments = new Set<string>();
  const targetSamples = Math.max(1, count);
  const maxAttempts = targetSamples * 4;

  for (let attempt = 0; attempt < maxAttempts && samples.length < targetSamples; attempt++) {
    timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS);
    const sample = cloneState(state);
    const sampleBuckets = buildStratifiedSampleBuckets(
      state,
      playerIdx,
      prioritizedUnseen,
      assignments,
      rankResidue,
      attempt,
      rng,
      timing,
    );
    const sampleKey = buildSampleAssignmentKey(sampleBuckets);
    if (seenAssignments.has(sampleKey)) continue;

    seenAssignments.add(sampleKey);
    assignBucketsToSample(sample, sampleBuckets);
    samples.push(sample);
  }

  if (samples.length === 0) {
    timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS);
    const fallbackSample = cloneState(state);
    assignBucketsToSample(
      fallbackSample,
      buildStratifiedSampleBuckets(state, playerIdx, prioritizedUnseen, assignments, rankResidue, 0, rng, timing),
    );
    return [fallbackSample];
  }

  return samples;
}

function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] {
  return getUnseenCardsForEstimate(
    state,
    playerIdx,
    state.players[playerIdx].hand,
    state.table,
    undefined,
  );
}

function getUnseenCardsForEstimate(
  state: GameState,
  playerIdx: PlayerIndex,
  myHand: Card[],
  table: Card[],
  tracker: CardTracker | undefined,
): Card[] {
  if (tracker) {
    return tracker.getUnseenCards(myHand, table);
  }

  const known = new Set<string>();
  for (const card of myHand) known.add(card.id);
  for (const card of table) known.add(card.id);
  for (const player of state.players) {
    for (const card of player.pile) {
      known.add(card.id);
    }
  }

  const deck = buildDeck();
  return deck.filter(card => !known.has(card.id));
}

function shuffleArray<T>(arr: T[], rng: RandomSource = Math.random): T[] {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(rng() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

function alphaBeta(
  state: GameState, depth: number, alpha: number, beta: number,
  myTeam: 0 | 1, rootPlayer: PlayerIndex,
  phase: number, deadline: number,
  timing: SearchTimingContext,
  tracker: CardTracker | undefined,
  transpositionTable: Map<string, TranspositionEntry>,
  heuristics: SearchHeuristics,
  ply: number,
): number {
  const stateKey = buildSearchStateKey(state);

  if (depth === 0 || state.roundOver) {
    const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer);
    transpositionTable.set(stateKey, {
      key: stateKey,
      bestMove: null,
      bestMoveKey: null,
      depth,
      score,
      bound: 'exact',
    });
    return score;
  }

  timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS);
  if (timing.now() > deadline) {
    return evaluateFast(state, myTeam, phase, tracker, rootPlayer);
  }

  const originalAlpha = alpha;
  const originalBeta = beta;
  const ttEntry = transpositionTable.get(stateKey);
  if (ttEntry && ttEntry.depth >= depth) {
    if (ttEntry.bound === 'exact') {
      return ttEntry.score;
    }
    if (ttEntry.bound === 'lower') {
      alpha = Math.max(alpha, ttEntry.score);
    } else {
      beta = Math.min(beta, ttEntry.score);
    }
    if (alpha >= beta) {
      return ttEntry.score;
    }
  }

  const cur = state.currentPlayer;
  const isMyTeam = teamOf(cur) === myTeam;
  const moves = getLegalMoves(state, cur);

  if (moves.length === 0) {
    const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer);
    transpositionTable.set(stateKey, {
      key: stateKey,
      bestMove: null,
      bestMoveKey: null,
      depth,
      score,
      bound: 'exact',
    });
    return score;
  }

  const orderedMoves = orderSearchMoves(
    moves,
    state,
    cur,
    rootPlayer,
    tracker,
    ttEntry?.bound === 'exact' ? getValidHashMove(moves, ttEntry) : undefined,
    getValidHashMove(moves, ttEntry),
    heuristics,
    ply,
  );

  if (isMyTeam) {
    let value = -Infinity;
    let bestMove: AIMove | null = null;
    let isFirstMove = true;
    for (const move of orderedMoves) {
      const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
      const child = searchPrincipalVariationChild(
        result.nextState,
        depth - 1,
        alpha,
        beta,
        myTeam,
        rootPlayer,
        phase,
        deadline,
        timing,
        tracker,
        transpositionTable,
        heuristics,
        ply + 1,
        isFirstMove,
        true,
      );
      if (child > value) {
        value = child;
        bestMove = move;
      }
      alpha = Math.max(alpha, value);
      if (beta <= alpha) {
        recordQuietCutoff(heuristics, move, ply, depth);
        break;
      }
      isFirstMove = false;
    }

    transpositionTable.set(stateKey, {
      key: stateKey,
      bestMove,
      bestMoveKey: bestMove ? moveKey(bestMove) : null,
      depth,
      score: value,
      bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact',
    });
    return value;
  } else {
    let value = Infinity;
    let bestMove: AIMove | null = null;
    let isFirstMove = true;
    for (const move of orderedMoves) {
      const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
      const child = searchPrincipalVariationChild(
        result.nextState,
        depth - 1,
        alpha,
        beta,
        myTeam,
        rootPlayer,
        phase,
        deadline,
        timing,
        tracker,
        transpositionTable,
        heuristics,
        ply + 1,
        isFirstMove,
        false,
      );
      if (child < value) {
        value = child;
        bestMove = move;
      }
      beta = Math.min(beta, value);
      if (beta <= alpha) {
        recordQuietCutoff(heuristics, move, ply, depth);
        break;
      }
      isFirstMove = false;
    }

    transpositionTable.set(stateKey, {
      key: stateKey,
      bestMove,
      bestMoveKey: bestMove ? moveKey(bestMove) : null,
      depth,
      score: value,
      bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact',
    });
    return value;
  }
}

interface TeamEvaluationSnapshot {
  cards: number;
  denari: number;
  settebello: boolean;
  primiera: number;
  primieraSuits: number;
  sevenSuits: number;
  sevens: number;
  sixes: number;
  aces: number;
  scope: number;
  totalPoints: number;
}

function buildTeamEvaluationSnapshot(state: GameState, team: 0 | 1): TeamEvaluationSnapshot {
  const players = team === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
  const bestPrimieraBySuit: Partial<Record<Suit, number>> = {};
  const sevenSuits = new Set<Suit>();
  let cards = 0;
  let denari = 0;
  let settebello = false;
  let sevens = 0;
  let sixes = 0;
  let aces = 0;
  let scope = 0;

  for (const player of players) {
    scope += player.scope;
    for (const card of player.pile) {
      cards++;
      if (card.suit === 'denara') {
        denari++;
        if (card.value === 7) settebello = true;
      }
      if (card.value === 7) {
        sevens++;
        sevenSuits.add(card.suit);
      }
      if (card.value === 6) sixes++;
      if (card.value === 1) aces++;

      const primieraScore = PRIMIERA_VALUES[card.value] ?? 0;
      if ((bestPrimieraBySuit[card.suit] ?? 0) < primieraScore) {
        bestPrimieraBySuit[card.suit] = primieraScore;
      }
    }
  }

  let primiera = 0;
  let primieraSuits = 0;
  for (const suit of SUITS) {
    const suitScore = bestPrimieraBySuit[suit] ?? 0;
    if (suitScore > 0) {
      primiera += suitScore;
      primieraSuits++;
    }
  }

  return {
    cards,
    denari,
    settebello,
    primiera,
    primieraSuits,
    sevenSuits: sevenSuits.size,
    sevens,
    sixes,
    aces,
    scope,
    totalPoints: state.teamScores[team].totalPoints,
  };
}

function scoreMajorityRace(
  myValue: number,
  oppValue: number,
  target: number,
  unitWeight: number,
  thresholdBonus: number,
): number {
  let score = (myValue - oppValue) * unitWeight;

  if (myValue >= target && oppValue < target) {
    score += thresholdBonus;
  } else if (oppValue >= target && myValue < target) {
    score -= thresholdBonus;
  } else {
    const myDistance = Math.max(0, target - myValue);
    const oppDistance = Math.max(0, target - oppValue);
    score += (oppDistance - myDistance) * Math.round(unitWeight * 0.75);
  }

  return score;
}

function scoreCardsMajorityPosition(
  myCards: number,
  oppCards: number,
  phase: number,
): number {
  return scoreMajorityRace(myCards, oppCards, 21, Math.round(24 + phase * 22), 240);
}

function getRoundScoringCardWeight(card: Card): number {
  let weight = 18 + primieraVal(card) * 3;

  if (card.suit === 'denara') weight += 42;
  if (card.value === 7) weight += 44;
  if (card.suit === 'denara' && card.value === 7) weight += 120;

  return weight;
}

function scorePendingTableOwnership(state: GameState, perspectiveTeam: 0 | 1): number {
  if (state.table.length === 0 || state.lastCapturTeam === null) return 0;

  const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0);
  const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75;
  const tableValue = state.table.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0);

  return Math.round((state.lastCapturTeam === perspectiveTeam ? 1 : -1) * tableValue * urgency);
}

function scoreObjectiveTableExposure(state: GameState, perspectiveTeam: 0 | 1): number {
  if (state.table.length === 0) return 0;

  const nextTeamSign = teamOf(state.currentPlayer) === perspectiveTeam ? 1 : -1;
  const tableSum = sumCardValues(state.table);
  const exposedDenari = state.table.filter(card => card.suit === 'denara').length;
  const exposedSevens = state.table.filter(card => card.value === 7).length;
  const exposedSettebello = state.table.some(card => card.suit === 'denara' && card.value === 7);
  const shortTable = state.table.length <= 2 || tableSum <= 12;
  let pressure = exposedDenari * 34 + exposedSevens * 42;

  if (exposedSettebello) pressure += 120;
  if (shortTable) pressure += 36;

  return Math.round(nextTeamSign * pressure * (shortTable ? 1.2 : 0.8));
}

function scoreTableControlReserve(state: GameState, perspectiveTeam: 0 | 1): number {
  if (state.table.length < 4) return 0;

  const tableSum = sumCardValues(state.table);
  if (tableSum < 20) return 0;

  const nextTeam = teamOf(state.currentPlayer);
  const exposedDenari = state.table.filter(card => card.suit === 'denara').length;
  const exposedSevens = state.table.filter(card => card.value === 7).length;
  let reserve = state.table.length * 22 + tableSum * 3;

  reserve -= exposedDenari * 10;
  reserve -= exposedSevens * 12;
  if (state.table.length >= 5) reserve += 24;
  if (tableSum >= 24) reserve += 28;

  return Math.round((nextTeam === perspectiveTeam ? -0.35 : 0.55) * reserve);
}

function scoreKnownImmediateCapturePressure(state: GameState, playerIdx: PlayerIndex): number {
  if (state.table.length === 0 || state.players[playerIdx].hand.length === 0) return 0;

  let bestScore = 0;
  for (const move of getLegalMoves(state, playerIdx)) {
    if (move.capture.length === 0) continue;

    const captured = [move.card, ...move.capture];
    let moveScore = captured.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0);

    if (move.capture.length === state.table.length) {
      const isTerminalClear = state.players.every((player, index) => (
        index === playerIdx ? player.hand.length === 1 : player.hand.length === 0
      ));
      moveScore += isTerminalClear ? 90 : 240;
    }

    bestScore = Math.max(bestScore, moveScore);
  }

  return bestScore;
}

function getUpcomingTableExposureActors(state: GameState): Array<{ playerIdx: PlayerIndex; weight: number }> {
  const actors: Array<{ playerIdx: PlayerIndex; weight: number }> = [];
  let playerIdx = state.currentPlayer;

  for (const weight of UPCOMING_TABLE_EXPOSURE_WEIGHTS) {
    actors.push({ playerIdx, weight });
    playerIdx = nextPlayer(playerIdx);
  }

  return actors;
}

function scoreCaptureOpportunity(
  capturedCards: Card[],
  playedValue: number,
  tableSize: number,
): number {
  let score = capturedCards.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0);

  score += 18 + (PRIMIERA_VALUES[playedValue] ?? 0) * 2;
  if (playedValue === 7) score += 48;
  if (capturedCards.some(card => card.suit === 'denara')) score += 24;
  if (capturedCards.some(card => card.value === 7)) score += 32;
  if (capturedCards.some(card => card.suit === 'denara' && card.value === 7)) score += 160;

  if (capturedCards.length === tableSize) {
    score += tableSize <= 3 ? 220 : 280;
  }

  return score;
}

function scoreProbableImmediateCapturePressure(
  state: GameState,
  playerIdx: PlayerIndex,
  rootPlayer: PlayerIndex,
  tracker: CardTracker | undefined,
): number {
  if (state.table.length === 0) return 0;

  const handSize = state.players[playerIdx].hand.length;
  if (handSize <= 0) return 0;

  const observerHand = state.players[rootPlayer].hand;
  let bestScore = 0;

  for (let value = 1; value <= 10; value++) {
    const representativeCard = REPRESENTATIVE_CARD_BY_VALUE.get(value);
    if (!representativeCard) continue;

    const captures = findCaptures(representativeCard, state.table);
    if (captures.length === 0) continue;

    const probability = handLikelyHasValue(
      value,
      handSize,
      state,
      rootPlayer,
      tracker,
      observerHand,
      state.table,
    );
    if (probability <= 0) continue;

    let bestCaptureScore = 0;
    for (const capture of captures) {
      bestCaptureScore = Math.max(
        bestCaptureScore,
        scoreCaptureOpportunity(capture, value, state.table.length),
      );
    }

    bestScore = Math.max(bestScore, Math.round(probability * bestCaptureScore));
  }

  return bestScore;
}

function scoreKnownTableExposure(state: GameState, perspectiveTeam: 0 | 1): number {
  if (state.table.length === 0) return 0;

  let score = 0;
  for (const actor of getUpcomingTableExposureActors(state)) {
    const capturePressure = scoreKnownImmediateCapturePressure(state, actor.playerIdx);
    if (capturePressure === 0) continue;

    score += Math.round(
      capturePressure
      * actor.weight
      * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1),
    );
  }

  return score;
}

function scoreProbableTableExposure(
  state: GameState,
  perspectiveTeam: 0 | 1,
  rootPlayer: PlayerIndex,
  tracker: CardTracker | undefined,
): number {
  if (state.table.length === 0) return 0;

  let score = 0;
  for (const actor of getUpcomingTableExposureActors(state)) {
    const actorScore = scoreProbableImmediateCapturePressure(state, actor.playerIdx, rootPlayer, tracker);
    if (actorScore === 0) continue;

    score += Math.round(
      actorScore
      * actor.weight
      * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1),
    );
  }

  return score;
}

function scoreRootOpeningAnchorState(
  state: GameState,
  perspectiveTeam: 0 | 1,
  rootPlayer: PlayerIndex,
): number {
  if (
    teamOf(rootPlayer) !== perspectiveTeam
    || state.table.length !== 1
    || teamOf(state.currentPlayer) === perspectiveTeam
  ) {
    return 0;
  }

  const exposedCard = state.table[0];
  const rootHand = state.players[rootPlayer].hand;
  const sameValueAnchors = countValueInHand(rootHand, exposedCard.value);
  let score = 0;

  if (sameValueAnchors > 0) {
    score += exposedCard.value >= 8 ? 240 : 88;
    if (exposedCard.suit !== 'denara') score += 36;
  }

  if (sameValueAnchors === 0 && exposedCard.value <= 3) score -= 220;
  if (exposedCard.suit === 'denara') score -= 120;
  if (exposedCard.value === 7) score -= 140;

  return score;
}

function evaluateTeamPosition(
  state: GameState,
  perspectiveTeam: 0 | 1,
  _phase: number,
  tracker: CardTracker | undefined,
  rootPlayer: PlayerIndex,
  allowHiddenHands: boolean,
): number {
  const opponentTeam = perspectiveTeam === 0 ? 1 : 0;
  const mine = buildTeamEvaluationSnapshot(state, perspectiveTeam);
  const opp = buildTeamEvaluationSnapshot(state, opponentTeam);
  const phase = gamePhase(state);
  const matchWeight = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 360 : 260;
  const matchPointCardsPressure = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 3.2 : 1;

  let score = 0;

  score += (mine.totalPoints - opp.totalPoints) * Math.round(matchWeight + phase * 40);
  if (mine.totalPoints >= 10 && opp.totalPoints < 10) score += 260;
  if (opp.totalPoints >= 10 && mine.totalPoints < 10) score -= 260;

  score += Math.round(scoreCardsMajorityPosition(mine.cards, opp.cards, phase) * matchPointCardsPressure);
  score += scoreMajorityRace(mine.denari, opp.denari, 6, Math.round(70 + phase * 22), 220);

  if (mine.settebello) score += 420;
  if (opp.settebello) score -= 420;

  score += (mine.scope - opp.scope) * 390;

  score += (mine.primiera - opp.primiera) * Math.round(4.5 + phase * 3);
  score += (mine.primieraSuits - opp.primieraSuits) * 124;
  if (mine.primieraSuits === 4 && opp.primieraSuits < 4) score += 180;
  if (opp.primieraSuits === 4 && mine.primieraSuits < 4) score -= 180;
  score += (mine.sevenSuits - opp.sevenSuits) * 92;
  score += (mine.sevens - opp.sevens) * 68;
  score += (mine.sixes - opp.sixes) * 16;
  score += (mine.aces - opp.aces) * 12;

  score += scorePendingTableOwnership(state, perspectiveTeam);
  score += scoreRootOpeningAnchorState(state, perspectiveTeam, rootPlayer);
  score += scoreObjectiveTableExposure(state, perspectiveTeam);
  score += scoreTableControlReserve(state, perspectiveTeam);
  if ((mine.totalPoints >= 9 || opp.totalPoints >= 9) && state.table.length <= 2) {
    score += Math.max(0, mine.cards - opp.cards) * 32;
    score -= Math.max(0, opp.cards - mine.cards) * 32;
  }
  if (allowHiddenHands) {
    score += scoreCurrentPlayerVisibleTempo(state, perspectiveTeam);
  }
  score += allowHiddenHands
    ? scoreKnownTableExposure(state, perspectiveTeam)
    : scoreProbableTableExposure(state, perspectiveTeam, rootPlayer, tracker);

  return score;
}

/** Fast evaluation: avoids flatMap/filter at every leaf node */
function evaluateFast(
  state: GameState,
  myTeam: 0 | 1,
  _phase: number,
  tracker: CardTracker | undefined,
  rootPlayer: PlayerIndex,
): number {
  return evaluateTeamPosition(state, myTeam, 0, tracker, rootPlayer, true);
}

/**
 * PIMC evaluation: full-information evaluation for use in determinized PIMC search.
 * Enables scoreCurrentPlayerVisibleTempo and scoreKnownTableExposure which require
 * all hands to be visible — satisfied in a determinized PIMC state.
 * Exported so the new PIMC engine can use the same evaluation quality.
 */
export function evaluateTeamPositionPIMC(state: GameState, perspectiveTeam: 0 | 1, rootPlayer?: PlayerIndex): number {
  return evaluateTeamPosition(state, perspectiveTeam, 0, undefined, rootPlayer ?? state.currentPlayer, true);
}

/**
 * Full-quality move score for use in PIMC move ordering (root and interior nodes).
 * Equivalent to the legacy's internal quickEval: position eval + scoreMoveObjectiveBias.
 * Use this for 1-ply ordering in determinized states where all hands are visible.
 *
 * @param playerIdx  The player whose turn it is at this node.
 * @param rootPlayer The player at the root of the search (perspective stays fixed).
 */
export function quickEvalRootMovePIMC(
  move: AIMove,
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: import('./card-tracker').CardTracker | undefined,
  rootPlayer?: PlayerIndex,
): number {
  return quickEval(move, state, playerIdx, rootPlayer ?? playerIdx, tracker, true);
}

/**
 * Generate hand samples using the legacy's stratified bucketing algorithm.
 * Much more accurate than random inference sampling: assigns high-value cards
 * (7s, denari) to the most likely holders using rank-residue analysis.
 *
 * @param count  Number of samples to generate (legacy target).
 */
export function generateSamplesForPIMC(
  state: GameState,
  playerIdx: PlayerIndex,
  tracker: import('./card-tracker').CardTracker | undefined,
  count: number,
  rng: RandomSource,
  timingSource?: AITimingSource,
): GameState[] {
  const timing = createSearchTimingContext(timingSource);
  return generateSamples(state, playerIdx, tracker, count, rng, timing);
}
