import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { AgentsService } from '../agent/agents.service';
import { Decimal } from '@prisma/client/runtime/library';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
import { generateBatchNo } from '../../shared/common/decorators';
import {
  settleSelection,
  calculatePayout,
  calculateParlayPayout,
  ScoreInput,
  MatchStatsInput,
  SelectionResult,
} from './domain/settlement-calculator';
import {
  resolveSelectionCode,
  templateScoresForMarket,
} from './domain/settlement-helpers';
import { isSettlementSupportedMarketType } from '@thebet365/shared';

const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
const STAT_MARKET_REQUIREMENTS = {
  FT_CORNERS_HANDICAP: ['homeCorners', 'awayCorners'],
  FT_CORNERS_OVER_UNDER: ['homeCorners', 'awayCorners'],
  FT_CARDS_OVER_UNDER: ['homeCards', 'awayCards'],
} as const satisfies Record<string, readonly (keyof MatchStatsInput)[]>;

type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
type SettlementScoreSource = ScoreInput & MatchStatsInput & { winnerTeamId?: bigint | null };

type BetSelectionLeg = {
  id: bigint;
  matchId: bigint | null;
  marketType: string;
  selectionId: bigint;
  selectionNameSnapshot: string;
  handicapLine: Decimal | null;
  totalLine: Decimal | null;
  odds: Decimal;
  resultStatus: string | null;
  sortOrder: number;
};

@Injectable()
export class SettlementService {
  constructor(
    private prisma: PrismaService,
    private funds: FundsPostingService,
    private agents: AgentsService,
  ) {}

  private async resolveWinnerTeamCode(
    winnerTeamId: bigint | null | undefined,
    tx?: TxClient,
  ): Promise<string | null> {
    if (!winnerTeamId) return null;
    const client: PrismaClientLike = tx ?? this.prisma;
    const team = await client.team.findUnique({ where: { id: winnerTeamId } });
    return team?.code ?? null;
  }

  private async assertOutrightLeagueFixturesSettled(match: {
    leagueId: bigint;
    isOutright: boolean;
  }) {
    if (!match.isOutright) return;
    const unsettled = await this.prisma.match.count({
      where: {
        leagueId: match.leagueId,
        isOutright: false,
        deletedAt: null,
        status: { notIn: ['SETTLED', 'CANCELLED'] },
      },
    });
    if (unsettled > 0) {
      throw appBadRequest('OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED');
    }
  }

  private buildSettleInput(
    sel: BetSelectionLeg,
    selectionCode: string,
    scoreInput: ScoreInput,
    statsInput: MatchStatsInput,
    winnerTeamCode: string | null,
  ) {
    return {
      marketType: sel.marketType,
      selectionCode,
      handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null,
      totalLine: sel.totalLine != null ? Number(sel.totalLine) : null,
      score: scoreInput,
      stats: statsInput,
      templateScores: templateScoresForMarket(sel.marketType),
      winnerTeamCode,
    };
  }

