import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { AgentsService } from '../agent/agents.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../shared/common/decorators';
import {
  settleSelection,
  calculatePayout,
  calculateParlayPayout,
  ScoreInput,
  SelectionResult,
} from './domain/settlement-calculator';
import {
  resolveSelectionCode,
  templateScoresForMarket,
} from './domain/settlement-helpers';

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 wallet: WalletService,
    private agents: AgentsService,
  ) {}

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

  private buildSettleInput(
    sel: BetSelectionLeg,
    selectionCode: string,
    scoreInput: ScoreInput,
    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,
      templateScores: templateScoresForMarket(sel.marketType),
      winnerTeamCode,
    };
  }

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

  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';
  }

  async recordScore(
    matchId: bigint,
    htHome: number,
    htAway: number,
    ftHome: number,
    ftAway: number,
    operatorId: bigint,
    winnerTeamId?: bigint,
  ) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
    });
    if (!match) throw new NotFoundException('Match not found');

    if (match.isOutright) {
      if (!winnerTeamId) {
        throw new BadRequestException('冠军盘结算需指定获胜球队 winnerTeamId');
      }
      const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
      if (!team) throw new BadRequestException('获胜球队不存在');
      const outrightSel = await this.prisma.marketSelection.findFirst({
        where: {
          market: { matchId, marketType: 'OUTRIGHT_WINNER' },
          selectionCode: team.code,
        },
      });
      if (!outrightSel) {
        throw new BadRequestException('该球队不在本冠军盘选项中');
      }
    }

    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,
        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,
        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, winnerTeamId: winnerTeamId?.toString() ?? null };
  }

  async previewSettlement(
    matchId: bigint,
    operatorId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
    if (!score) throw new BadRequestException('Score not recorded');

    const computation = await this.computePreviewComputation(matchId);
    const batch = await this.prisma.settlementBatch.create({
      data: {
        matchId,
        batchNo: generateBatchNo('STL'),
        htHomeScore: score.htHomeScore,
        htAwayScore: score.htAwayScore,
        ftHomeScore: score.ftHomeScore,
        ftAwayScore: score.ftAwayScore,
        status: 'PREVIEW',
        totalBets: computation.pendingBets.length,
        totalPayout: computation.totalPayout,
        totalRefund: computation.totalRefund,
        operatorId,
      },
    });

    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 new NotFoundException('Batch not found');
    if (batch.status !== 'PREVIEW') {
      throw new BadRequestException('Batch is not in preview');
    }

    const computation = await this.computePreviewComputation(batch.matchId);
    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,
    };
  }

  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 computePreviewComputation(matchId: bigint) {
    const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
    if (!score) throw new BadRequestException('Score not recorded');

    const scoreInput: ScoreInput = {
      htHome: score.htHomeScore ?? 0,
      htAway: score.htAwayScore ?? 0,
      ftHome: score.ftHomeScore ?? 0,
      ftAway: score.ftAwayScore ?? 0,
    };
    const 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);

    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, winnerTeamCode);
        const payout = calculatePayout(bet.stake, sel.odds, result);
        if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 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, winnerTeamCode),
          };
        });
        const parlay = calculateParlayPayout(bet.stake, legResults);
        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, winnerTeamCode);
          if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
        }

        const preview = this.previewParlayForMatch(
          bet,
          matchId,
          scoreInput,
          winnerTeamCode,
          selectionCodes,
        );
        if (preview.kind === 'SETTLED') {
          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 if (preview.kind === 'LOST_ON_THIS_MATCH') {
          lostOnThisMatch += 1;
          items.push({
            betId: bet.id,
            betNo: bet.betNo,
            betType: 'PARLAY',
            result: 'LOST',
            payout: preview.payout,
            note: '本场已有输腿，串关整单作废',
          });
        } 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,
    winnerTeamCode: string | null,
    selectionCodes: Map<string, string | null>,
  ):
    | { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal }
    | { kind: 'LOST_ON_THIS_MATCH'; payout: Decimal }
    | { kind: 'PENDING_OTHER_MATCHES' } {
    for (const sel of bet.selections) {
      if (sel.matchId?.toString() !== matchId.toString()) continue;
      const code = resolveSelectionCode(
        selectionCodes.get(sel.selectionId.toString()),
        sel.selectionNameSnapshot,
      );
      const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
      if (result === 'LOSE' || result === 'HALF_LOSE') {
        return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) };
      }
    }

    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,
        );
        legResults.push({
          odds: sel.odds,
          result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
        });
      } else if (sel.resultStatus) {
        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 }> }>,
  ): 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 rows = await this.prisma.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 new NotFoundException('Batch not found');
    if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');

    const score = await this.prisma.matchScore.findUnique({
      where: { matchId: batch.matchId },
    });
    if (!score) throw new BadRequestException('Score not found');

    const scoreInput: ScoreInput = {
      htHome: score.htHomeScore ?? 0,
      htAway: score.htAwayScore ?? 0,
      ftHome: score.ftHomeScore ?? 0,
      ftAway: score.ftAwayScore ?? 0,
    };
    const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);

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

    const selectionCodes = await this.loadSelectionCodes(pendingBets);
    const agentIds = new Set<bigint>();

    await this.prisma.$transaction(async (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, winnerTeamCode);
          const payout = calculatePayout(bet.stake, sel.odds, result);
          const betStatus = this.betStatusFromSelection(result);

          await tx.bet.update({
            where: { id: bet.id },
            data: {
              status: betStatus,
              actualReturn: payout,
              settledAt: new Date(),
            },
          });

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

          await this.wallet.settleBet(
            bet.userId,
            bet.stake,
            payout,
            bet.betNo,
            this.walletResultFromSelection(result),
            tx,
          );

          if (bet.agentId) agentIds.add(bet.agentId);

          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, 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 =
            parlayResult.betResult === 'LOST'
              ? 'LOST'
              : parlayResult.betResult === 'PUSH'
                ? 'PUSH'
                : 'WON';

          await tx.bet.update({
            where: { id: bet.id },
            data: {
              status: betStatus,
              actualReturn: parlayResult.payout,
              settledAt: new Date(),
            },
          });

          await this.wallet.settleBet(
            bet.userId,
            bet.stake,
            parlayResult.payout,
            bet.betNo,
            parlayResult.betResult === 'LOST'
              ? 'LOSE'
              : parlayResult.betResult === 'PUSH'
                ? 'PUSH'
                : 'WIN',
            tx,
          );

          if (bet.agentId) agentIds.add(bet.agentId);

          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, 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 =
              parlayResult.betResult === 'LOST'
                ? 'LOST'
                : parlayResult.betResult === 'PUSH'
                  ? 'PUSH'
                  : 'WON';

            await tx.bet.update({
              where: { id: bet.id },
              data: {
                status: betStatus,
                actualReturn: parlayResult.payout,
                settledAt: new Date(),
              },
            });

            await this.wallet.settleBet(
              bet.userId,
              bet.stake,
              parlayResult.payout,
              bet.betNo,
              parlayResult.betResult === 'LOST'
                ? 'LOSE'
                : parlayResult.betResult === 'PUSH'
                  ? 'PUSH'
                  : 'WIN',
              tx,
            );

            if (bet.agentId) agentIds.add(bet.agentId);

            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() },
      });

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

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

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

  async getMatchBetStats(
    matchId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
    });
    if (!match) throw new NotFoundException('Match not found');

    const legs = await this.prisma.betSelection.findMany({
      where: { matchId },
      include: {
        bet: {
          select: {
            id: true,
            betNo: true,
            betType: true,
            stake: true,
            status: true,
            settlementStatus: true,
            potentialReturn: true,
            actualReturn: true,
            placedAt: true,
            user: { select: { username: true } },
          },
        },
      },
      orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
    });

    const betById = new Map<string, (typeof legs)[0]['bet']>();
    for (const leg of legs) {
      betById.set(leg.betId.toString(), leg.bet);
    }

    let totalStake = new Decimal(0);
    let totalPotential = new Decimal(0);
    let singleBets = 0;
    let parlayBets = 0;
    const statusCounts: Record<string, number> = {};

    for (const bet of betById.values()) {
      totalStake = totalStake.add(bet.stake);
      if (bet.potentialReturn) {
        totalPotential = totalPotential.add(bet.potentialReturn);
      }
      if (bet.betType === 'SINGLE') singleBets += 1;
      else if (bet.betType === 'PARLAY') parlayBets += 1;
      statusCounts[bet.status] = (statusCounts[bet.status] ?? 0) + 1;
    }

    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 legs) {
      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);
      });

    const betsById = new Map<
      string,
      {
        bet: (typeof legs)[0]['bet'];
        matchLegs: (typeof legs);
      }
    >();
    for (const leg of legs) {
      const key = leg.betId.toString();
      const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] };
      row.matchLegs.push(leg);
      betsById.set(key, row);
    }

    const allBets = Array.from(betsById.values())
      .map(({ bet, matchLegs }) => ({
        id: bet.id.toString(),
        betNo: bet.betNo,
        username: matchLegs[0].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: matchLegs.length,
        selections: matchLegs.map((leg) => ({
          marketType: leg.marketType,
          period: leg.period,
          selectionName: leg.selectionNameSnapshot,
          odds: leg.odds.toString(),
        })),
      }))
      .sort(
        (a, b) =>
          new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
      );

    const page = Math.max(1, opts?.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
    const total = allBets.length;
    const start = (page - 1) * pageSize;

    return {
      summary: {
        totalBets: betById.size,
        singleBets,
        parlayBets,
        totalStake: totalStake.toString(),
        totalPotentialReturn: totalPotential.toString(),
        statusCounts,
        legCount: legs.length,
      },
      bySelection,
      bets: {
        items: allBets.slice(start, start + pageSize),
        total,
        page,
        pageSize,
      },
    };
  }

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

    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, winnerTeamCode);
        legUpdates.set(sel.id.toString(), result);
      } else {
        if (!sel.resultStatus) {
          throw new BadRequestException(`Parlay bet ${bet.id} has unsettled legs`);
        }
        result = sel.resultStatus as SelectionResult;
      }
      legResults.push({ odds: sel.odds, result });
    }

    const parlayResult = calculateParlayPayout(bet.stake, legResults);
    const betStatus =
      parlayResult.betResult === 'LOST'
        ? 'LOST'
        : parlayResult.betResult === 'PUSH'
          ? 'PUSH'
          : 'WON';

    return { payout: parlayResult.payout, betStatus, legUpdates };
  }

  async previewResettlement(
    matchId: bigint,
    scoreInput: ScoreInput,
    operatorId: bigint,
    reason?: string,
    winnerTeamId?: bigint,
  ) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
    });
    if (!match) throw new NotFoundException('Match not found');
    if (match.status !== 'SETTLED') {
      throw new BadRequestException('Only settled matches can be resettled');
    }

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

    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);
    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,
        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,
        status: 'PREVIEW',
        totalBets: items.length,
        totalPayout: totalTopup,
        totalRefund: totalClawback,
        operatorId,
        isResettle: true,
        reason: reason?.trim() || null,
      },
    });

    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 new NotFoundException('Batch not found');
    if (!batch.isResettle) throw new BadRequestException('Not a resettle batch');
    if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');

    const scoreInput: ScoreInput = {
      htHome: batch.htHomeScore ?? 0,
      htAway: batch.htAwayScore ?? 0,
      ftHome: batch.ftHomeScore ?? 0,
      ftAway: batch.ftAwayScore ?? 0,
    };
    const winnerTeamCode = await this.resolveWinnerTeamCode(
      (
        await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } })
      )?.winnerTeamId,
    );

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

    const selectionCodes = await this.loadSelectionCodes(settledBets);
    const agentIds = new Set<bigint>();

    await this.prisma.$transaction(async (tx) => {
      await tx.matchScore.upsert({
        where: { matchId: batch.matchId },
        create: {
          matchId: batch.matchId,
          htHomeScore: scoreInput.htHome,
          htAwayScore: scoreInput.htAway,
          ftHomeScore: scoreInput.ftHome,
          ftAwayScore: scoreInput.ftAway,
          recordedBy: operatorId,
        },
        update: {
          htHomeScore: scoreInput.htHome,
          htAwayScore: scoreInput.htAway,
          ftHomeScore: scoreInput.ftHome,
          ftAwayScore: scoreInput.ftAway,
          recordedBy: operatorId,
        },
      });

      for (const bet of settledBets) {
        const oldPayout = new Decimal(bet.actualReturn);
        const outcome = await this.computeBetOutcome(
          bet,
          batch.matchId,
          scoreInput,
          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.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx);

        if (bet.agentId) agentIds.add(bet.agentId);

        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 },
      });
    });

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

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

  async voidMatchBets(matchId: bigint) {
    const bets = await this.prisma.bet.findMany({
      where: { status: 'PENDING', selections: { some: { matchId } } },
    });

    for (const bet of bets) {
      await this.wallet.settleBet(bet.userId, bet.stake, bet.stake, bet.betNo, 'VOID');
      await this.prisma.bet.update({
        where: { id: bet.id },
        data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
      });
    }

    return { voidedCount: bets.length };
  }
}
