import { Injectable } from '@nestjs/common';
import {
  defaultMarketName,
  defaultSelectionName,
  isPreMatchKickoff,
  isSettlementSupportedMarketType,
  PARLAY_MARKET_TYPES,
  resolveMarketText,
  resolveTranslationFallback,
  sanitizeLocalizedText,
} from '@thebet365/shared';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
import { MatchBetStatsService, type MatchBetStatsSummary } from './match-bet-stats.service';
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
import {
  leagueCodeFromExport,
  resolveInternalStatus,
  resolveIsHot,
  resolveStartTime,
  teamCodeFromExport,
  toKickoffJson,
  toVenueJson,
  translationsFromZhiboNames,
} from './zhibo-match.mapper';
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
import { OutrightService } from './outright.service';

export type { MatchBetStatsSummary } from './match-bet-stats.service';

const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';

const PLAYER_PARLAY_MARKET_TYPES = PARLAY_MARKET_TYPES;

export type ListPublishedOptions = {
  /** 响应中是否包含 markets（列表页默认 false，仅摘要） */
  includeMarkets?: boolean;
  /** 仅返回串关玩法 markets（需 includeMarkets=true） */
  parlayMarketsOnly?: boolean;
};

@Injectable()
export class MatchesService {
  constructor(
    private prisma: PrismaService,
    private outright: OutrightService,
    private matchBetStats: MatchBetStatsService,
  ) {}

  async createLeague(code: string, translations: Record<string, string>) {
    const league = await this.prisma.league.create({ data: { code } });
    for (const [locale, value] of Object.entries(translations)) {
      await this.prisma.entityTranslation.create({
        data: {
          entityType: 'LEAGUE',
          entityId: league.id,
          locale,
          fieldName: 'name',
          value,
        },
      });
    }
    return league;
  }

  async createTeam(code: string, translations: Record<string, string>) {
    const team = await this.prisma.team.create({ data: { code } });
    for (const [locale, value] of Object.entries(translations)) {
      await this.prisma.entityTranslation.create({
        data: {
          entityType: 'TEAM',
          entityId: team.id,
          locale,
          fieldName: 'name',
          value,
        },
      });
    }
    return team;
  }

  async createMatch(data: {
    leagueId: bigint;
    homeTeamId: bigint;
    awayTeamId: bigint;
    startTime: Date;
    isHot?: boolean;
    displayOrder?: number;
    createdBy?: bigint;
    status?: string;
    publishTime?: Date;
    zhibo?: Partial<{
      officialMatchNo: number;
      stage: string;
      groupName: string;
      liveMatchId?: bigint;
      additionMatchId: bigint | null;
      channelId: string | null;
      matchName: string;
      venueJson: Prisma.InputJsonValue;
      kickoffJson: Prisma.InputJsonValue;
      externalStatus: string;
    }>;
  }) {
    const status = data.status ?? 'DRAFT';
    return this.prisma.match.create({
      data: {
        leagueId: data.leagueId,
        homeTeamId: data.homeTeamId,
        awayTeamId: data.awayTeamId,
        startTime: data.startTime,
        isHot: data.isHot ?? false,
        displayOrder: data.displayOrder ?? 0,
        createdBy: data.createdBy,
        status,
        publishTime: data.publishTime ?? (status === 'PUBLISHED' ? new Date() : undefined),
        officialMatchNo: data.zhibo?.officialMatchNo,
        stage: data.zhibo?.stage,
        groupName: data.zhibo?.groupName,
        liveMatchId: data.zhibo?.liveMatchId,
        additionMatchId: data.zhibo?.additionMatchId ?? undefined,
        channelId: data.zhibo?.channelId ?? undefined,
        matchName: data.zhibo?.matchName,
        venueJson: data.zhibo?.venueJson,
        kickoffJson: data.zhibo?.kickoffJson,
        externalStatus: data.zhibo?.externalStatus,
      },
    });
  }

  private async upsertEntityTranslations(
    entityType: 'LEAGUE' | 'TEAM',
    entityId: bigint,
    translations: Record<string, string>,
  ) {
    for (const [locale, value] of Object.entries(translations)) {
      await this.prisma.entityTranslation.upsert({
        where: {
          entityType_entityId_locale_fieldName: {
            entityType,
            entityId,
            locale,
            fieldName: 'name',
          },
        },
        create: { entityType, entityId, locale, fieldName: 'name', value },
        update: { value },
      });
    }
  }

  async upsertLeagueFromZhiboExport(league: ZhiboLeagueExport) {
    const code = leagueCodeFromExport(league);
    const record = await this.prisma.league.upsert({
      where: { code },
      create: { code, sportType: league.type || 'FOOTBALL' },
      update: { sportType: league.type || 'FOOTBALL' },
    });
    await this.upsertEntityTranslations('LEAGUE', record.id, {
      'zh-CN': league.zh,
      'en-US': league.en,
    });
    return record;
  }

  async upsertTeamFromZhiboExport(team: ZhiboTeamExport) {
    const translations = translationsFromZhiboNames(team.names, team.name);

    if (team.id != null) {
      const existing = await this.prisma.team.findFirst({
        where: { externalId: team.id },
      });
      if (existing) {
        const record = await this.prisma.team.update({
          where: { id: existing.id },
          data: {
            logoUrl: team.image || existing.logoUrl,
            externalId: team.id,
          },
        });
        await this.upsertEntityTranslations('TEAM', record.id, translations);
        return record;
      }
    }

    const code = teamCodeFromExport(team);
    const record = await this.prisma.team.upsert({
      where: { code },
      create: {
        code,
        externalId: team.id ?? undefined,
        logoUrl: team.image || undefined,
      },
      update: {
        logoUrl: team.image || undefined,
        externalId: team.id ?? undefined,
      },
    });

    await this.upsertEntityTranslations('TEAM', record.id, translations);
    return record;
  }

  private async findExistingZhiboMatch(
    leagueId: bigint,
    homeTeamId: bigint,
    awayTeamId: bigint,
    item: ZhiboMatchExport,
  ) {
    if (item.liveMatchId != null) {
      return this.prisma.match.findUnique({
        where: { liveMatchId: BigInt(item.liveMatchId) },
      });
    }
    if (item.officialMatchNo != null) {
      return this.prisma.match.findFirst({
        where: {
          leagueId,
          homeTeamId,
          awayTeamId,
          officialMatchNo: item.officialMatchNo,
        },
      });
    }
    return null;
  }