  private settleLegResult(
    sel: BetSelectionLeg,
    selectionCode: string,
    scoreInput: ScoreInput,
    statsInput: MatchStatsInput,
    winnerTeamCode: string | null,
  ): SelectionResult {
    return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, statsInput, winnerTeamCode));
  }

  private cardTotalFromBreakdown(
    total: number | null | undefined,
    yellow: number | null | undefined,
    red: number | null | undefined,
  ): number | null {
    const hasBreakdown = yellow != null || red != null;
    if (!hasBreakdown) return total ?? null;
    if (yellow == null || red == null) return null;
    return yellow + red;
  }

  private statsInputFromSource(source: MatchStatsInput): MatchStatsInput {
    const homeYellowCards = source.homeYellowCards ?? null;
    const awayYellowCards = source.awayYellowCards ?? null;
    const homeRedCards = source.homeRedCards ?? null;
    const awayRedCards = source.awayRedCards ?? null;
    return {
      homeCorners: source.homeCorners ?? null,
      awayCorners: source.awayCorners ?? null,
      homeYellowCards,
      awayYellowCards,
      homeRedCards,
      awayRedCards,
      homeCards: this.cardTotalFromBreakdown(source.homeCards, homeYellowCards, homeRedCards),
      awayCards: this.cardTotalFromBreakdown(source.awayCards, awayYellowCards, awayRedCards),
    };
  }

  private collectRequiredStatFields(marketTypes: Iterable<string>) {
    const fields = new Set<keyof MatchStatsInput>();
    for (const marketType of marketTypes) {
      const required = STAT_MARKET_REQUIREMENTS[marketType as keyof typeof STAT_MARKET_REQUIREMENTS];
      if (!required) continue;
      for (const field of required) fields.add(field);
    }
    return fields;
  }

  private async assertRequiredStatsAvailable(
    matchId: bigint,
    statsInput: MatchStatsInput,
    betMarketTypes: Iterable<string>,
    client?: PrismaClientLike,
  ) {
    const db = client ?? this.prisma;
    const visibleMarkets = await db.market.findMany({
      where: { matchId, showOnPlayer: true },
      select: { marketType: true },
    });
    const required = this.collectRequiredStatFields([
      ...visibleMarkets.map((market) => market.marketType),
      ...betMarketTypes,
    ]);
    const missing = [...required].filter((field) => statsInput[field] == null);
    if (missing.length) {
      throw appBadRequest('SETTLEMENT_FACTS_REQUIRED', {
        fields: missing.join(','),
      });
    }
  }

  private walletResultFromSelection(
    result: SelectionResult,
  ): 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE' {
    if (result === 'HALF_WIN') return 'HALF_WIN';
    if (result === 'HALF_LOSE') return 'HALF_LOSE';
    return result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID';
  }

  private betStatusFromSelection(result: SelectionResult): 'LOST' | 'PUSH' | 'WON' {
    if (result === 'LOSE') return 'LOST';
    if (result === 'PUSH' || result === 'VOID') return 'PUSH';
    return 'WON';
  }

  private assertMatchClosedForSettlement(status: string) {
    if (!SETTLEMENT_ENTRY_STATUSES.has(status)) {
      throw appBadRequest('MATCH_MUST_CLOSE_FOR_SETTLEMENT');
    }
  }

  private assertScoreConsistency(score: ScoreInput, isOutright: boolean) {
    if (isOutright) return;
    if (
      score.htHome < 0 ||
      score.htAway < 0 ||
      score.ftHome < 0 ||
      score.ftAway < 0 ||
      score.htHome > score.ftHome ||
      score.htAway > score.ftAway
    ) {
      throw appBadRequest('SETTLEMENT_SCORE_INVALID');
    }
  }

  private assertSupportedMarketTypes(
    bets: Array<{ selections: Array<{ marketType: string }> }>,
  ) {
    for (const bet of bets) {
      for (const sel of bet.selections) {
        if (!isSettlementSupportedMarketType(sel.marketType)) {
          throw appBadRequest('SETTLEMENT_MARKET_UNSUPPORTED', {
            marketType: sel.marketType,
          });
        }
      }
    }
  }

  private assertOutrightWinnerForBets(
    bets: Array<{ selections: Array<{ marketType: string }> }>,
    winnerTeamCode: string | null,
    isOutright: boolean,
  ) {
    const hasOutrightLeg =
      isOutright ||
      bets.some((bet) =>
        bet.selections.some((sel) => sel.marketType === 'OUTRIGHT_WINNER'),
      );
    if (hasOutrightLeg && !winnerTeamCode) {
      throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
    }
  }

  private requireBetUpdate(updated: { count: number }, betNo: string) {
    if (updated.count !== 1) {
      throw appBadRequest('SETTLEMENT_BET_UPDATE_FAILED', { betNo });
    }
  }

  private async assertLatestPreviewBatch(
    batchId: bigint,
    matchId: bigint,
    tx?: TxClient,
  ) {
    const client: PrismaClientLike = tx ?? this.prisma;
    const latest = await client.settlementBatch.findFirst({
      where: { matchId, status: 'PREVIEW', isResettle: false },
      orderBy: { createdAt: 'desc' },
      select: { id: true },
    });
    if (latest && latest.id !== batchId) {
      throw appBadRequest('SETTLEMENT_BATCH_STALE', { batchId: batchId.toString() });
    }
  }

  private async cancelStalePreviewBatches(matchId: bigint, tx?: TxClient) {
    const client: PrismaClientLike = tx ?? this.prisma;
    await client.settlementBatch.updateMany({
      where: { matchId, status: 'PREVIEW', isResettle: false },
      data: { status: 'CANCELLED' },
    });
  }

  private parlayBetStatusFromResult(
    parlayResult: ReturnType<typeof calculateParlayPayout>,
  ): 'LOST' | 'PUSH' | 'WON' {
    if (parlayResult.betResult === 'LOST') return 'LOST';
    if (parlayResult.betResult === 'PUSH') return 'PUSH';
    return 'WON';
  }

  private walletResultFromParlayBetResult(
    betResult: 'WON' | 'LOST' | 'PUSH',
  ): 'WIN' | 'LOSE' | 'PUSH' {
    if (betResult === 'LOST') return 'LOSE';
    if (betResult === 'PUSH') return 'PUSH';
    return 'WIN';
  }

  async recordScore(
    matchId: bigint,
    htHome: number,
    htAway: number,
    ftHome: number,
    ftAway: number,
    operatorId: bigint,
    winnerTeamId?: bigint,
    statsInput: MatchStatsInput = {},
  ) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
    });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    this.assertMatchClosedForSettlement(match.status);

    if (match.isOutright) {
      await this.assertOutrightLeagueFixturesSettled(match);
    }

    if (match.isOutright) {
      if (!winnerTeamId) {
        throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
      }
      const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
      if (!team) throw appBadRequest('SETTLEMENT_WINNER_NOT_FOUND');
      const outrightSel = await this.prisma.marketSelection.findFirst({
        where: {
          market: { matchId, marketType: 'OUTRIGHT_WINNER' },
          selectionCode: team.code,
        },
      });
      if (!outrightSel) {
        throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET');
      }
    }

    if (!match.isOutright) {
      this.assertScoreConsistency(
        { htHome, htAway, ftHome, ftAway },
        match.isOutright,
      );
    }

    const stats = this.statsInputFromSource(statsInput);
    await this.prisma.matchScore.upsert({
      where: { matchId },
      create: {
        matchId,
        htHomeScore: match.isOutright ? 0 : htHome,
        htAwayScore: match.isOutright ? 0 : htAway,
        ftHomeScore: match.isOutright ? 0 : ftHome,
        ftAwayScore: match.isOutright ? 0 : ftAway,
        homeCorners: match.isOutright ? null : stats.homeCorners,
        awayCorners: match.isOutright ? null : stats.awayCorners,
        homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
        awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
        homeRedCards: match.isOutright ? null : stats.homeRedCards,
        awayRedCards: match.isOutright ? null : stats.awayRedCards,
        homeCards: match.isOutright ? null : stats.homeCards,
        awayCards: match.isOutright ? null : stats.awayCards,
        winnerTeamId: match.isOutright ? winnerTeamId : null,
        recordedBy: operatorId,
      },
      update: {
        htHomeScore: match.isOutright ? 0 : htHome,
        htAwayScore: match.isOutright ? 0 : htAway,
        ftHomeScore: match.isOutright ? 0 : ftHome,
        ftAwayScore: match.isOutright ? 0 : ftAway,
        homeCorners: match.isOutright ? null : stats.homeCorners,
        awayCorners: match.isOutright ? null : stats.awayCorners,
        homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
        awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
        homeRedCards: match.isOutright ? null : stats.homeRedCards,
        awayRedCards: match.isOutright ? null : stats.awayRedCards,
        homeCards: match.isOutright ? null : stats.homeCards,
        awayCards: match.isOutright ? null : stats.awayCards,
        winnerTeamId: match.isOutright ? winnerTeamId : null,
        recordedBy: operatorId,
      },
    });

    await this.prisma.match.update({
      where: { id: matchId },
      data: { status: 'PENDING_SETTLEMENT' },
    });

    return {
      matchId,
      htHome,
      htAway,
      ftHome,
      ftAway,
      ...stats,
      winnerTeamId: winnerTeamId?.toString() ?? null,
    };
  }

  async previewSettlement(
    matchId: bigint,
    operatorId: bigint,
    opts?: {
      page?: number;
      pageSize?: number;
      htHome?: number;
      htAway?: number;
      ftHome?: number;
      ftAway?: number;
      homeCorners?: number | null;
      awayCorners?: number | null;
      homeYellowCards?: number | null;
      awayYellowCards?: number | null;
      homeRedCards?: number | null;
      awayRedCards?: number | null;
      homeCards?: number | null;
      awayCards?: number | null;
      winnerTeamId?: bigint;
    },
  ) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
    });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    this.assertMatchClosedForSettlement(match.status);

    if (match.isOutright) {
      await this.assertOutrightLeagueFixturesSettled(match);
    }

    const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
    if (!match.isOutright) {
      this.assertScoreConsistency(
        {
          htHome: scoreSource.htHome,
          htAway: scoreSource.htAway,
          ftHome: scoreSource.ftHome,
          ftAway: scoreSource.ftAway,
        },
        match.isOutright,
      );
    }
    const computation = await this.computePreviewComputation(matchId, scoreSource);
    await this.cancelStalePreviewBatches(matchId);
    const batch = await this.prisma.settlementBatch.create({
      data: {
        matchId,
        batchNo: generateBatchNo('STL'),
        htHomeScore: scoreSource.htHome,
        htAwayScore: scoreSource.htAway,
        ftHomeScore: scoreSource.ftHome,
        ftAwayScore: scoreSource.ftAway,
        homeCorners: scoreSource.homeCorners ?? null,
        awayCorners: scoreSource.awayCorners ?? null,
        homeYellowCards: scoreSource.homeYellowCards ?? null,
        awayYellowCards: scoreSource.awayYellowCards ?? null,
        homeRedCards: scoreSource.homeRedCards ?? null,
        awayRedCards: scoreSource.awayRedCards ?? null,
        homeCards: scoreSource.homeCards ?? null,
        awayCards: scoreSource.awayCards ?? null,
        status: 'PREVIEW',
        totalBets: computation.pendingBets.length,
        totalPayout: computation.totalPayout,
        totalRefund: computation.totalRefund,
        operatorId,
      },
    });

    if (match.isOutright && scoreSource.winnerTeamId) {
      await this.upsertMatchScoreRecord(matchId, scoreSource, operatorId);
    }

    if (match.status !== 'PENDING_SETTLEMENT' && match.status !== 'SETTLED') {
      await this.prisma.match.update({
        where: { id: matchId },
        data: { status: 'PENDING_SETTLEMENT' },
      });
    }

    return this.buildPreviewResponse(computation, batch, opts);
  }

  async getPreviewSettlementItems(
    batchId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId } });
    if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
    if (batch.status !== 'PREVIEW') {
      throw appBadRequest('SETTLEMENT_BATCH_NOT_PREVIEW');
    }

    const existingScore = await this.prisma.matchScore.findUnique({
      where: { matchId: batch.matchId },
    });
    const computation = await this.computePreviewComputation(batch.matchId, {
      htHome: batch.htHomeScore ?? 0,
      htAway: batch.htAwayScore ?? 0,
      ftHome: batch.ftHomeScore ?? 0,
      ftAway: batch.ftAwayScore ?? 0,
      homeCorners: batch.homeCorners,
      awayCorners: batch.awayCorners,
      homeYellowCards: batch.homeYellowCards,
      awayYellowCards: batch.awayYellowCards,
      homeRedCards: batch.homeRedCards,
      awayRedCards: batch.awayRedCards,
      homeCards: batch.homeCards,
      awayCards: batch.awayCards,
      winnerTeamId: existingScore?.winnerTeamId ?? null,
    });
    const itemsPage = this.paginatePreviewItems(computation.items, opts);
    return {
      items: itemsPage.items.map((item) => this.serializePreviewItem(item)),
      total: itemsPage.total,
      page: itemsPage.page,
      pageSize: itemsPage.pageSize,
    };
  }

  async getActivePreview(
    matchId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    const batch = await this.prisma.settlementBatch.findFirst({
      where: { matchId, status: 'PREVIEW' },
      orderBy: { createdAt: 'desc' },
    });
    if (!batch) return null;

    const existingScore = await this.prisma.matchScore.findUnique({
      where: { matchId },
    });
    const computation = await this.computePreviewComputation(matchId, {
      htHome: batch.htHomeScore ?? 0,
      htAway: batch.htAwayScore ?? 0,
      ftHome: batch.ftHomeScore ?? 0,
      ftAway: batch.ftAwayScore ?? 0,
      homeCorners: batch.homeCorners ?? null,
      awayCorners: batch.awayCorners ?? null,
      homeYellowCards: batch.homeYellowCards ?? null,
      awayYellowCards: batch.awayYellowCards ?? null,
      homeRedCards: batch.homeRedCards ?? null,
      awayRedCards: batch.awayRedCards ?? null,
      homeCards: batch.homeCards ?? null,
      awayCards: batch.awayCards ?? null,
      winnerTeamId: existingScore?.winnerTeamId ?? null,
    });

    return this.buildPreviewResponse(computation, batch, opts);
  }

  async getMatchSettlementHistory(matchId: bigint) {
    const batches = await this.prisma.settlementBatch.findMany({
      where: { matchId, status: 'CONFIRMED' },
      orderBy: { confirmedAt: 'desc' },
    });

    const operatorIds = batches
      .map((b) => b.operatorId)
      .filter((id): id is bigint => id !== null);

    const operators =
      operatorIds.length > 0
        ? await this.prisma.user.findMany({
            where: { id: { in: operatorIds } },
            select: { id: true, username: true },
          })
        : [];

    const operatorMap = new Map(operators.map((o) => [o.id.toString(), o.username]));

    return batches.map((b) => ({
      id: b.id.toString(),
      batchNo: b.batchNo,
      htHomeScore: b.htHomeScore,
      htAwayScore: b.htAwayScore,
      ftHomeScore: b.ftHomeScore,
      ftAwayScore: b.ftAwayScore,
      homeCorners: b.homeCorners,
      awayCorners: b.awayCorners,
      homeYellowCards: b.homeYellowCards,
      awayYellowCards: b.awayYellowCards,
      homeRedCards: b.homeRedCards,
      awayRedCards: b.awayRedCards,
      homeCards: b.homeCards,
      awayCards: b.awayCards,
      totalBets: b.totalBets,
      totalPayout: b.totalPayout.toString(),
      totalRefund: b.totalRefund.toString(),
      confirmedAt: b.confirmedAt?.toISOString() ?? null,
      isResettle: b.isResettle,
      reason: b.reason,
      operatorUsername: b.operatorId ? operatorMap.get(b.operatorId.toString()) ?? '—' : '—',
    }));
  }

  private buildPreviewResponse(
    computation: {
      scoreInput: ScoreInput;
      winnerTeamCode: string | null;
      pendingBets: Array<{ betType: string }>;
      items: Array<{
        betId: bigint;
        betNo: string;
        betType: string;
        result: string;
        payout: Decimal;
        note?: string;
      }>;
      totalPayout: Decimal;
      totalRefund: Decimal;
      lostOnThisMatch: number;
      pendingOtherMatches: number;
      wonLegsOnMatch: number;
    },
    batch: { id: bigint },
    opts?: { page?: number; pageSize?: number },
  ) {
    const { pendingBets } = computation;
    const itemsPage = this.paginatePreviewItems(computation.items, opts);

    return {
      batch,
      score: computation.scoreInput,
      winnerTeamCode: computation.winnerTeamCode,
      pendingBetCount: pendingBets.length,
      singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length,
      parlayBetCount: pendingBets.filter((b) => b.betType === 'PARLAY').length,
      lostOnThisMatch: computation.lostOnThisMatch,
      pendingOtherMatches: computation.pendingOtherMatches,
      wonLegsOnMatch: computation.wonLegsOnMatch,
      items: {
        items: itemsPage.items.map((item) => this.serializePreviewItem(item)),
        total: itemsPage.total,
        page: itemsPage.page,
        pageSize: itemsPage.pageSize,
      },
      totalPayout: computation.totalPayout.toString(),
      totalRefund: computation.totalRefund.toString(),
    };
  }

  private serializePreviewItem(item: {
    betId: bigint;
    betNo: string;
    betType: string;
    result: string;
    payout: Decimal;
    note?: string;
  }) {
    return {
      betId: item.betId.toString(),
      betNo: item.betNo,
      betType: item.betType,
      result: item.result,
      payout: item.payout.toString(),
      note: item.note,
    };
  }

  private paginatePreviewItems<T>(all: T[], opts?: { page?: number; pageSize?: number }) {
    const page = Math.max(1, opts?.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
    const total = all.length;
    const start = (page - 1) * pageSize;
    return {
      items: all.slice(start, start + pageSize),
      total,
      page,
      pageSize,
    };
  }

  private async upsertMatchScoreRecord(
    matchId: bigint,
    scoreSource: {
      htHome: number;
      htAway: number;
      ftHome: number;
      ftAway: number;
      homeCorners?: number | null;
      awayCorners?: number | null;
      homeYellowCards?: number | null;
      awayYellowCards?: number | null;
      homeRedCards?: number | null;
      awayRedCards?: number | null;
      homeCards?: number | null;
      awayCards?: number | null;
      winnerTeamId?: bigint | null;
    },
    operatorId: bigint,
    tx?: Parameters<Parameters<PrismaService['$transaction']>[0]>[0],
  ) {
    const client = tx ?? this.prisma;
    const stats = this.statsInputFromSource(scoreSource);
    await client.matchScore.upsert({
      where: { matchId },
      create: {
        matchId,
        htHomeScore: scoreSource.htHome,
        htAwayScore: scoreSource.htAway,
        ftHomeScore: scoreSource.ftHome,
        ftAwayScore: scoreSource.ftAway,
        homeCorners: stats.homeCorners,
        awayCorners: stats.awayCorners,
        homeYellowCards: stats.homeYellowCards,
        awayYellowCards: stats.awayYellowCards,
        homeRedCards: stats.homeRedCards,
        awayRedCards: stats.awayRedCards,
        homeCards: stats.homeCards,
        awayCards: stats.awayCards,
        winnerTeamId: scoreSource.winnerTeamId ?? null,
        recordedBy: operatorId,
      },
      update: {
        htHomeScore: scoreSource.htHome,
        htAwayScore: scoreSource.htAway,
        ftHomeScore: scoreSource.ftHome,
        ftAwayScore: scoreSource.ftAway,
        homeCorners: stats.homeCorners,
        awayCorners: stats.awayCorners,
        homeYellowCards: stats.homeYellowCards,
        awayYellowCards: stats.awayYellowCards,
        homeRedCards: stats.homeRedCards,
        awayRedCards: stats.awayRedCards,
        homeCards: stats.homeCards,
        awayCards: stats.awayCards,
        winnerTeamId: scoreSource.winnerTeamId ?? null,
        recordedBy: operatorId,
      },
    });
  }

  private async resolvePreviewScoreSource(
    matchId: bigint,
    isOutright: boolean,
    opts?: {
      htHome?: number;
      htAway?: number;
      ftHome?: number;
      ftAway?: number;
      homeCorners?: number | null;
      awayCorners?: number | null;
      homeYellowCards?: number | null;
      awayYellowCards?: number | null;
      homeRedCards?: number | null;
      awayRedCards?: number | null;
      homeCards?: number | null;
      awayCards?: number | null;
      winnerTeamId?: bigint;
    },
  ) {
    const hasRequestScore =
      opts?.htHome !== undefined ||
      opts?.htAway !== undefined ||
      opts?.ftHome !== undefined ||
      opts?.ftAway !== undefined ||
      opts?.homeCorners !== undefined ||
      opts?.awayCorners !== undefined ||
      opts?.homeYellowCards !== undefined ||
      opts?.awayYellowCards !== undefined ||
      opts?.homeRedCards !== undefined ||
      opts?.awayRedCards !== undefined ||
      opts?.homeCards !== undefined ||
      opts?.awayCards !== undefined ||
      opts?.winnerTeamId !== undefined;

    if (hasRequestScore) {
      if (isOutright) {
        if (!opts?.winnerTeamId) throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
        const team = await this.prisma.team.findUnique({ where: { id: opts.winnerTeamId } });
        if (!team) throw appBadRequest('SETTLEMENT_WINNER_NOT_FOUND');
        const outrightSel = await this.prisma.marketSelection.findFirst({
          where: {
            market: { matchId, marketType: 'OUTRIGHT_WINNER' },
            selectionCode: team.code,
          },
        });
        if (!outrightSel) throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET');
      }
      const stats = this.statsInputFromSource({
        homeCorners: opts?.homeCorners ?? null,
        awayCorners: opts?.awayCorners ?? null,
        homeYellowCards: opts?.homeYellowCards ?? null,
        awayYellowCards: opts?.awayYellowCards ?? null,
        homeRedCards: opts?.homeRedCards ?? null,
        awayRedCards: opts?.awayRedCards ?? null,
        homeCards: opts?.homeCards ?? null,
        awayCards: opts?.awayCards ?? null,
      });
      return {
        htHome: opts?.htHome ?? 0,
        htAway: opts?.htAway ?? 0,
        ftHome: opts?.ftHome ?? 0,
        ftAway: opts?.ftAway ?? 0,
        ...stats,
        winnerTeamId: isOutright ? (opts?.winnerTeamId ?? null) : null,
      };
    }

    const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
    if (!score) throw appBadRequest('SCORE_NOT_RECORDED');

    return {
      htHome: score.htHomeScore ?? 0,
      htAway: score.htAwayScore ?? 0,
      ftHome: score.ftHomeScore ?? 0,
      ftAway: score.ftAwayScore ?? 0,
      homeCorners: score.homeCorners,
      awayCorners: score.awayCorners,
      homeYellowCards: score.homeYellowCards,
      awayYellowCards: score.awayYellowCards,
      homeRedCards: score.homeRedCards,
      awayRedCards: score.awayRedCards,
      homeCards: score.homeCards,
      awayCards: score.awayCards,
      winnerTeamId: score.winnerTeamId,
    };
  }

  private async computePreviewComputation(
    matchId: bigint,
    scoreSource?: SettlementScoreSource,
  ) {
    let scoreInput: ScoreInput;
    let statsInput: MatchStatsInput;
    let winnerTeamCode: string | null;

    if (scoreSource) {
      scoreInput = {
        htHome: scoreSource.htHome,
        htAway: scoreSource.htAway,
        ftHome: scoreSource.ftHome,
        ftAway: scoreSource.ftAway,
      };
      statsInput = this.statsInputFromSource(scoreSource);
      winnerTeamCode = await this.resolveWinnerTeamCode(scoreSource.winnerTeamId ?? null);
    } else {
      const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
      if (!score) throw appBadRequest('SCORE_NOT_RECORDED');
      scoreInput = {
        htHome: score.htHomeScore ?? 0,
        htAway: score.htAwayScore ?? 0,
        ftHome: score.ftHomeScore ?? 0,
        ftAway: score.ftAwayScore ?? 0,
      };
      statsInput = this.statsInputFromSource(score);
      winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
    }

    const pendingBets = await this.prisma.bet.findMany({
      where: {
        status: 'PENDING',
        selections: { some: { matchId } },
      },
      include: { selections: { orderBy: { sortOrder: 'asc' } } },
    });

    const selectionCodes = await this.loadSelectionCodes(pendingBets);
    await this.assertRequiredStatsAvailable(
      matchId,
      statsInput,
      pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
    );
    this.assertSupportedMarketTypes(pendingBets);
    this.assertOutrightWinnerForBets(pendingBets, winnerTeamCode, false);

    let totalPayout = new Decimal(0);
    let totalRefund = new Decimal(0);
    let lostOnThisMatch = 0;
    let pendingOtherMatches = 0;
    let wonLegsOnMatch = 0;
    const items: Array<{
      betId: bigint;
      betNo: string;
      betType: string;
      result: string;
      payout: Decimal;
      note?: string;
    }> = [];

    for (const bet of pendingBets) {
      if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
        const sel = bet.selections[0];
        const code = resolveSelectionCode(
          selectionCodes.get(sel.selectionId.toString()),
          sel.selectionNameSnapshot,
        );
        const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
        const payout = calculatePayout(bet.stake, sel.odds, result);
        if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
        if (result === 'LOSE') lostOnThisMatch += 1;
        items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
        if (result === 'PUSH' || result === 'VOID') {
          totalRefund = totalRefund.add(bet.stake);
        } else if (result !== 'LOSE') {
          totalPayout = totalPayout.add(payout);
        }
      } else if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
        const legResults = bet.selections.map((sel) => {
          const code = resolveSelectionCode(
            selectionCodes.get(sel.selectionId.toString()),
            sel.selectionNameSnapshot,
          );
          return {
            odds: sel.odds,
            result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
          };
        });
        const parlay = calculateParlayPayout(bet.stake, legResults);
        if (parlay.betResult === 'LOST') lostOnThisMatch += 1;
        items.push({
          betId: bet.id,
          betNo: bet.betNo,
          betType: 'SINGLE',
          result: parlay.betResult === 'LOST' ? 'LOSE' : parlay.betResult,
          payout: parlay.payout,
          note: '单关含多腿，按串关规则合并结算',
        });
        if (parlay.betResult === 'PUSH') {
          totalRefund = totalRefund.add(bet.stake);
        } else if (parlay.betResult === 'WON') {
          totalPayout = totalPayout.add(parlay.payout);
        }
      } else {
        for (const sel of bet.selections) {
          if (sel.matchId?.toString() !== matchId.toString()) continue;
          const code = resolveSelectionCode(
            selectionCodes.get(sel.selectionId.toString()),
            sel.selectionNameSnapshot,
          );
          const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
          if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
        }

        const preview = this.previewParlayForMatch(
          bet,
          matchId,
          scoreInput,
          statsInput,
          winnerTeamCode,
          selectionCodes,
        );
        if (preview.kind === 'SETTLED') {
          if (preview.betResult === 'LOST') lostOnThisMatch += 1;
          items.push({
            betId: bet.id,
            betNo: bet.betNo,
            betType: 'PARLAY',
            result: preview.betResult,
            payout: preview.payout,
          });
          if (preview.betResult === 'PUSH') {
            totalRefund = totalRefund.add(bet.stake);
          } else if (preview.betResult === 'WON') {
            totalPayout = totalPayout.add(preview.payout);
          }
        } else {
          pendingOtherMatches += 1;
          items.push({
            betId: bet.id,
            betNo: bet.betNo,
            betType: 'PARLAY',
            result: 'PENDING_OTHER_MATCHES',
            payout: new Decimal(0),
            note: '本场腿已出结果，待其他场次结算后统一结算',
          });
        }
      }
    }

    return {
      scoreInput,
      winnerTeamCode,
      pendingBets,
      items,
      totalPayout,
      totalRefund,
      lostOnThisMatch,
      pendingOtherMatches,
      wonLegsOnMatch,
    };
  }

  private previewParlayForMatch(
    bet: { stake: Decimal; selections: BetSelectionLeg[] },
    matchId: bigint,
    scoreInput: ScoreInput,
    statsInput: MatchStatsInput,
    winnerTeamCode: string | null,
    selectionCodes: Map<string, string | null>,
  ):
    | { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal }
    | { kind: 'PENDING_OTHER_MATCHES' } {
    const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
    for (const sel of bet.selections) {
      if (sel.matchId?.toString() === matchId.toString()) {
        const code = resolveSelectionCode(
          selectionCodes.get(sel.selectionId.toString()),
          sel.selectionNameSnapshot,
        );
        const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
        if (legResult === 'LOSE') {
          return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
        }
        legResults.push({
          odds: sel.odds,
          result: legResult,
        });
      } else if (sel.resultStatus) {
        if (sel.resultStatus === 'LOSE') {
          return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
        }
        legResults.push({
          odds: sel.odds,
          result: sel.resultStatus as SelectionResult,
        });
      } else {
        return { kind: 'PENDING_OTHER_MATCHES' };
      }
    }

    if (legResults.length !== bet.selections.length) {
      return { kind: 'PENDING_OTHER_MATCHES' };
    }
    const settled = calculateParlayPayout(bet.stake, legResults);
    return { kind: 'SETTLED', betResult: settled.betResult, payout: settled.payout };
  }

  private async loadSelectionCodes(
    bets: Array<{ selections: Array<{ selectionId: bigint }> }>,
    tx?: TxClient,
  ): Promise<Map<string, string | null>> {
    const ids = new Set<bigint>();
    for (const bet of bets) {
      for (const sel of bet.selections) ids.add(sel.selectionId);
    }
    if (!ids.size) return new Map();

    const client: PrismaClientLike = tx ?? this.prisma;
    const rows = await client.marketSelection.findMany({
      where: { id: { in: [...ids] } },
      select: { id: true, selectionCode: true },
    });
    return new Map(rows.map((r) => [r.id.toString(), r.selectionCode]));
  }

  async confirmSettlement(batchId: bigint, operatorId: bigint) {
    const batch = await this.prisma.settlementBatch.findUnique({
      where: { id: batchId },
      include: { match: true },
    });
    if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
    if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
    if (batch.match.status !== 'PENDING_SETTLEMENT') {
      throw appBadRequest('MATCH_NOT_SETTLEABLE');
    }

    if (batch.match.isOutright) {
      await this.assertOutrightLeagueFixturesSettled(batch.match);
    }

    const agentIds = new Set<bigint>();

    await this.prisma.$transaction(async (tx) => {
      const claimed = await tx.settlementBatch.updateMany({
        where: { id: batchId, status: 'PREVIEW' },
        data: { status: 'CONFIRMING', operatorId },
      });
      if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');

      const currentBatch = await tx.settlementBatch.findUnique({
        where: { id: batchId },
        include: { match: true },
      });
      if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
      if (currentBatch.match.status !== 'PENDING_SETTLEMENT') {
        throw appBadRequest('MATCH_NOT_SETTLEABLE');
      }

      const scoreInput: ScoreInput = {
        htHome: currentBatch.htHomeScore ?? 0,
        htAway: currentBatch.htAwayScore ?? 0,
        ftHome: currentBatch.ftHomeScore ?? 0,
        ftAway: currentBatch.ftAwayScore ?? 0,
      };
      const statsInput = this.statsInputFromSource(currentBatch);
      const existingScore = await tx.matchScore.findUnique({
        where: { matchId: currentBatch.matchId },
      });
      const winnerTeamCode = await this.resolveWinnerTeamCode(
        existingScore?.winnerTeamId ?? null,
        tx,
      );

      const pendingBets = await tx.bet.findMany({
        where: {
          status: 'PENDING',
          selections: { some: { matchId: currentBatch.matchId } },
        },
        include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
      });
      const selectionCodes = await this.loadSelectionCodes(pendingBets, tx);
      await this.assertRequiredStatsAvailable(
        currentBatch.matchId,
        statsInput,
        pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
        tx,
      );
      this.assertSupportedMarketTypes(pendingBets);
      this.assertOutrightWinnerForBets(
        pendingBets,
        winnerTeamCode,
        currentBatch.match.isOutright,
      );
      await this.assertLatestPreviewBatch(batchId, currentBatch.matchId, tx);
      let settledCount = 0;

      await this.upsertMatchScoreRecord(
        currentBatch.matchId,
        {
          htHome: scoreInput.htHome,
          htAway: scoreInput.htAway,
          ftHome: scoreInput.ftHome,
          ftAway: scoreInput.ftAway,
          ...statsInput,
          winnerTeamId: existingScore?.winnerTeamId ?? null,
        },
        operatorId,
        tx,
      );

      for (const bet of pendingBets) {
        if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
          const sel = bet.selections[0];
          const code = resolveSelectionCode(
            selectionCodes.get(sel.selectionId.toString()),
            sel.selectionNameSnapshot,
          );
          const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
          const payout = calculatePayout(bet.stake, sel.odds, result);
          const betStatus = this.betStatusFromSelection(result);

          const updatedBet = await tx.bet.updateMany({
            where: { id: bet.id, status: 'PENDING' },
            data: {
              status: betStatus,
              actualReturn: payout,
              settledAt: new Date(),
            },
          });
          this.requireBetUpdate(updatedBet, bet.betNo);

          await tx.betSelection.update({
            where: { id: sel.id },
            data: { resultStatus: result, effectiveOdds: sel.odds },
          });

          await this.funds.settleBet({
            userId: bet.userId,
            stake: bet.stake,
            payout,
            betNo: bet.betNo,
            batchNo: batch.batchNo,
            result: this.walletResultFromSelection(result),
            tx,
          });

          if (bet.agentId) agentIds.add(bet.agentId);
          settledCount += 1;

          await tx.settlementItem.create({
            data: {
              batchId,
              betId: bet.id,
              userId: bet.userId,
              result: betStatus,
              payout,
            },
          });
        } else if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
          const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
          for (const sel of bet.selections) {
            const code = resolveSelectionCode(
              selectionCodes.get(sel.selectionId.toString()),
              sel.selectionNameSnapshot,
            );
            const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
            legResults.push({ odds: sel.odds, result });
            await tx.betSelection.update({
              where: { id: sel.id },
              data: { resultStatus: result, effectiveOdds: sel.odds },
            });
          }
          const parlayResult = calculateParlayPayout(bet.stake, legResults);
          const betStatus = this.parlayBetStatusFromResult(parlayResult);

          const updatedBet = await tx.bet.updateMany({
            where: { id: bet.id, status: 'PENDING' },
            data: {
              status: betStatus,
              actualReturn: parlayResult.payout,
              settledAt: new Date(),
            },
          });
          this.requireBetUpdate(updatedBet, bet.betNo);

          await this.funds.settleBet({
            userId: bet.userId,
            stake: bet.stake,
            payout: parlayResult.payout,
            betNo: bet.betNo,
            batchNo: batch.batchNo,
            result: this.walletResultFromParlayBetResult(parlayResult.betResult),
            tx,
          });

          if (bet.agentId) agentIds.add(bet.agentId);
          settledCount += 1;

          await tx.settlementItem.create({
            data: {
              batchId,
              betId: bet.id,
              userId: bet.userId,
              result: betStatus,
              payout: parlayResult.payout,
            },
          });
        } else {
          for (const sel of bet.selections) {
            if (sel.matchId?.toString() === batch.matchId.toString()) {
              const code = resolveSelectionCode(
                selectionCodes.get(sel.selectionId.toString()),
                sel.selectionNameSnapshot,
              );
              const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
              await tx.betSelection.update({
                where: { id: sel.id },
                data: { resultStatus: result },
              });
            }
          }

          const updated = await tx.betSelection.findMany({
            where: { betId: bet.id },
            orderBy: { sortOrder: 'asc' },
          });
          const allHaveResult = updated.every((s) => s.resultStatus != null);

          if (allHaveResult) {
            const legResults = updated.map((s) => ({
              odds: s.odds,
              result: s.resultStatus as SelectionResult,
            }));
            const parlayResult = calculateParlayPayout(bet.stake, legResults);
            const betStatus = this.parlayBetStatusFromResult(parlayResult);

            const updatedBet = await tx.bet.updateMany({
              where: { id: bet.id, status: 'PENDING' },
              data: {
                status: betStatus,
                actualReturn: parlayResult.payout,
                settledAt: new Date(),
              },
            });
            this.requireBetUpdate(updatedBet, bet.betNo);

            await this.funds.settleBet({
              userId: bet.userId,
              stake: bet.stake,
              payout: parlayResult.payout,
              betNo: bet.betNo,
              batchNo: batch.batchNo,
              result: this.walletResultFromParlayBetResult(parlayResult.betResult),
              tx,
            });

            if (bet.agentId) agentIds.add(bet.agentId);
            settledCount += 1;

            await tx.settlementItem.create({
              data: {
                batchId,
                betId: bet.id,
                userId: bet.userId,
                result: betStatus,
                payout: parlayResult.payout,
              },
            });
          }
        }
      }

      await tx.settlementBatch.update({
        where: { id: batchId },
        data: {
          status: 'CONFIRMED',
          confirmedAt: new Date(),
          operatorId,
          totalBets: settledCount,
        },
      });

      await tx.match.update({
        where: { id: currentBatch.matchId },
        data: { status: 'SETTLED', isHot: false },
      });
    });

    for (const agentId of agentIds) {
      await this.agents.recalculateUsedCredit(agentId);
    }

    return { success: true, batchId: batchId.toString() };
  }

  private async assertMatchReadyForBetStats(matchId: bigint) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
    });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    this.assertMatchClosedForSettlement(match.status);
    return match;
  }

  async getMatchBetStatsSummary(matchId: bigint) {
    await this.assertMatchReadyForBetStats(matchId);
    const betWhere = { selections: { some: { matchId } } };

    const [
      legCount,
      totalBets,
      singleBets,
      parlayBets,
      stakeAgg,
      statusGroups,
      legsForSelection,
    ] = await Promise.all([
      this.prisma.betSelection.count({ where: { matchId } }),
      this.prisma.bet.count({ where: betWhere }),
      this.prisma.bet.count({ where: { ...betWhere, betType: 'SINGLE' } }),
      this.prisma.bet.count({ where: { ...betWhere, betType: 'PARLAY' } }),
      this.prisma.bet.aggregate({
        where: betWhere,
        _sum: { stake: true, potentialReturn: true },
      }),
      this.prisma.bet.groupBy({
        by: ['status'],
        where: betWhere,
        _count: { _all: true },
      }),
      this.prisma.betSelection.findMany({
        where: { matchId },
        select: {
          marketId: true,
          selectionId: true,
          marketType: true,
          period: true,
          selectionNameSnapshot: true,
          bet: { select: { betType: true, stake: true } },
        },
        orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
      }),
    ]);

    const statusCounts: Record<string, number> = {};
    for (const row of statusGroups) {
      statusCounts[row.status] = row._count._all;
    }

    type SelAgg = {
      marketType: string;
      period: string | null;
      selectionName: string;
      selectionId: string;
      legCount: number;
      singleStake: Decimal;
      parlayLegCount: number;
    };
    const selMap = new Map<string, SelAgg>();

    for (const leg of legsForSelection) {
      const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`;
      let row = selMap.get(key);
      if (!row) {
        row = {
          marketType: leg.marketType,
          period: leg.period,
          selectionName: leg.selectionNameSnapshot,
          selectionId: leg.selectionId.toString(),
          legCount: 0,
          singleStake: new Decimal(0),
          parlayLegCount: 0,
        };
        selMap.set(key, row);
      }
      row.legCount += 1;
      if (leg.bet.betType === 'SINGLE') {
        row.singleStake = row.singleStake.add(leg.bet.stake);
      } else if (leg.bet.betType === 'PARLAY') {
        row.parlayLegCount += 1;
      }
    }

    const bySelection = Array.from(selMap.values())
      .map((r) => ({
        marketType: r.marketType,
        period: r.period,
        selectionName: r.selectionName,
        selectionId: r.selectionId,
        legCount: r.legCount,
        singleStake: r.singleStake.toString(),
        parlayLegCount: r.parlayLegCount,
      }))
      .sort((a, b) => {
        const mk = a.marketType.localeCompare(b.marketType);
        if (mk !== 0) return mk;
        return a.selectionName.localeCompare(b.selectionName);
      });

    return {
      summary: {
        totalBets,
        singleBets,
        parlayBets,
        totalStake: (stakeAgg._sum.stake ?? new Decimal(0)).toString(),
        totalPotentialReturn: (stakeAgg._sum.potentialReturn ?? new Decimal(0)).toString(),
        statusCounts,
        legCount,
      },
      bySelection,
    };
  }

  async getMatchBetStatsBets(
    matchId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    await this.assertMatchReadyForBetStats(matchId);
    const betWhere = { selections: { some: { matchId } } };
    const totalBets = await this.prisma.bet.count({ where: betWhere });

    const page = Math.max(1, opts?.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));

    const betRows = await this.prisma.bet.findMany({
      where: betWhere,
      orderBy: { placedAt: 'desc' },
      skip: (page - 1) * pageSize,
      take: pageSize,
      include: {
        user: { select: { username: true } },
        selections: {
          where: { matchId },
          orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
        },
      },
    });

    const items = betRows.map((bet) => ({
      id: bet.id.toString(),
      betNo: bet.betNo,
      username: bet.user.username,
      betType: bet.betType,
      status: bet.status,
      settlementStatus: bet.settlementStatus,
      stake: bet.stake.toString(),
      potentialReturn: bet.potentialReturn?.toString() ?? null,
      actualReturn: bet.actualReturn.toString(),
      placedAt: bet.placedAt.toISOString(),
      legCountOnMatch: bet.selections.length,
      selections: bet.selections.map((leg) => ({
        marketType: leg.marketType,
        period: leg.period,
        selectionName: leg.selectionNameSnapshot,
        odds: leg.odds.toString(),
      })),
    }));

    return {
      items,
      total: totalBets,
      page,
      pageSize,
    };
  }

  async getMatchBetStats(
    matchId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    const [summaryPart, bets] = await Promise.all([
      this.getMatchBetStatsSummary(matchId),
      this.getMatchBetStatsBets(matchId, opts),
    ]);
    return {
      ...summaryPart,
      bets,
    };
  }

  private async computeBetOutcome(
    bet: {
      id: bigint;
      betType: string;
      stake: Decimal;
      actualReturn: Decimal;
      selections: BetSelectionLeg[];
    },
    matchId: bigint,
    scoreInput: ScoreInput,
    statsInput: MatchStatsInput,
    winnerTeamCode: string | null,
    selectionCodes: Map<string, string | null>,
  ) {
    if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
      const sel = bet.selections[0];
      const code = resolveSelectionCode(
        selectionCodes.get(sel.selectionId.toString()),
        sel.selectionNameSnapshot,
      );
      const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
      const payout = calculatePayout(bet.stake, sel.odds, result);
      return {
        payout,
        betStatus: this.betStatusFromSelection(result),
        legUpdates: new Map([[sel.id.toString(), result]]),
      };
    }

    if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
      const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
      const legUpdates = new Map<string, SelectionResult>();
      for (const sel of bet.selections) {
        const code = resolveSelectionCode(
          selectionCodes.get(sel.selectionId.toString()),
          sel.selectionNameSnapshot,
        );
        const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
        legResults.push({ odds: sel.odds, result });
        legUpdates.set(sel.id.toString(), result);
      }
      const parlayResult = calculateParlayPayout(bet.stake, legResults);
      return {
        payout: parlayResult.payout,
        betStatus: this.parlayBetStatusFromResult(parlayResult),
        legUpdates,
      };
    }

    const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
    const legUpdates = new Map<string, SelectionResult>();

    for (const sel of bet.selections) {
      let result: SelectionResult;
      if (sel.matchId?.toString() === matchId.toString()) {
        const code = resolveSelectionCode(
          selectionCodes.get(sel.selectionId.toString()),
          sel.selectionNameSnapshot,
        );
        result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
        legUpdates.set(sel.id.toString(), result);
      } else {
        if (!sel.resultStatus) {
          throw appBadRequest('PARLAY_UNSETTLED_LEGS', { betId: bet.id.toString() });
        }
        result = sel.resultStatus as SelectionResult;
      }
      legResults.push({ odds: sel.odds, result });
    }

    const parlayResult = calculateParlayPayout(bet.stake, legResults);
    return {
      payout: parlayResult.payout,
      betStatus: this.parlayBetStatusFromResult(parlayResult),
      legUpdates,
    };
  }

  async previewResettlement(
    matchId: bigint,
    scoreInput: ScoreInput & MatchStatsInput,
    operatorId: bigint,
    reason?: string,
    winnerTeamId?: bigint,
  ) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
    });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    if (match.status !== 'SETTLED') {
      throw appBadRequest('RESETTLE_SETTLED_ONLY');
    }

    if (!match.isOutright) {
      this.assertScoreConsistency(
        {
          htHome: scoreInput.htHome,
          htAway: scoreInput.htAway,
          ftHome: scoreInput.ftHome,
          ftAway: scoreInput.ftAway,
        },
        match.isOutright,
      );
    }

    const winnerTeamCode = winnerTeamId
      ? await this.resolveWinnerTeamCode(winnerTeamId)
      : null;
    const statsInput = this.statsInputFromSource(scoreInput);

    const settledBets = await this.prisma.bet.findMany({
      where: {
        status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
        selections: { some: { matchId } },
      },
      include: { selections: { orderBy: { sortOrder: 'asc' } } },
    });

    const selectionCodes = await this.loadSelectionCodes(settledBets);
    await this.assertRequiredStatsAvailable(
      matchId,
      statsInput,
      settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
    );
    this.assertSupportedMarketTypes(settledBets);
    this.assertOutrightWinnerForBets(settledBets, winnerTeamCode, match.isOutright);
    const items: Array<{
      betId: bigint;
      betNo: string;
      oldPayout: Decimal;
      newPayout: Decimal;
      delta: Decimal;
      oldStatus: string;
      newStatus: string;
    }> = [];

    let totalClawback = new Decimal(0);
    let totalTopup = new Decimal(0);

    for (const bet of settledBets) {
      const oldPayout = new Decimal(bet.actualReturn);
      const outcome = await this.computeBetOutcome(
        bet,
        matchId,
        scoreInput,
        statsInput,
        winnerTeamCode,
        selectionCodes,
      );
      const delta = outcome.payout.sub(oldPayout);
      if (delta.eq(0) && outcome.betStatus === bet.status) continue;

      items.push({
        betId: bet.id,
        betNo: bet.betNo,
        oldPayout,
        newPayout: outcome.payout,
        delta,
        oldStatus: bet.status,
        newStatus: outcome.betStatus,
      });

      if (delta.gt(0)) totalTopup = totalTopup.add(delta);
      else if (delta.lt(0)) totalClawback = totalClawback.add(delta.abs());
    }

    const batch = await this.prisma.settlementBatch.create({
      data: {
        matchId,
        batchNo: generateBatchNo('RST'),
        htHomeScore: scoreInput.htHome,
        htAwayScore: scoreInput.htAway,
        ftHomeScore: scoreInput.ftHome,
        ftAwayScore: scoreInput.ftAway,
        homeCorners: statsInput.homeCorners ?? null,
        awayCorners: statsInput.awayCorners ?? null,
        homeYellowCards: statsInput.homeYellowCards ?? null,
        awayYellowCards: statsInput.awayYellowCards ?? null,
        homeRedCards: statsInput.homeRedCards ?? null,
        awayRedCards: statsInput.awayRedCards ?? null,
        homeCards: statsInput.homeCards ?? null,
        awayCards: statsInput.awayCards ?? null,
        status: 'PREVIEW',
        totalBets: items.length,
        totalPayout: totalTopup,
        totalRefund: totalClawback,
        operatorId,
        isResettle: true,
        reason: reason?.trim() || null,
      },
    });

    if (match.isOutright && winnerTeamId) {
      await this.upsertMatchScoreRecord(
        matchId,
        {
          htHome: scoreInput.htHome,
          htAway: scoreInput.htAway,
          ftHome: scoreInput.ftHome,
          ftAway: scoreInput.ftAway,
          ...statsInput,
          winnerTeamId,
        },
        operatorId,
      );
    }

    return {
      batch,
      score: scoreInput,
      winnerTeamCode,
      items,
      totalClawback,
      totalTopup,
      affectedCount: items.length,
    };
  }

  async confirmResettlement(batchId: bigint, operatorId: bigint) {
    const batch = await this.prisma.settlementBatch.findUnique({
      where: { id: batchId },
      include: { match: true },
    });
    if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
    if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY');
    if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');

    const agentIds = new Set<bigint>();

    await this.prisma.$transaction(async (tx) => {
      const claimed = await tx.settlementBatch.updateMany({
        where: { id: batchId, status: 'PREVIEW' },
        data: { status: 'CONFIRMING', operatorId },
      });
      if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');

      const currentBatch = await tx.settlementBatch.findUnique({
        where: { id: batchId },
        include: { match: true },
      });
      if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');

      const scoreInput: ScoreInput = {
        htHome: currentBatch.htHomeScore ?? 0,
        htAway: currentBatch.htAwayScore ?? 0,
        ftHome: currentBatch.ftHomeScore ?? 0,
        ftAway: currentBatch.ftAwayScore ?? 0,
      };
      const statsInput = this.statsInputFromSource(currentBatch);
      const existingScore = await tx.matchScore.findUnique({
        where: { matchId: currentBatch.matchId },
      });
      const winnerTeamCode = await this.resolveWinnerTeamCode(
        existingScore?.winnerTeamId ?? null,
        tx,
      );

      const settledBets = await tx.bet.findMany({
        where: {
          status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
          selections: { some: { matchId: currentBatch.matchId } },
        },
        include: { selections: { orderBy: { sortOrder: 'asc' } } },
      });
      const selectionCodes = await this.loadSelectionCodes(settledBets, tx);
      await this.assertRequiredStatsAvailable(
        currentBatch.matchId,
        statsInput,
        settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
        tx,
      );
      this.assertSupportedMarketTypes(settledBets);
      this.assertOutrightWinnerForBets(
        settledBets,
        winnerTeamCode,
        currentBatch.match.isOutright,
      );
      let affectedCount = 0;

      await this.upsertMatchScoreRecord(
        currentBatch.matchId,
        {
          htHome: scoreInput.htHome,
          htAway: scoreInput.htAway,
          ftHome: scoreInput.ftHome,
          ftAway: scoreInput.ftAway,
          ...statsInput,
          winnerTeamId: existingScore?.winnerTeamId ?? null,
        },
        operatorId,
        tx,
      );

      for (const bet of settledBets) {
        const oldPayout = new Decimal(bet.actualReturn);
        const outcome = await this.computeBetOutcome(
          bet,
          currentBatch.matchId,
          scoreInput,
          statsInput,
          winnerTeamCode,
          selectionCodes,
        );
        const delta = outcome.payout.sub(oldPayout);

        if (delta.eq(0) && outcome.betStatus === bet.status) continue;

        for (const [legId, result] of outcome.legUpdates) {
          const leg = bet.selections.find((s) => s.id.toString() === legId);
          await tx.betSelection.update({
            where: { id: BigInt(legId) },
            data: {
              resultStatus: result,
              effectiveOdds: leg?.odds ?? undefined,
            },
          });
        }

        await tx.bet.update({
          where: { id: bet.id },
          data: {
            status: outcome.betStatus,
            actualReturn: outcome.payout,
            settlementStatus: 'RESETTLED',
          },
        });

        await this.funds.applyResettleDelta({
          userId: bet.userId,
          delta,
          betNo: bet.betNo,
          batchNo: currentBatch.batchNo,
          tx,
        });

        if (bet.agentId) agentIds.add(bet.agentId);
        affectedCount += 1;

        await tx.settlementItem.create({
          data: {
            batchId,
            betId: bet.id,
            userId: bet.userId,
            result: outcome.betStatus,
            payout: outcome.payout,
          },
        });
      }

      await tx.settlementBatch.update({
        where: { id: batchId },
        data: {
          status: 'CONFIRMED',
          confirmedAt: new Date(),
          operatorId,
          totalBets: affectedCount,
        },
      });
    });

    for (const agentId of agentIds) {
      await this.agents.recalculateUsedCredit(agentId);
    }

    return { success: true, batchId: batchId.toString() };
  }

  async voidMatchBets(matchId: bigint) {
    return this.voidMatchBetsInTransaction(matchId);
  }

  async cancelMatchAndVoidBets(matchId: bigint) {
    return this.voidMatchBetsInTransaction(matchId, { cancelMatch: true });
  }

  private async voidMatchBetsInTransaction(
    matchId: bigint,
    options: { cancelMatch?: boolean } = {},
  ) {
    const agentIds = new Set<bigint>();
    const voidBatchNo = `void:${matchId}`;

    const voidedCount = await this.prisma.$transaction(async (tx) => {
      if (options.cancelMatch) {
        await tx.match.update({
          where: { id: matchId },
          data: { status: 'CANCELLED' },
        });
      }

      const bets = await tx.bet.findMany({
        where: { status: 'PENDING', selections: { some: { matchId } } },
        include: { selections: { orderBy: { sortOrder: 'asc' } } },
      });

      let count = 0;
      for (const bet of bets) {
        const isSingleOneLeg = bet.betType === 'SINGLE' && bet.selections.length === 1;

        if (isSingleOneLeg) {
          const sel = bet.selections[0];
          const updated = await tx.bet.updateMany({
            where: { id: bet.id, status: 'PENDING' },
            data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
          });
          if (updated.count !== 1) continue;

          await tx.betSelection.update({
            where: { id: sel.id },
            data: { resultStatus: 'VOID' },
          });

          await this.funds.voidBet({
            userId: bet.userId,
            stake: bet.stake,
            betNo: bet.betNo,
            businessKey: `void:${matchId}:${bet.betNo}`,
            tx,
          });
          if (bet.agentId) agentIds.add(bet.agentId);
          count += 1;
          continue;
        }

        const legsOnMatch = bet.selections.filter(
          (sel) => sel.matchId?.toString() === matchId.toString(),
        );
        if (!legsOnMatch.length) continue;

        for (const sel of legsOnMatch) {
          await tx.betSelection.update({
            where: { id: sel.id },
            data: { resultStatus: 'VOID' },
          });
        }

        const updatedLegs = await tx.betSelection.findMany({
          where: { betId: bet.id },
          orderBy: { sortOrder: 'asc' },
        });
        if (!updatedLegs.every((sel) => sel.resultStatus != null)) {
          continue;
        }

        const legResults = updatedLegs.map((sel) => ({
          odds: sel.odds,
          result: sel.resultStatus as SelectionResult,
        }));
        const parlayResult = calculateParlayPayout(bet.stake, legResults);
        const betStatus = this.parlayBetStatusFromResult(parlayResult);

        const updatedBet = await tx.bet.updateMany({
          where: { id: bet.id, status: 'PENDING' },
          data: {
            status: betStatus,
            actualReturn: parlayResult.payout,
            settledAt: new Date(),
          },
        });
        if (updatedBet.count !== 1) continue;

        await this.funds.settleBet({
          userId: bet.userId,
          stake: bet.stake,
          payout: parlayResult.payout,
          betNo: bet.betNo,
          batchNo: voidBatchNo,
          result: this.walletResultFromParlayBetResult(parlayResult.betResult),
          tx,
        });
        if (bet.agentId) agentIds.add(bet.agentId);
        count += 1;
      }

      return count;
    });

    for (const agentId of agentIds) {
      await this.agents.recalculateUsedCredit(agentId);
    }

    return { voidedCount };
  }
}