  async createPlatformLeague(data: {
    leagueEn: string;
    leagueZh: string;
    leagueMs?: string;
    logoUrl?: string;
    displayOrder?: number;
    isActive?: boolean;
  }) {
    const leagueEn = data.leagueEn.trim();
    const leagueZh = data.leagueZh.trim();
    if (!leagueEn && !leagueZh) {
      throw appBadRequest('LEAGUE_NAME_REQUIRED');
    }
    const league = await this.upsertLeagueFromZhiboExport({
      type: 'FOOTBALL',
      en: leagueEn || leagueZh,
      zh: leagueZh || leagueEn,
    });
    if (data.leagueMs?.trim()) {
      await this.upsertEntityTranslations('LEAGUE', league.id, {
        'ms-MY': data.leagueMs.trim(),
      });
    }
    const updates: { logoUrl?: string; displayOrder?: number; isActive: boolean } = {
      isActive: data.isActive ?? false,
    };
    if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
    if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
    await this.prisma.league.update({ where: { id: league.id }, data: updates });
    const [en, zh, ms] = await Promise.all([
      this.getTranslationExact('LEAGUE', league.id, 'en-US'),
      this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
      this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
    ]);
    const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: league.id } });
    return {
      id: fresh.id.toString(),
      code: fresh.code,
      logoUrl: fresh.logoUrl,
      displayOrder: fresh.displayOrder,
      isPublished: fresh.isActive,
      leagueEn: en,
      leagueZh: zh,
      leagueMs: ms,
    };
  }

  async updatePlatformLeague(
    leagueId: bigint,
    data: {
      leagueEn: string;
      leagueZh: string;
      leagueMs?: string;
      logoUrl?: string;
      displayOrder?: number;
      isActive?: boolean;
    },
  ) {
    const league = await this.prisma.league.findFirst({
      where: { id: leagueId, deletedAt: null },
    });
    if (!league) throw appNotFound('LEAGUE_NOT_FOUND');

    const leagueEn = data.leagueEn.trim();
    const leagueZh = data.leagueZh.trim();
    if (!leagueEn && !leagueZh) {
      throw appBadRequest('LEAGUE_NAME_REQUIRED');
    }

    await this.upsertEntityTranslations('LEAGUE', leagueId, {
      'zh-CN': leagueZh,
      'en-US': leagueEn,
      'ms-MY': (data.leagueMs ?? '').trim(),
    });

    const updates: { logoUrl?: string | null; displayOrder?: number; isActive?: boolean } = {};
    if (data.logoUrl !== undefined) {
      updates.logoUrl = data.logoUrl.trim() || null;
    }
    if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
    if (data.isActive !== undefined) {
      if (league.isActive && data.isActive === false) {
        const outright = await this.prisma.match.findFirst({
          where: { leagueId, isOutright: true, deletedAt: null },
          select: { status: true },
        });
        if (outright?.status === 'SETTLED') {
          throw appBadRequest('LEAGUE_UNPUBLISH_SETTLED');
        }
      }
      updates.isActive = data.isActive;
    }
    if (Object.keys(updates).length) {
      await this.prisma.league.update({ where: { id: leagueId }, data: updates });
    }

    if (data.isActive === true) {
      await this.outright.syncWithLeaguePublished(leagueId);
    }

    const [en, zh, ms] = await Promise.all([
      this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
      this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
      this.getTranslationExact('LEAGUE', leagueId, 'ms-MY'),
    ]);
    const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: leagueId } });
    return {
      id: fresh.id.toString(),
      code: fresh.code,
      logoUrl: fresh.logoUrl,
      displayOrder: fresh.displayOrder,
      isPublished: fresh.isActive,
      leagueEn: en,
      leagueZh: zh,
      leagueMs: ms,
    };
  }

  async listAdminLeagues(opts: {
    page: number;
    pageSize: number;
    keyword?: string;
    status?: string;
  }) {
    const skip = (opts.page - 1) * opts.pageSize;
    const kw = opts.keyword?.trim();
    let idFilter: bigint[] | undefined;

    // 状态仅用于单场计数/展开列表筛选，不隐藏无该状态单场的联赛（含新建空联赛）
    if (kw) {
      const ids = new Set<bigint>();
      const trRows = await this.prisma.entityTranslation.findMany({
        where: {
          entityType: 'LEAGUE',
          fieldName: 'name',
          value: { contains: kw, mode: 'insensitive' },
        },
        select: { entityId: true },
      });
      for (const r of trRows) ids.add(r.entityId);
      const matchWhere: Prisma.MatchWhereInput = {
        deletedAt: null,
        isOutright: false,
        OR: [
          { matchName: { contains: kw, mode: 'insensitive' } },
          { homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
          { awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
        ],
      };
      if (opts.status) matchWhere.status = opts.status;
      const matchLeagues = await this.prisma.match.findMany({
        where: matchWhere,
        select: { leagueId: true },
        distinct: ['leagueId'],
      });
      for (const m of matchLeagues) ids.add(m.leagueId);
      idFilter = [...ids];
      if (!idFilter.length) {
        return { items: [], total: 0, page: opts.page, pageSize: opts.pageSize };
      }
    }

    const where: Prisma.LeagueWhereInput = { deletedAt: null };
    if (idFilter) where.id = { in: idFilter };

    const [leagues, total] = await Promise.all([
      this.prisma.league.findMany({
        where,
        orderBy: [{ displayOrder: 'asc' }, { id: 'desc' }],
        skip,
        take: opts.pageSize,
      }),
      this.prisma.league.count({ where }),
    ]);

    const items = await Promise.all(
      leagues.map(async (league) => {
        const [leagueEn, leagueZh, leagueMs, matchCount] = await Promise.all([
          this.getTranslationExact('LEAGUE', league.id, 'en-US'),
          this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
          this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
          this.prisma.match.count({
            where: {
              leagueId: league.id,
              deletedAt: null,
              isOutright: false,
              ...(opts.status ? { status: opts.status } : {}),
            },
          }),
        ]);
        return {
          id: league.id.toString(),
          code: league.code,
          logoUrl: league.logoUrl,
          displayOrder: league.displayOrder,
          isPublished: league.isActive,
          leagueEn,
          leagueZh,
          leagueMs,
          matchCount,
          betStats: { betCount: 0, totalStake: '0', pendingCount: 0 },
        };
      }),
    );

    const leagueIds = leagues.map((l) => l.id);
    const leagueMatches = await this.prisma.match.findMany({
      where: {
        leagueId: { in: leagueIds },
        deletedAt: null,
        isOutright: false,
        ...(opts.status ? { status: opts.status } : {}),
      },
      select: { id: true, leagueId: true },
    });
    const fixtureTeamRows = await this.prisma.match.findMany({
      where: {
        leagueId: { in: leagueIds },
        deletedAt: null,
        isOutright: false,
      },
      select: {
        leagueId: true,
        homeTeam: { select: { id: true, code: true } },
        awayTeam: { select: { id: true, code: true } },
      },
    });
    const fixtureTeamSets = new Map<string, Set<string>>();
    for (const row of fixtureTeamRows) {
      const lid = row.leagueId.toString();
      if (!fixtureTeamSets.has(lid)) fixtureTeamSets.set(lid, new Set());
      const set = fixtureTeamSets.get(lid)!;
      for (const team of [row.homeTeam, row.awayTeam]) {
        if (team.code !== 'OUT') set.add(team.id.toString());
      }
    }
    const matchStats = await this.betStatsForMatches(
      leagueMatches.map((m) => m.id),
    );
    const leagueBetRollup = new Map<
      string,
      { betCount: number; totalStake: Decimal; pendingCount: number }
    >();
    for (const lm of leagueMatches) {
      const lid = lm.leagueId.toString();
      const cur = leagueBetRollup.get(lid) ?? {
        betCount: 0,
        totalStake: new Decimal(0),
        pendingCount: 0,
      };
      const ms = matchStats.get(lm.id.toString());
      if (ms) {
        cur.betCount += ms.betCount;
        cur.totalStake = cur.totalStake.add(new Decimal(ms.totalStake));
        cur.pendingCount += ms.pendingCount;
      }
      leagueBetRollup.set(lid, cur);
    }
    const outrightMatches = await this.prisma.match.findMany({
      where: {
        leagueId: { in: leagueIds },
        isOutright: true,
        deletedAt: null,
      },
      select: { id: true, leagueId: true },
    });
    const outrightTeamCounts = new Map<string, number>();
    if (outrightMatches.length > 0) {
      const matchIdToLeagueId = new Map(
        outrightMatches.map((m) => [m.id.toString(), m.leagueId.toString()]),
      );
      const outrightMatchIds = outrightMatches.map((m) => m.id);
      const markets = await this.prisma.market.findMany({
        where: {
          matchId: { in: outrightMatchIds },
          marketType: 'OUTRIGHT_WINNER',
        },
        select: { id: true, matchId: true },
      });
      if (markets.length > 0) {
        const marketIdToMatchId = new Map(
          markets.map((m) => [m.id.toString(), m.matchId.toString()]),
        );
        const selectionCounts = await this.prisma.marketSelection.groupBy({
          by: ['marketId'],
          where: {
            marketId: { in: markets.map((m) => m.id) },
            status: 'OPEN',
            selectionCode: { not: 'OUT' },
          },
          _count: { id: true },
        });
        for (const row of selectionCounts) {
          const matchId = marketIdToMatchId.get(row.marketId.toString());
          const leagueId = matchId ? matchIdToLeagueId.get(matchId) : undefined;
          if (leagueId) {
            outrightTeamCounts.set(leagueId, row._count.id);
          }
        }
      }
    }

    for (const item of items) {
      const roll = leagueBetRollup.get(item.id);
      if (roll) {
        item.betStats = {
          betCount: roll.betCount,
          totalStake: roll.totalStake.toString(),
          pendingCount: roll.pendingCount,
        };
      }
      (item as { fixtureTeamCount?: number }).fixtureTeamCount =
        fixtureTeamSets.get(item.id)?.size ?? 0;
      (item as { outrightTeamCount?: number }).outrightTeamCount =
        outrightTeamCounts.get(item.id) ?? 0;
    }

    return { items, total, page: opts.page, pageSize: opts.pageSize };
  }

  async listAdminLeagueMatches(
    leagueId: bigint,
    opts: {
      status?: string;
      keyword?: string;
      locale?: string;
      page?: number;
      pageSize?: number;
      hasBets?: string;
      orderBy?: string;
      startFrom?: Date;
      startTo?: Date;
    },
  ) {
    const where: Prisma.MatchWhereInput = {
      leagueId,
      deletedAt: null,
      isOutright: false,
    };
    if (opts.status) where.status = opts.status;
    if (opts.startFrom || opts.startTo) {
      where.startTime = {};
      if (opts.startFrom) where.startTime.gte = opts.startFrom;
      if (opts.startTo) where.startTime.lte = opts.startTo;
    }
    const kw = opts.keyword?.trim();
    if (kw) {
      where.OR = [
        { matchName: { contains: kw, mode: 'insensitive' } },
        { homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
        { awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
      ];
    }
    const page = Math.max(1, opts.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));

    if (opts.hasBets === 'true' || opts.orderBy === 'betCount' || opts.orderBy === 'totalStake') {
      const allRows = await this.prisma.match.findMany({
        where,
        include: { homeTeam: true, awayTeam: true },
      });
      const locale = opts.locale ?? 'zh-CN';
      const betStatsMap = await this.betStatsForMatches(allRows.map((m) => m.id));
      const itemsWithStats = await Promise.all(
        allRows.map(async (m) => {
          const [homeTeamName, awayTeamName] = await Promise.all([
            this.getTranslation('TEAM', m.homeTeamId, locale),
            this.getTranslation('TEAM', m.awayTeamId, locale),
          ]);
          const raw = betStatsMap.get(m.id.toString());
          const betCount = raw?.betCount ?? 0;
          const totalStake = raw?.totalStake ?? '0';
          const pendingBets = raw?.pendingCount ?? 0;
          return {
            id: m.id.toString(),
            status: m.status,
            isOutright: m.isOutright,
            isHot: m.isHot,
            displayOrder: m.displayOrder,
            startTime: m.startTime,
            matchName: m.matchName,
            homeTeamName,
            awayTeamName,
            homeTeam: { code: m.homeTeam.code },
            awayTeam: { code: m.awayTeam.code },
            betCount,
            totalStake,
            pendingBets,
          };
        }),
      );

      let filteredItems = itemsWithStats;
      if (opts.hasBets === 'true') {
        filteredItems = itemsWithStats.filter((item) => item.betCount > 0);
      }

      if (opts.orderBy === 'betCount') {
        filteredItems.sort((a, b) => b.betCount - a.betCount);
      } else if (opts.orderBy === 'totalStake') {
        filteredItems.sort((a, b) => {
          const stakeA = parseFloat(a.totalStake);
          const stakeB = parseFloat(b.totalStake);
          return stakeB - stakeA;
        });
      } else if (opts.orderBy === 'kickoffAsc') {
        filteredItems.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
      } else if (opts.orderBy === 'kickoffDesc') {
        filteredItems.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
      } else {
        filteredItems.sort((a, b) => {
          if (a.displayOrder !== b.displayOrder) {
            return a.displayOrder - b.displayOrder;
          }
          return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
        });
      }

      const total = filteredItems.length;
      const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
      return { items: paginatedItems, total, page, pageSize };
    }

    const orderBy =
      opts.orderBy === 'kickoffAsc'
        ? [{ startTime: 'asc' as const }, { displayOrder: 'asc' as const }]
        : opts.orderBy === 'kickoffDesc'
          ? [{ startTime: 'desc' as const }, { displayOrder: 'asc' as const }]
          : [{ displayOrder: 'asc' as const }, { startTime: 'desc' as const }];

    const [total, rows] = await Promise.all([
      this.prisma.match.count({ where }),
      this.prisma.match.findMany({
        where,
        include: { homeTeam: true, awayTeam: true },
        orderBy,
        skip: (page - 1) * pageSize,
        take: pageSize,
      }),
    ]);
    const locale = opts.locale ?? 'zh-CN';
    const betStatsMap = await this.betStatsForMatches(rows.map((m) => m.id));
    const items = await Promise.all(
      rows.map(async (m) => {
        const [homeTeamName, awayTeamName] = await Promise.all([
          this.getTranslation('TEAM', m.homeTeamId, locale),
          this.getTranslation('TEAM', m.awayTeamId, locale),
        ]);
        const raw = betStatsMap.get(m.id.toString());
        const betCount = raw?.betCount ?? 0;
        const totalStake = raw?.totalStake ?? '0';
        const pendingBets = raw?.pendingCount ?? 0;
        return {
          id: m.id.toString(),
          status: m.status,
          isOutright: m.isOutright,
          isHot: m.isHot,
          displayOrder: m.displayOrder,
          startTime: m.startTime,
          matchName: m.matchName,
          homeTeamName,
          awayTeamName,
          homeTeam: { code: m.homeTeam.code },
          awayTeam: { code: m.awayTeam.code },
          betCount,
          totalStake,
          pendingBets,
        };
      }),
    );
    return { items, total, page, pageSize };
  }

  /** 批量汇总多场关联注单（按 bet 去重计注单数） */
  async betStatsForMatches(
    matchIds: bigint[],
  ): Promise<Map<string, MatchBetStatsSummary>> {
    return this.matchBetStats.betStatsForMatches(matchIds);
  }

  private async upsertTeamByCode(data: {
    code: string;
    teamZh: string;
    teamEn: string;
    teamMs?: string;
    logoUrl?: string;
  }) {
    const code = data.code.trim().toUpperCase();
    if (!code) throw appBadRequest('TEAM_CODE_REQUIRED');
    const logoUrl = data.logoUrl?.trim() || undefined;
    const team = await this.prisma.team.upsert({
      where: { code },
      create: { code, logoUrl },
      update: logoUrl ? { logoUrl } : {},
    });
    const translations: Record<string, string> = {
      'zh-CN': data.teamZh.trim() || data.teamEn.trim(),
      'en-US': data.teamEn.trim() || data.teamZh.trim(),
    };
    if (data.teamMs?.trim()) translations['ms-MY'] = data.teamMs.trim();
    await this.upsertEntityTranslations('TEAM', team.id, translations);
    return team;
  }

  async createPlatformMatch(data: {
    leagueId?: bigint;
    leagueEn?: string;
    leagueZh?: string;
    leagueMs?: string;
    homeTeamCode?: string;
    awayTeamCode?: string;
    homeTeamZh: string;
    homeTeamEn: string;
    homeTeamMs?: string;
    awayTeamZh: string;
    awayTeamEn: string;
    awayTeamMs?: string;
    startTime: Date;
    isHot?: boolean;
    displayOrder?: number;
    matchName?: string;
    stage?: string;
    groupName?: string;
    leagueLogoUrl?: string;
    homeTeamLogoUrl?: string;
    awayTeamLogoUrl?: string;
    createdBy?: bigint;
  }) {
    const homeEn = data.homeTeamEn.trim();
    const homeZh = data.homeTeamZh.trim();
    const homeMs = data.homeTeamMs?.trim() ?? '';
    const awayEn = data.awayTeamEn.trim();
    const awayZh = data.awayTeamZh.trim();
    const awayMs = data.awayTeamMs?.trim() ?? '';
    if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) {
      throw appBadRequest('TEAMS_NAME_REQUIRED');
    }

    let league;
    if (data.leagueId) {
      league = await this.prisma.league.findFirst({
        where: { id: data.leagueId, deletedAt: null },
      });
      if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
    } else {
      const leagueEn = data.leagueEn?.trim() ?? '';
      const leagueZh = data.leagueZh?.trim() ?? '';
      const leagueMs = data.leagueMs?.trim() ?? '';
      if (!leagueEn && !leagueZh && !leagueMs) {
        throw appBadRequest('LEAGUE_NAME_REQUIRED');
      }
      league = await this.upsertLeagueFromZhiboExport({
        type: 'FOOTBALL',
        en: leagueEn || leagueZh || leagueMs,
        zh: leagueZh || leagueEn || leagueMs,
      });
      if (leagueMs) {
        await this.upsertEntityTranslations('LEAGUE', league.id, {
          'ms-MY': leagueMs,
        });
      }
      if (data.leagueLogoUrl?.trim()) {
        await this.prisma.league.update({
          where: { id: league.id },
          data: { logoUrl: data.leagueLogoUrl.trim() },
        });
      }
    }
    const homeCode = data.homeTeamCode?.trim().toUpperCase();
    const awayCode = data.awayTeamCode?.trim().toUpperCase();
    let homeTeam;
    let awayTeam;
    if (homeCode && awayCode) {
      if (homeCode === awayCode) {
        throw appBadRequest('TEAMS_SAME');
      }
      homeTeam = await this.upsertTeamByCode({
        code: homeCode,
        teamZh: homeZh,
        teamEn: homeEn,
        teamMs: homeMs,
        logoUrl: data.homeTeamLogoUrl,
      });
      awayTeam = await this.upsertTeamByCode({
        code: awayCode,
        teamZh: awayZh,
        teamEn: awayEn,
        teamMs: awayMs,
        logoUrl: data.awayTeamLogoUrl,
      });
    } else {
      homeTeam = await this.upsertTeamFromZhiboExport({
        id: null,
        name: homeEn || homeZh || homeMs,
        names: {
          zh: homeZh || null,
          en: homeEn || null,
          zhTw: '',
          vi: null,
          km: null,
          ms: homeMs || null,
        },
        image: data.homeTeamLogoUrl?.trim() || '',
      });
      awayTeam = await this.upsertTeamFromZhiboExport({
        id: null,
        name: awayEn || awayZh || awayMs,
        names: {
          zh: awayZh || null,
          en: awayEn || null,
          zhTw: '',
          vi: null,
          km: null,
          ms: awayMs || null,
        },
        image: data.awayTeamLogoUrl?.trim() || '',
      });
    }

    if (homeTeam.id === awayTeam.id) {
      throw appBadRequest('TEAMS_SAME');
    }

    const matchName =
      data.matchName?.trim() ||
      `${homeEn || homeZh || homeMs} - ${awayEn || awayZh || awayMs}`;

    return this.createMatch({
      leagueId: league.id,
      homeTeamId: homeTeam.id,
      awayTeamId: awayTeam.id,
      startTime: data.startTime,
      isHot: data.isHot ?? false,
      displayOrder: data.displayOrder ?? 0,
      createdBy: data.createdBy,
      status: 'DRAFT',
      zhibo: {
        matchName,
        stage: data.stage?.trim() || undefined,
        groupName: data.groupName?.trim() || undefined,
      },
    });
  }

  private async requireAdminMatch(matchId: bigint) {
    const match = await this.prisma.match.findFirst({
      where: { id: matchId, deletedAt: null },
      include: { homeTeam: true, awayTeam: true, league: true },
    });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    return match;
  }

  async getAdminMatchDetail(matchId: bigint) {
    const match = await this.requireAdminMatch(matchId);
    let scoreRow = await this.prisma.matchScore.findUnique({
      where: { matchId },
    });
    if (!scoreRow) {
      const previewBatch = await this.prisma.settlementBatch.findFirst({
        where: { matchId, status: 'PREVIEW' },
        orderBy: { createdAt: 'desc' },
      });
      if (previewBatch) {
        scoreRow = {
          htHomeScore: previewBatch.htHomeScore,
          htAwayScore: previewBatch.htAwayScore,
          ftHomeScore: previewBatch.ftHomeScore,
          ftAwayScore: previewBatch.ftAwayScore,
          homeCorners: previewBatch.homeCorners,
          awayCorners: previewBatch.awayCorners,
          homeYellowCards: previewBatch.homeYellowCards,
          awayYellowCards: previewBatch.awayYellowCards,
          homeRedCards: previewBatch.homeRedCards,
          awayRedCards: previewBatch.awayRedCards,
          homeCards: previewBatch.homeCards,
          awayCards: previewBatch.awayCards,
          winnerTeamId: null,
        } as any;
      }
    }
    const markets = await this.prisma.market.findMany({
      where: { matchId },
      include: { selections: { orderBy: { sortOrder: 'asc' } } },
      orderBy: { sortOrder: 'asc' },
    });
    const [leagueEn, leagueZh, leagueMs, homeEn, homeZh, homeMs, awayEn, awayZh, awayMs] =
      await Promise.all([
      this.getTranslationExact('LEAGUE', match.leagueId, 'en-US'),
      this.getTranslationExact('LEAGUE', match.leagueId, 'zh-CN'),
      this.getTranslationExact('LEAGUE', match.leagueId, 'ms-MY'),
      this.getTranslationExact('TEAM', match.homeTeamId, 'en-US'),
      this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
      this.getTranslationExact('TEAM', match.homeTeamId, 'ms-MY'),
      this.getTranslationExact('TEAM', match.awayTeamId, 'en-US'),
      this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
      this.getTranslationExact('TEAM', match.awayTeamId, 'ms-MY'),
    ]);
    return {
      id: match.id.toString(),
      status: match.status,
      isOutright: match.isOutright,
      isHot: match.isHot,
      displayOrder: match.displayOrder,
      startTime: match.startTime.toISOString(),
      leagueId: match.leagueId.toString(),
      leagueCode: match.league.code,
      leagueEn,
      leagueZh,
      leagueMs,
      leagueLogoUrl: match.league.logoUrl ?? '',
      homeTeamEn: homeEn,
      homeTeamZh: homeZh,
      homeTeamMs: homeMs,
      homeTeamCode: match.homeTeam.code,
      homeTeamLogoUrl: match.homeTeam.logoUrl ?? '',
      awayTeamEn: awayEn,
      awayTeamZh: awayZh,
      awayTeamMs: awayMs,
      awayTeamCode: match.awayTeam.code,
      awayTeamLogoUrl: match.awayTeam.logoUrl ?? '',
      matchName: match.matchName ?? '',
      stage: match.stage ?? '',
      groupName: match.groupName ?? '',
      score: scoreRow
        ? {
            htHome: scoreRow.htHomeScore ?? 0,
            htAway: scoreRow.htAwayScore ?? 0,
            ftHome: scoreRow.ftHomeScore ?? 0,
            ftAway: scoreRow.ftAwayScore ?? 0,
            homeCorners: scoreRow.homeCorners ?? null,
            awayCorners: scoreRow.awayCorners ?? null,
            homeYellowCards: scoreRow.homeYellowCards ?? null,
            awayYellowCards: scoreRow.awayYellowCards ?? null,
            homeRedCards: scoreRow.homeRedCards ?? null,
            awayRedCards: scoreRow.awayRedCards ?? null,
            homeCards: scoreRow.homeCards ?? null,
            awayCards: scoreRow.awayCards ?? null,
            winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
          }
        : null,
      markets: markets.map((m) => ({
        id: m.id.toString(),
        marketType: m.marketType,
        marketKey: m.marketKey ?? m.marketType,
        lineKey: m.lineKey ?? null,
        period: m.period,
        lineValue: m.lineValue != null ? Number(m.lineValue) : null,
        paramsJson: m.paramsJson ?? null,
        status: m.status,
        allowSingle: m.allowSingle,
        allowParlay: m.allowParlay,
        showOnPlayer: m.showOnPlayer,
        promoLabel: m.promoLabel ?? '',
        promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
        nameI18n: sanitizeLocalizedText(m.nameI18n),
        sortOrder: m.sortOrder,
        selections: m.selections.map((s) => ({
          id: s.id.toString(),
          selectionCode: s.selectionCode,
          selectionName: s.selectionName,
          nameI18n: sanitizeLocalizedText(s.nameI18n),
          odds: Number(s.odds),
          status: s.status,
          sortOrder: s.sortOrder,
        })),
      })),
    };
  }

  async updatePlatformMatch(
    matchId: bigint,
    data: {
      homeTeamZh: string;
      homeTeamEn: string;
      homeTeamMs?: string;
      awayTeamZh: string;
      awayTeamEn: string;
      awayTeamMs?: string;
      startTime: Date;
      isHot?: boolean;
      displayOrder?: number;
      matchName?: string;
      stage?: string;
      groupName?: string;
      homeTeamLogoUrl?: string;
      awayTeamLogoUrl?: string;
      updatedBy?: bigint;
    },
  ) {
    const match = await this.requireAdminMatch(matchId);
    if (match.isOutright) {
      throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
    }
    if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
      throw appBadRequest('MATCH_NOT_EDITABLE');
    }

    const matchName =
      data.matchName?.trim() ||
      `${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`;

    await Promise.all([
      this.upsertEntityTranslations('TEAM', match.homeTeamId, {
        'zh-CN': data.homeTeamZh.trim(),
        'en-US': data.homeTeamEn.trim(),
        'ms-MY': (data.homeTeamMs ?? '').trim(),
      }),
      this.upsertEntityTranslations('TEAM', match.awayTeamId, {
        'zh-CN': data.awayTeamZh.trim(),
        'en-US': data.awayTeamEn.trim(),
        'ms-MY': (data.awayTeamMs ?? '').trim(),
      }),
    ]);

    const logoUpdates: Promise<unknown>[] = [];
    if (data.homeTeamLogoUrl !== undefined) {
      logoUpdates.push(
        this.prisma.team.update({
          where: { id: match.homeTeamId },
          data: { logoUrl: data.homeTeamLogoUrl.trim() || null },
        }),
      );
    }
    if (data.awayTeamLogoUrl !== undefined) {
      logoUpdates.push(
        this.prisma.team.update({
          where: { id: match.awayTeamId },
          data: { logoUrl: data.awayTeamLogoUrl.trim() || null },
        }),
      );
    }
    if (logoUpdates.length) await Promise.all(logoUpdates);

    return this.prisma.match.update({
      where: { id: matchId },
      data: {
        startTime: data.startTime,
        isHot: data.isHot ?? match.isHot,
        displayOrder: data.displayOrder ?? match.displayOrder,
        matchName,
        stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
        groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
        updatedBy: data.updatedBy,
      },
    });
  }

  async deleteMatch(matchId: bigint) {
    const match = await this.requireAdminMatch(matchId);
    if (match.isOutright) {
      throw appBadRequest('OUTRIGHT_DELETE_FORBIDDEN');
    }
    if (match.status !== 'DRAFT') {
      throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
    }
    const betCount = await this.prisma.betSelection.count({ where: { matchId } });
    if (betCount > 0) {
      throw appBadRequest('MATCH_HAS_BETS');
    }
    return this.prisma.match.update({
      where: { id: matchId },
      data: { deletedAt: new Date() },
    });
  }

  async createMatchFromZhiboExport(
    item: ZhiboMatchExport,
    createdBy?: bigint,
    opts?: { asDraft?: boolean },
  ) {
    const league = await this.upsertLeagueFromZhiboExport(item.league);
    const [homeTeam, awayTeam] = await Promise.all([
      this.upsertTeamFromZhiboExport(item.homeTeam),
      this.upsertTeamFromZhiboExport(item.awayTeam),
    ]);

    const status = opts?.asDraft ? 'DRAFT' : resolveInternalStatus(item);
    const startTime = resolveStartTime(item.kickoff);
    const liveMatchId =
      item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined;
    const payload = {
      leagueId: league.id,
      homeTeamId: homeTeam.id,
      awayTeamId: awayTeam.id,
      startTime,
      isHot: resolveIsHot(item),
      displayOrder: item.sortOrder,
      createdBy,
      status,
      publishTime: status === 'PUBLISHED' ? new Date() : undefined,
      zhibo: {
        officialMatchNo: item.officialMatchNo,
        stage: item.stage,
        groupName: item.groupName,
        liveMatchId,
        additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null,
        channelId: item.channelId,
        matchName: item.matchName,
        venueJson: toVenueJson(item.venue),
        kickoffJson: toKickoffJson(item.kickoff),
        externalStatus: item.status.state,
      },
    };

    const existing = await this.findExistingZhiboMatch(
      league.id,
      homeTeam.id,
      awayTeam.id,
      item,
    );
    if (existing) {
      return this.prisma.match.update({
        where: { id: existing.id },
        data: {
          leagueId: payload.leagueId,
          homeTeamId: payload.homeTeamId,
          awayTeamId: payload.awayTeamId,
          startTime: payload.startTime,
          isHot: payload.isHot,
          displayOrder: payload.displayOrder,
          status: payload.status,
          publishTime: existing.publishTime ?? payload.publishTime,
          officialMatchNo: payload.zhibo.officialMatchNo,
          stage: payload.zhibo.stage,
          groupName: payload.zhibo.groupName,
          liveMatchId: payload.zhibo.liveMatchId ?? undefined,
          additionMatchId: payload.zhibo.additionMatchId ?? undefined,
          channelId: payload.zhibo.channelId ?? undefined,
          matchName: payload.zhibo.matchName,
          venueJson: payload.zhibo.venueJson,
          kickoffJson: payload.zhibo.kickoffJson,
          externalStatus: payload.zhibo.externalStatus,
          updatedBy: createdBy,
        },
      });
    }

    return this.createMatch(payload);
  }

  async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) {
    if (!bundle.matches?.length) {
      throw appBadRequest('MATCHES_ARRAY_REQUIRED');
    }

    const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = [];

    for (const item of bundle.matches) {
      try {
        const match = await this.createMatchFromZhiboExport(item, createdBy, { asDraft: true });
        results.push({
          liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
          id: match.id.toString(),
          status: match.status,
        });
      } catch (err) {
        const message = err instanceof Error ? err.message : 'import failed';
        results.push({
          liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
          id: '',
          status: 'error',
          reason: message,
        });
      }
    }

    return {
      total: bundle.matches.length,
      imported: results.filter((r) => !r.skipped && r.status !== 'error').length,
      skipped: results.filter((r) => r.skipped).length,
      failed: results.filter((r) => r.status === 'error').length,
      results,
    };
  }

  async publishMatch(matchId: bigint) {
    return this.prisma.match.update({
      where: { id: matchId },
      data: { status: 'PUBLISHED', publishTime: new Date() },
    });
  }

  async unpublishMatch(matchId: bigint) {
    const match = await this.requireAdminMatch(matchId);
    if (match.isOutright) {
      throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
    }
    const allowed = ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'];
    if (!allowed.includes(match.status)) {
      throw appBadRequest('MATCH_UNPUBLISH_FORBIDDEN');
    }
    if (match.status === 'PENDING_SETTLEMENT') {
      await this.prisma.settlementBatch.deleteMany({
        where: { matchId, status: 'PREVIEW' },
      });
    }
    return this.prisma.match.update({
      where: { id: matchId },
      data: { status: 'DRAFT', closeTime: null },
    });
  }

  async closeMatch(matchId: bigint) {
    return this.prisma.match.update({
      where: { id: matchId },
      data: { status: 'CLOSED', closeTime: new Date() },
    });
  }

  async reopenMatch(matchId: bigint, startTime?: Date) {
    const match = await this.requireAdminMatch(matchId);

    if (match.isOutright) {
      const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
      if (scoreRow?.winnerTeamId) throw appBadRequest('MATCH_NOT_REOPENABLE');
      if (match.status === 'SETTLED') throw appBadRequest('MATCH_NOT_REOPENABLE');
      const reopenable =
        match.status === 'CLOSED' || match.status === 'PENDING_SETTLEMENT';
      if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
      if (match.status === 'PENDING_SETTLEMENT') {
        await this.prisma.settlementBatch.deleteMany({
          where: { matchId, status: 'PREVIEW' },
        });
      }
      return this.prisma.match.update({
        where: { id: matchId },
        data: { status: 'PUBLISHED', closeTime: null },
      });
    }

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

    const reopenable =
      match.status === 'CLOSED' ||
      (match.status === 'PENDING_SETTLEMENT' && !scoreRow);
    if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');

    if (match.status === 'PENDING_SETTLEMENT') {
      await this.prisma.settlementBatch.deleteMany({
        where: { matchId, status: 'PREVIEW' },
      });
    }

    const effectiveStart = startTime ?? match.startTime;
    if (!isPreMatchKickoff(effectiveStart)) {
      throw appBadRequest('MATCH_REOPEN_KICKOFF_REQUIRED');
    }

    return this.prisma.match.update({
      where: { id: matchId },
      data: {
        status: 'PUBLISHED',
        closeTime: null,
        ...(startTime ? { startTime } : {}),
      },
    });
  }

  async cancelMatch(matchId: bigint) {
    return this.prisma.match.update({
      where: { id: matchId },
      data: { status: 'CANCELLED' },
    });
  }

  private async getTranslationExact(entityType: string, entityId: bigint, locale: string) {
    const row = await this.prisma.entityTranslation.findFirst({
      where: { entityType, entityId, locale, fieldName: 'name' },
    });
    return row?.value ?? '';
  }

  async getTranslation(entityType: string, entityId: bigint, locale: string) {
    const translations = await this.prisma.entityTranslation.findMany({
      where: { entityType, entityId },
    });
    const map = Object.fromEntries(
      translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
    );
    return resolveTranslationFallback(map, locale);
  }

  async enrichMatch(
    match: Record<string, unknown>,
    locale: string,
    options?: { omitMarkets?: boolean },
  ) {
    const m = match as {
      id: bigint;
      leagueId: bigint;
      homeTeamId: bigint;
      awayTeamId: bigint;
      startTime: Date;
      status?: string;
      isHot?: boolean;
      displayOrder?: number;
      matchName?: string | null;
      stage?: string | null;
      groupName?: string | null;
      homeTeam?: { code: string; logoUrl?: string | null };
      awayTeam?: { code: string; logoUrl?: string | null };
      league?: { logoUrl?: string | null };
      score?: {
        htHomeScore: number | null;
        htAwayScore: number | null;
        ftHomeScore: number | null;
        ftAwayScore: number | null;
        homeCorners?: number | null;
        awayCorners?: number | null;
        homeYellowCards?: number | null;
        awayYellowCards?: number | null;
        homeRedCards?: number | null;
        awayRedCards?: number | null;
        homeCards?: number | null;
        awayCards?: number | null;
      } | null;
      markets?: Array<Record<string, unknown>>;
    };
    const [leagueName, homeName, awayName] = await Promise.all([
      this.getTranslation('LEAGUE', m.leagueId, locale),
      this.getTranslation('TEAM', m.homeTeamId, locale),
      this.getTranslation('TEAM', m.awayTeamId, locale),
    ]);
    const base = {
      id: m.id.toString(),
      leagueId: m.leagueId.toString(),
      leagueName,
      leagueLogoUrl: m.league?.logoUrl ?? null,
      homeTeamName: homeName,
      awayTeamName: awayName,
      homeTeamCode: m.homeTeam?.code ?? '',
      awayTeamCode: m.awayTeam?.code ?? '',
      homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null,
      awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
      startTime: m.startTime.toISOString(),
      isHot: m.status === 'SETTLED' ? false : (m.isHot ?? false),
      displayOrder: m.displayOrder ?? 0,
      matchName: m.matchName ?? null,
      stage: m.stage ?? null,
      groupName: m.groupName ?? null,
      status: m.status ?? 'PUBLISHED',
      score: m.score
        ? {
            htHome: m.score.htHomeScore ?? null,
            htAway: m.score.htAwayScore ?? null,
            ftHome: m.score.ftHomeScore ?? null,
            ftAway: m.score.ftAwayScore ?? null,
            homeCorners: m.score.homeCorners ?? null,
            awayCorners: m.score.awayCorners ?? null,
            homeYellowCards: m.score.homeYellowCards ?? null,
            awayYellowCards: m.score.awayYellowCards ?? null,
            homeRedCards: m.score.homeRedCards ?? null,
            awayRedCards: m.score.awayRedCards ?? null,
            homeCards: m.score.homeCards ?? null,
            awayCards: m.score.awayCards ?? null,
          }
        : null,
      bettingOpen: this.isMatchBettingOpen({
        status: m.status,
        startTime: m.startTime,
        markets: (m.markets ?? []) as Array<{
          status: string;
          selections: Array<{ status: string }>;
        }>,
      }),
      matchPhase: this.resolvePlayerMatchPhase(m.status ?? 'PUBLISHED', {
        status: m.status,
        startTime: m.startTime,
        markets: (m.markets ?? []) as Array<{
          status: string;
          selections: Array<{ status: string }>;
        }>,
      }),
    };
    if (m.markets && !options?.omitMarkets) {
      return {
        ...base,
        markets: m.markets
          .filter(
            (market) =>
              ((market.showOnPlayer as boolean | undefined) ?? true) &&
              isSettlementSupportedMarketType(market.marketType as string),
          )
          .map((market) => ({
            id: (market.id as bigint).toString(),
            marketType: market.marketType as string,
            marketKey: (market.marketKey as string | null | undefined) ?? (market.marketType as string),
            lineKey: (market.lineKey as string | null | undefined) ?? null,
            period: market.period as string,
            status: (market.status as string) ?? 'OPEN',
            lineValue: market.lineValue != null ? Number(market.lineValue) : null,
            allowSingle: (market.allowSingle as boolean | undefined) ?? true,
            allowParlay: (market.allowParlay as boolean | undefined) ?? true,
            marketDisplayName:
              resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType as string, locale)) ||
              (market.marketType as string),
            promoLabel:
              resolveMarketText(
                market.promoLabelI18n,
                locale,
                (market.promoLabel as string | null | undefined) ?? '',
              ) || null,
            selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
              id: (s.id as bigint).toString(),
              selectionCode: s.selectionCode as string,
              selectionName: s.selectionName as string,
              selectionDisplayName:
                resolveMarketText(
                  s.nameI18n,
                  locale,
                  defaultSelectionName(market.marketType as string, s.selectionCode as string, locale) ||
                    (s.selectionName as string),
                ) || (s.selectionName as string),
              status: (s.status as string) ?? 'OPEN',
              odds: Number(s.odds),
              oddsVersion: (s.oddsVersion as bigint).toString(),
            })),
          })),
      };
    }
    return base;
  }

  private resolvePlayerMatchPhaseFromStatus(
    status: string,
    startTime: Date,
  ): 'open' | 'closed_pending' | 'settled' {
    if (status === 'SETTLED') return 'settled';
    if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
    if (status === 'PUBLISHED' && !isPreMatchKickoff(startTime)) return 'closed_pending';
    return 'open';
  }

  private resolvePlayerMatchPhase(
    status: string,
    m: {
      status?: string;
      startTime: Date;
      markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
    },
  ): 'open' | 'closed_pending' | 'settled' {
    if (status === 'SETTLED') return 'settled';
    if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
    if (status === 'PUBLISHED' && !this.isMatchBettingOpen(m)) return 'closed_pending';
    return 'open';
  }

  private isMatchBettingOpen(m: {
    status?: string;
    startTime: Date;
    markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
  }): boolean {
    if (m.status !== 'PUBLISHED') return false;
    if (!isPreMatchKickoff(m.startTime)) return false;
    return (m.markets ?? []).some(
      (mk) => mk.status === 'OPEN' && mk.selections.some((s) => s.status === 'OPEN'),
    );
  }

  private playerMarketInclude = {
    where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
    include: {
      selections: {
        where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
        orderBy: { sortOrder: 'asc' as const },
      },
    },
    orderBy: { sortOrder: 'asc' as const },
  };

  /** 仅 status 字段，用于列表页计算 bettingOpen，不返回给客户端 */
  private playerMarketStatusInclude = {
    where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
    select: {
      status: true,
      selections: { select: { status: true } },
    },
  };

  async listPublished(
    locale = 'en-US',
    leagueId?: bigint,
    options?: ListPublishedOptions,
  ) {
    const includeMarkets = options?.includeMarkets ?? true;
    const parlayMarketsOnly = options?.parlayMarketsOnly ?? false;

    const marketWhere = {
      status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
      showOnPlayer: true,
      ...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
    };

    const marketsRelation = includeMarkets
      ? {
          where: marketWhere,
          include: {
            selections: {
              where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
              orderBy: { sortOrder: 'asc' as const },
            },
          },
          orderBy: { sortOrder: 'asc' as const },
        }
      : this.playerMarketStatusInclude;

    const matches = await this.prisma.match.findMany({
      where: {
        status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
        isOutright: false,
        sportType: 'FOOTBALL',
        deletedAt: null,
        league: { isActive: true, deletedAt: null },
        ...(leagueId ? { leagueId } : {}),
      },
      include: {
        league: true,
        homeTeam: true,
        awayTeam: true,
        score: true,
        markets: marketsRelation,
      },
      orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
    });

    const enriched = await Promise.all(
      matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
    );

    if (!includeMarkets || !parlayMarketsOnly) return enriched;

    return enriched.filter(
      (m) =>
        Array.isArray((m as { markets?: unknown[] }).markets) &&
        (m as { markets: unknown[] }).markets.length > 0,
    );
  }

  /** 未来 N 天内开赛的已发布赛事（按开赛时间升序，不限 isHot） */
  async listUpcomingPublished(
    locale = 'en-US',
    options?: { limit?: number; days?: number },
  ) {
    const limit = options?.limit ?? 50;
    const days = options?.days ?? 3;
    const now = new Date();
    const end = new Date(now);
    end.setDate(end.getDate() + days);

    const matches = await this.prisma.match.findMany({
      where: {
        status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
        isOutright: false,
        sportType: 'FOOTBALL',
        deletedAt: null,
        startTime: { gte: now, lte: end },
        league: { isActive: true, deletedAt: null },
      },
      include: {
        league: true,
        homeTeam: true,
        awayTeam: true,
        score: true,
        markets: this.playerMarketStatusInclude,
      },
      orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
      take: limit,
    });

    return Promise.all(
      matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
    );
  }

  async getMatchDetail(matchId: bigint, locale = 'en-US') {
    const match = await this.prisma.match.findFirst({
      where: {
        id: matchId,
        deletedAt: null,
        sportType: 'FOOTBALL',
        isOutright: false,
        status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
        league: { isActive: true, deletedAt: null },
      },
      include: {
        league: true,
        homeTeam: true,
        awayTeam: true,
        markets: this.playerMarketInclude,
        score: true,
      },
    });
    if (!match) throw appNotFound('MATCH_NOT_FOUND');
    return this.enrichMatch(match, locale);
  }

  async getSelectionsOdds(ids: bigint[]) {
    const selections = await this.prisma.marketSelection.findMany({
      where: { id: { in: ids } },
      include: { market: { include: { match: true } } },
    });

    return selections.map((sel) => ({
      id: sel.id.toString(),
      odds: sel.odds.toString(),
      oddsVersion: sel.oddsVersion.toString(),
      status: sel.status,
      marketStatus: sel.market.status,
      marketShowOnPlayer: sel.market.showOnPlayer,
      matchStatus: sel.market.match.status,
      matchId: sel.market.match.id.toString(),
    }));
  }

  async listOutrights(locale = 'en-US') {
    try {
      await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
    } catch {
      /* 联赛未 seed 时忽略，仍返回已有数据 */
    }

    const matches = await this.prisma.match.findMany({
      where: {
        status: 'PUBLISHED',
        isOutright: true,
        sportType: 'FOOTBALL',
        deletedAt: null,
      },
      include: {
        markets: {
          where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
          include: {
            selections: {
              where: { status: 'OPEN' },
              orderBy: { sortOrder: 'asc' },
            },
          },
        },
      },
      orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
    });

    const results = [];
    for (const match of matches) {
      const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale);
      const market = match.markets[0];
      if (!market) continue;

      const selections = await Promise.all(
        market.selections
          .filter((sel) => sel.selectionCode !== OUTRIGHT_PLACEHOLDER_CODE)
          .map(async (sel) => {
            const team = await this.prisma.team.findUnique({
              where: { code: sel.selectionCode },
            });
            const teamName = team
              ? await this.getTranslation('TEAM', team.id, locale)
              : sel.selectionName;
            return {
              id: sel.id.toString(),
              teamCode: sel.selectionCode,
              teamName,
              rank: sel.sortOrder + 1,
              odds: sel.odds.toString(),
              oddsVersion: sel.oddsVersion.toString(),
            };
          }),
      );

      if (selections.length === 0) continue;

      results.push({
        id: match.id.toString(),
        leagueId: match.leagueId.toString(),
        leagueName,
        title: `*${leagueName} 冠军`,
        marketId: market.id.toString(),
        selections,
      });
    }
    return results;
  }

  private marketLabelKey(marketType: string, locale = 'zh-CN'): string {
    type LangMap = Record<string, string>;
    const labels: Record<string, LangMap> = {
      FT_1X2:           { 'zh-CN': '全场独赢',     'en-US': 'FT 1X2',          'ms-MY': '1X2 Penuh' },
      FT_HANDICAP:      { 'zh-CN': '全场让球',     'en-US': 'FT Handicap',      'ms-MY': 'Handicap Penuh' },
      FT_OVER_UNDER:    { 'zh-CN': '全场大小',     'en-US': 'FT O/U',           'ms-MY': 'Atas/Bawah Penuh' },
      FT_ODD_EVEN:      { 'zh-CN': '全场单双',     'en-US': 'FT Odd/Even',      'ms-MY': 'Ganjil/Genap Penuh' },
      HT_1X2:           { 'zh-CN': '半场独赢',     'en-US': 'HT 1X2',           'ms-MY': '1X2 Separuh' },
      HT_HANDICAP:      { 'zh-CN': '半场让球',     'en-US': 'HT Handicap',      'ms-MY': 'Handicap Separuh' },
      HT_OVER_UNDER:    { 'zh-CN': '半场大小',     'en-US': 'HT O/U',           'ms-MY': 'Atas/Bawah Separuh' },
      OUTRIGHT_WINNER:  { 'zh-CN': '冠军',         'en-US': 'Outright',         'ms-MY': 'Juara' },
      FT_CORRECT_SCORE: { 'zh-CN': '波胆',         'en-US': 'Correct Score',    'ms-MY': 'Skor Tepat' },
      HT_CORRECT_SCORE: { 'zh-CN': '上半场波胆',   'en-US': '1H Correct Score', 'ms-MY': 'Skor Tepat PB1' },
      SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆',   'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' },
    };
    const entry = labels[marketType];
    if (!entry) return defaultMarketName(marketType, locale);
    return entry[locale] ?? entry['en-US'] ?? defaultMarketName(marketType, locale);
  }

  async enrichBetsForHistory(
    bets: Array<{
      betNo: string;
      betType: string;
      stake: unknown;
      totalOdds: unknown;
      potentialReturn: unknown;
      actualReturn: unknown;
      status: string;
      placedAt: Date;
      isCashbacked?: boolean;
      selections: Array<{
        matchId: bigint | null;
        marketType: string;
        marketNameSnapshot?: string | null;
        selectionNameSnapshot: string;
        odds: unknown;
        resultStatus?: string | null;
      }>;
    }>,
    locale: string,
  ) {
    const matchIds = [
      ...new Set(
        bets.flatMap((b) =>
          b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
        ),
      ),
    ];

    const matches =
      matchIds.length > 0
        ? await this.prisma.match.findMany({
            where: { id: { in: matchIds } },
            include: { homeTeam: true, awayTeam: true, score: true },
          })
        : [];

    const matchMeta = new Map<
      string,
      {
        leagueName: string;
        matchTitle: string;
        isOutright: boolean;
        matchPhase: 'open' | 'closed_pending' | 'settled';
        score: { ht: string | null; ft: string | null } | null;
      }
    >();

    for (const m of matches) {
      const [leagueName, homeName, awayName] = await Promise.all([
        this.getTranslation('LEAGUE', m.leagueId, locale),
        this.getTranslation('TEAM', m.homeTeamId, locale),
        this.getTranslation('TEAM', m.awayTeamId, locale),
      ]);
      const s = m.score;
      const ftScore =
        s?.ftHomeScore != null && s?.ftAwayScore != null
          ? `${s.ftHomeScore}-${s.ftAwayScore}`
          : null;
      const htScore =
        s?.htHomeScore != null && s?.htAwayScore != null
          ? `${s.htHomeScore}-${s.htAwayScore}`
          : null;
      matchMeta.set(m.id.toString(), {
        leagueName,
        matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
        isOutright: m.isOutright,
        matchPhase: this.resolvePlayerMatchPhaseFromStatus(m.status, m.startTime),
        score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
      });
    }

    return bets.map((bet) => {
      const firstMatchId = bet.selections.find((s) => s.matchId)?.matchId?.toString();
      const meta = firstMatchId ? matchMeta.get(firstMatchId) : undefined;
      const isParlay = bet.betType === 'PARLAY' || bet.selections.length > 1;

      const legs = bet.selections.map((sel) => {
        const mid = sel.matchId?.toString();
        const m = mid ? matchMeta.get(mid) : undefined;
        return {
          marketType: sel.marketType,
          marketLabel: sel.marketNameSnapshot || this.marketLabelKey(sel.marketType, locale),
          selectionName: sel.selectionNameSnapshot,
          odds: sel.odds,
          resultStatus: sel.resultStatus,
          matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
          leagueName: m?.leagueName ?? '',
          score: m?.score ?? null,
        };
      });

      const firstScore = meta?.score ?? legs[0]?.score ?? null;

      return {
        betNo: bet.betNo,
        betType: bet.betType,
        stake: bet.stake,
        totalOdds: bet.totalOdds,
        potentialReturn: bet.potentialReturn,
        actualReturn: bet.actualReturn,
        status: bet.status,
        placedAt: bet.placedAt,
        isCashbacked: bet.isCashbacked ?? false,
        leagueName: isParlay
          ? 'Parlay'
          : meta?.leagueName ?? legs[0]?.leagueName ?? '',
        legCount: bet.selections.length,
        matchTitle: isParlay
          ? ''
          : meta?.matchTitle ?? legs[0]?.matchTitle ?? bet.betNo,
        pickLabel: isParlay
          ? ''
          : `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
        legs,
        isParlay,
        matchScore: isParlay ? null : firstScore,
        matchPhase: isParlay ? null : meta?.matchPhase ?? null,
      };
    });
  }

  @Cron(CronExpression.EVERY_MINUTE)
  async autoCloseMatches() {
    const now = new Date();
    await this.prisma.match.updateMany({
      where: {
        status: 'PUBLISHED',
        isOutright: false,
        startTime: { lte: now },
      },
      data: { status: 'CLOSED', closeTime: now },
    });
  }
}
