import {
  Controller,
  Delete,
  Get,
  Post,
  Put,
  Patch,
  Body,
  Param,
  Query,
  UploadedFile,
  UseGuards,
  UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile, unlink, readdir, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { DepositScreenshotCleanupService } from '../../domains/deposit/deposit-screenshot-cleanup.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
import { AdminStaffService } from '../../domains/identity/admin-staff.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { CatalogArchiveService } from '../../domains/catalog/catalog-archive.service';
import { OutrightService } from '../../domains/catalog/outright.service';
import { MarketsService } from '../../domains/odds/markets.service';
import { SettlementService } from '../../domains/settlement/settlement.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { I18nService } from '../../domains/operations/i18n/i18n.service';
import { AuditService } from '../../domains/operations/audit/audit.service';
import {
  CatalogAuditAction,
  CATALOG_AUDIT_MODULE,
  logCatalogAudit,
  summarizeOddsUpdates,
} from '../../domains/operations/audit/catalog-audit';
import { BetsService } from '../../domains/betting/bets.service';
import { BettingLimitsService } from '../../domains/betting/betting-limits.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AdminDashboardService } from './admin-dashboard.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
import { PresenceService } from '../../domains/presence/presence.service';
import {
  IsString,
  IsNumber,
  IsOptional,
  IsArray,
  IsBoolean,
  MinLength,
  IsIn,
  Min,
  Max,
  MaxLength,
  Equals,
  ValidateIf,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';

const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents', 'payments', 'deposits'] as const;
type UploadCategory = (typeof UPLOAD_CATEGORIES)[number];

const IMAGE_MIME_EXT: Record<string, string> = {
  'image/png': '.png',
  'image/jpeg': '.jpg',
  'image/webp': '.webp',
  'image/gif': '.gif',
  'image/svg+xml': '.svg',
};

type UploadedImage = {
  originalname: string;
  mimetype: string;
  buffer: Buffer;
  size: number;
};

type AdminUploadUser = {
  role?: string;
  permissions?: string[];
};

function parseMatchStartTime(value: string): Date {
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) {
    throw appBadRequest('MATCH_START_TIME_INVALID');
  }
  return date;
}

function uploadCategory(value?: string): UploadCategory {
  const category = (value || 'contents').trim();
  if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
    return category as UploadCategory;
  }
  throw appBadRequest('UPLOAD_CATEGORY_UNSUPPORTED');
}

function requiredUploadPermission(category: UploadCategory) {
  return category === 'teams' ? P.matches : P.content;
}

function assertUploadPermission(user: AdminUploadUser | undefined, category: UploadCategory) {
  if (user?.role === 'SUPER_ADMIN') return;
  const required = requiredUploadPermission(category);
  if (!user?.permissions?.includes(required)) {
    throw appForbidden('INSUFFICIENT_PERMISSIONS');
  }
}

function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage {
  if (!file?.buffer?.length) {
    throw appBadRequest('UPLOAD_IMAGE_REQUIRED');
  }
  if (!IMAGE_MIME_EXT[file.mimetype]) {
    throw appBadRequest('UPLOAD_IMAGE_TYPE_INVALID');
  }
  if (file.mimetype === 'image/svg+xml') {
    const sample = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)).toLowerCase();
    if (sample.includes('<script') || sample.includes('javascript:') || /\son[a-z]+\s*=/.test(sample)) {
      throw appBadRequest('UPLOAD_SVG_UNSAFE');
    }
  }
}

function uploadFilename(file: UploadedImage) {
  const fromMime = IMAGE_MIME_EXT[file.mimetype];
  const fromName = extname(file.originalname || '').toLowerCase();
  const ext = fromMime || fromName || '.img';
  const base = (file.originalname || 'asset')
    .replace(/\.[^.]+$/, '')
    .toLowerCase()
    .replace(/[^a-z0-9_-]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 42) || 'asset';
  return `${Date.now()}-${base}-${randomUUID().slice(0, 8)}${ext}`;
}

class CreateUserDto {
  @IsString()
  username!: string;

  @IsString()
  @MinLength(8)
  password!: string;

  @IsOptional()
  @IsString()
  parentId?: string;

  @IsOptional()
  @IsNumber()
  creditLimit?: number;
}

class CreatePlayerAdminDto {
  @IsString()
  username!: string;

  @IsString()
  @MinLength(8)
  password!: string;

  @IsOptional()
  @IsString()
  parentId?: string;

  @IsOptional()
  @IsString()
  locale?: string;

  @IsOptional()
  @IsString()
  phone?: string;

  @IsOptional()
  @IsString()
  email?: string;

  @IsOptional()
  @IsNumber()
  @Min(0)
  initialDeposit?: number;

  @IsOptional()
  @IsString()
  remark?: string;

  /** 创建为一级代理（非玩家） */
  @IsOptional()
  asTier1Agent?: boolean;

  /** 创建为二级代理（需要 parentAgentId） */
  @IsOptional()
  asSubAgent?: boolean;

  /** 二级代理的上级代理 ID */
  @IsOptional()
  @IsString()
  parentAgentId?: string;

  @IsOptional()
  @IsNumber()
  @Min(0)
  creditLimit?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  cashbackRate?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxSingleDeposit?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxDailyDeposit?: number;
}

class UpdatePlayerAdminDto {
  @IsOptional()
  @IsIn(['ACTIVE', 'SUSPENDED'])
  status?: string;

  @IsOptional()
  @IsString()
  locale?: string;

  @IsOptional()
  @IsString()
  phone?: string;

  @IsOptional()
  @IsString()
  email?: string;

  @IsOptional()
  @IsString()
  username?: string;

  @IsOptional()
  @IsString()
  @MinLength(8)
  password?: string;

  /** 玩家专属返水比例（小数）；null 或 0 表示清除单独设置、使用默认 */
  @IsOptional()
  @ValidateIf((_, v) => v != null)
  @IsNumber()
  @Min(0)
  cashbackRate?: number | null;
}

class CreateStaffDto {
  @IsString()
  username!: string;

  @IsString()
  @MinLength(8)
  password!: string;

  @IsString()
  roleCode!: string;

  @IsOptional()
  @IsString()
  visibleMenus?: string;
}

class UpdateStaffDto {
  @IsOptional()
  @IsIn(['ACTIVE', 'SUSPENDED', 'DISABLED'])
  status?: string;

  @IsOptional()
  @IsString()
  roleCode?: string;

  @IsOptional()
  @IsString()
  @MinLength(8)
  password?: string;

  @IsOptional()
  @IsString()
  visibleMenus?: string;
}

class ResetPlayerPasswordDto {
  @IsOptional()
  @IsString()
  @MinLength(8)
  password?: string;
}

class PlatformDirectCashbackSettingsDto {
  @IsOptional()
  @IsNumber()
  @Min(0)
  platformDirectRate?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  adminInviteRate?: number;
}

class PlayerAccountSettingsDto {
  @IsOptional()
  @IsBoolean()
  allowPasswordChange?: boolean;

  @IsOptional()
  @IsBoolean()
  allowUsernameChange?: boolean;
}

class AgentSuspendSettingsDto {
  @IsOptional()
  @IsBoolean()
  suspendFreezeDirectPlayers?: boolean;

  @IsOptional()
  @IsBoolean()
  suspendBlockPlayerLogin?: boolean;
}

class AgentHierarchySettingsDto {
  @IsOptional()
  @IsNumber()
  @Min(0)
  maxAgentLevel?: number;

  @IsOptional()
  @IsNumber()
  @Min(1)
  @Max(100)
  defaultSubAgentCreditRatio?: number;
}

class ResetDatabaseDto {
  @IsString()
  @Equals('RESET')
  confirmPhrase!: string;
}

class CreateAgentAdminDto {
  /** 已有玩家用户 ID，升级为一级代理 */
  @IsString()
  userId!: string;

  @IsNumber()
  @Min(0)
  creditLimit!: number;

  @IsOptional()
  @IsString()
  phone?: string;

  @IsOptional()
  @IsString()
  email?: string;

  @IsOptional()
  @IsNumber()
  @Min(0)
  cashbackRate?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxSingleDeposit?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxDailyDeposit?: number;
}

class UpdateAgentAdminDto {
  @IsOptional()
  @IsIn(['ACTIVE', 'SUSPENDED'])
  status?: string;

  @IsOptional()
  @IsString()
  locale?: string;

  @IsOptional()
  @IsString()
  phone?: string;

  @IsOptional()
  @IsString()
  email?: string;

  @IsOptional()
  @IsNumber()
  @Min(0)
  cashbackRate?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxSingleDeposit?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxDailyDeposit?: number;

  @IsOptional()
  @IsString()
  username?: string;

  @IsOptional()
  @IsString()
  password?: string;

  /** 冻结时是否级联冻结直属玩家 */
  @IsOptional()
  @IsBoolean()
  freezeDirectPlayers?: boolean;

  /** 冻结时是否禁止直属玩家登录 */
  @IsOptional()
  @IsBoolean()
  blockDirectPlayerLogin?: boolean;

  /** 解冻时是否级联解冻直属玩家 */
  @IsOptional()
  @IsBoolean()
  unfreezeDirectPlayers?: boolean;
}

class DepositDto {
  @IsNumber()
  amount!: number;

  @IsString()
  requestId!: string;

  @IsOptional()
  @IsString()
  remark?: string;
}

class CreatePlatformLeagueDto {
  @IsString()
  leagueEn!: string;

  @IsString()
  leagueZh!: string;

  @IsOptional()
  @IsString()
  leagueMs?: string;

  @IsOptional()
  @IsString()
  logoUrl?: string;

  @IsOptional()
  @IsNumber()
  displayOrder?: number;

  @IsOptional()
  @IsBoolean()
  isActive?: boolean;
}

class CreatePlatformMatchDto {
  @IsOptional()
  @IsString()
  leagueId?: string;

  @ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
  @IsString()
  leagueEn?: string;

  @ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
  @IsString()
  leagueZh?: string;

  @IsOptional()
  @IsString()
  leagueMs?: string;

  @IsOptional()
  @IsString()
  homeTeamCode?: string;

  @IsOptional()
  @IsString()
  awayTeamCode?: string;

  @IsString()
  homeTeamEn!: string;

  @IsString()
  homeTeamZh!: string;

  @IsOptional()
  @IsString()
  homeTeamMs?: string;

  @IsString()
  awayTeamEn!: string;

  @IsString()
  awayTeamZh!: string;

  @IsOptional()
  @IsString()
  awayTeamMs?: string;

  @IsString()
  startTime!: string;

  @IsOptional()
  @IsBoolean()
  isHot?: boolean;

  @IsOptional()
  @IsNumber()
  displayOrder?: number;

  @IsOptional()
  @IsString()
  matchName?: string;

  @IsOptional()
  @IsString()
  stage?: string;

  @IsOptional()
  @IsString()
  groupName?: string;

  @IsOptional()
  @IsString()
  leagueLogoUrl?: string;

  @IsOptional()
  @IsString()
  homeTeamLogoUrl?: string;

  @IsOptional()
  @IsString()
  awayTeamLogoUrl?: string;

}

class UpdatePlatformMatchDto {
  @IsString()
  homeTeamEn!: string;

  @IsString()
  homeTeamZh!: string;

  @IsOptional()
  @IsString()
  homeTeamMs?: string;

  @IsString()
  awayTeamEn!: string;

  @IsString()
  awayTeamZh!: string;

  @IsOptional()
  @IsString()
  awayTeamMs?: string;

  @IsString()
  startTime!: string;

  @IsOptional()
  @IsBoolean()
  isHot?: boolean;

  @IsOptional()
  @IsNumber()
  displayOrder?: number;

  @IsOptional()
  @IsString()
  matchName?: string;

  @IsOptional()
  @IsString()
  stage?: string;

  @IsOptional()
  @IsString()
  groupName?: string;

  @IsOptional()
  @IsString()
  homeTeamLogoUrl?: string;

  @IsOptional()
  @IsString()
  awayTeamLogoUrl?: string;

}

class ReopenMatchDto {
  @IsOptional()
  @IsString()
  startTime?: string;
}

class ArchiveMatchDto {
  @IsOptional()
  @IsBoolean()
  force?: boolean;

  @IsOptional()
  @IsBoolean()
  refundPendingBets?: boolean;
}

class BatchMatchOddsDto {
  @IsArray()
  updates!: OutrightOddsUpdateItemDto[];
}

class UpdateMarketDto {
  @IsOptional()
  @IsString()
  promoLabel?: string | null;

  @IsOptional()
  promoLabelI18n?: Record<string, string> | null;

  @IsOptional()
  nameI18n?: Record<string, string> | null;

  @IsOptional()
  @IsString()
  status?: string;

  @IsOptional()
  @IsNumber()
  lineValue?: number | null;

  @IsOptional()
  @IsBoolean()
  showOnPlayer?: boolean;
}

class UpdateSelectionDto {
  @IsOptional()
  @IsString()
  selectionName?: string;

  @IsOptional()
  nameI18n?: Record<string, string> | null;

  @IsOptional()
  @IsNumber()
  @Min(1.01)
  odds?: number;

  @IsOptional()
  @IsString()
  status?: string;
}

function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
  if (!body || typeof body !== 'object') return false;
  return Array.isArray((body as ZhiboMatchesBundleExport).matches);
}

class ScoreDto {
  @IsOptional()
  @IsNumber()
  htHome?: number;

  @IsOptional()
  @IsNumber()
  htAway?: number;

  @IsOptional()
  @IsNumber()
  ftHome?: number;

  @IsOptional()
  @IsNumber()
  ftAway?: number;

  @IsOptional()
  @IsNumber()
  homeCorners?: number;

  @IsOptional()
  @IsNumber()
  awayCorners?: number;

  @IsOptional()
  @IsNumber()
  homeYellowCards?: number;

  @IsOptional()
  @IsNumber()
  awayYellowCards?: number;

  @IsOptional()
  @IsNumber()
  homeRedCards?: number;

  @IsOptional()
  @IsNumber()
  awayRedCards?: number;

  @IsOptional()
  @IsNumber()
  homeCards?: number;

  @IsOptional()
  @IsNumber()
  awayCards?: number;

  /** 冠军盘结算：获胜球队 ID */
  @IsOptional()
  @IsNumber()
  winnerTeamId?: number;
}

class SettlementPreviewDto extends ScoreDto {
  @IsOptional()
  @IsNumber()
  page?: number;

  @IsOptional()
  @IsNumber()
  pageSize?: number;
}

/* 智能比分推荐已关闭
class SmartScoreSuggestDto {
  @IsOptional()
  @IsArray()
  strategies?: Array<'MIN_PAYOUT' | 'MAX_PAYOUT' | 'BALANCED' | 'TARGET_HOLD'>;

  @IsOptional()
  @IsNumber()
  targetHoldPct?: number;

  @IsOptional()
  @IsNumber()
  maxGoals?: number;
}
*/

class MarketTemplatesDto {
  @IsArray()
  marketTypes!: string[];
}

class MarketDraftSelectionDto {
  @IsOptional()
  @IsString()
  id?: string;

  @IsString()
  selectionCode!: string;

  @IsOptional()
  @IsString()
  selectionName?: string;

  @IsOptional()
  nameI18n?: Record<string, string> | null;

  @IsNumber()
  @Min(1.01)
  odds!: number;

  @IsOptional()
  @IsString()
  status?: string;

  @IsOptional()
  @IsNumber()
  sortOrder?: number;
}

class MarketDraftDto {
  @IsOptional()
  @IsString()
  id?: string;

  @IsString()
  marketType!: string;

  @IsOptional()
  @IsString()
  marketKey?: string | null;

  @IsOptional()
  @IsString()
  lineKey?: string | null;

  @IsOptional()
  @IsString()
  period?: string;

  @IsOptional()
  @IsNumber()
  lineValue?: number | null;

  @IsOptional()
  paramsJson?: Record<string, unknown> | null;

  @IsOptional()
  @IsString()
  status?: string;

  @IsOptional()
  @IsBoolean()
  allowSingle?: boolean;

  @IsOptional()
  @IsBoolean()
  allowParlay?: boolean;

  @IsOptional()
  @IsBoolean()
  showOnPlayer?: boolean;

  @IsOptional()
  @IsNumber()
  sortOrder?: number;

  @IsOptional()
  @IsString()
  promoLabel?: string | null;

  @IsOptional()
  promoLabelI18n?: Record<string, string> | null;

  @IsOptional()
  nameI18n?: Record<string, string> | null;

  @IsOptional()
  @IsArray()
  selections?: MarketDraftSelectionDto[];
}

class MarketTemplateSaveDto {
  @IsOptional()
  @IsString()
  name?: string;

  @IsOptional()
  nameI18n?: Record<string, string> | null;

  @IsOptional()
  @IsString()
  description?: string | null;

  @IsOptional()
  @IsBoolean()
  isDefault?: boolean;

  @IsOptional()
  @IsString()
  status?: string;

  @IsOptional()
  @IsNumber()
  sortOrder?: number;

  @IsOptional()
  @IsArray()
  items?: MarketDraftDto[];
}

class ApplyMarketTemplateDto {
  @IsOptional()
  @IsString()
  templateId?: string;
}

class BulkMatchMarketsDto {
  @IsArray()
  markets!: MarketDraftDto[];
}

class UpdateOddsDto {
  @IsNumber()
  odds!: number;
}

class OutrightOddsUpdateItemDto {
  @IsString()
  selectionId!: string;

  @IsNumber()
  @Min(1.01)
  odds!: number;
}

class BatchOutrightOddsDto {
  @IsArray()
  updates!: OutrightOddsUpdateItemDto[];
}

class CreateOutrightDto {
  @IsString()
  leagueId!: string;

  @IsString()
  titleZh!: string;

  @IsString()
  titleEn!: string;

  @IsOptional()
  @IsString()
  titleMs?: string;

  @IsOptional()
  @IsString()
  status?: string;
}

class UpdateOutrightDto {
  @IsOptional()
  @IsString()
  status?: string;

  @IsOptional()
  @IsString()
  matchName?: string;

  @IsOptional()
  @IsString()
  titleZh?: string;

  @IsOptional()
  @IsString()
  titleEn?: string;

  @IsOptional()
  @IsString()
  titleMs?: string;

  @IsOptional()
  isHot?: boolean;

  @IsOptional()
  displayOrder?: number;
}

class AddOutrightSelectionDto {
  @IsString()
  teamCode!: string;

  @IsString()
  teamZh!: string;

  @IsString()
  teamEn!: string;

  @IsNumber()
  @Min(1.01)
  odds!: number;

  @IsOptional()
  @IsString()
  logoUrl?: string;
}

class AddOutrightSelectionsBatchDto {
  @IsArray()
  items!: AddOutrightSelectionDto[];
}

class UpdateOutrightSelectionTeamDto {
  @IsOptional()
  @IsString()
  teamCode?: string;

  @IsOptional()
  @IsString()
  teamZh?: string;

  @IsOptional()
  @IsString()
  teamEn?: string;

  @IsOptional()
  @IsString()
  logoUrl?: string | null;
}

class ContentTranslationDto {
  @IsString()
  locale!: string;

  @IsOptional()
  @IsString()
  title?: string;

  @IsOptional()
  @IsString()
  body?: string;

  @IsOptional()
  @IsString()
  imageUrl?: string;
}

class CreateContentDto {
  @IsString()
  @IsIn(['BANNER', 'NOTICE', 'TICKER'])
  contentType!: string;

  @IsOptional()
  @IsNumber()
  sortOrder?: number;

  @IsOptional()
  @IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
  status?: string;

  @IsOptional()
  @IsString()
  linkType?: string | null;

  @IsOptional()
  @IsString()
  linkTarget?: string | null;

  @IsOptional()
  @IsString()
  startTime?: string | null;

  @IsOptional()
  @IsString()
  endTime?: string | null;

  @IsOptional()
  @IsBoolean()
  notifyInbox?: boolean;

  @IsArray()
  translations!: ContentTranslationDto[];
}

class UpdateContentDto {
  @IsOptional()
  @IsNumber()
  sortOrder?: number;

  @IsOptional()
  @IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
  status?: string;

  @IsOptional()
  @IsString()
  linkType?: string | null;

  @IsOptional()
  @IsString()
  linkTarget?: string | null;

  @IsOptional()
  @IsString()
  startTime?: string | null;

  @IsOptional()
  @IsString()
  endTime?: string | null;

  @IsOptional()
  @IsArray()
  translations?: ContentTranslationDto[];
}

class ContentStatusDto {
  @IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
  status!: string;
}

class InboxNotifySettingsDto {
  @IsOptional()
  @IsBoolean()
  inboxEnabled?: boolean;

  @IsOptional()
  @IsBoolean()
  deposit?: boolean;

  @IsOptional()
  @IsBoolean()
  banner?: boolean;

  @IsOptional()
  @IsBoolean()
  announcement?: boolean;
}

class BroadcastTranslationDto {
  @IsString()
  locale!: string;

  @IsOptional()
  @IsString()
  @MaxLength(256)
  title?: string;

  @IsOptional()
  @IsString()
  body?: string;
}

class CreatePlayerMessageBroadcastDto {
  @IsArray()
  translations!: BroadcastTranslationDto[];

  @IsIn(['ALL', 'USER'])
  targetType!: 'ALL' | 'USER';

  @ValidateIf((dto: CreatePlayerMessageBroadcastDto) => dto.targetType === 'USER')
  @IsString()
  @MinLength(1)
  targetUsername?: string;
}

class UpdateDepositCleanupConfigDto {
  @IsOptional()
  @IsBoolean()
  enabled?: boolean;

  @IsOptional()
  @IsNumber()
  @Min(1)
  keepDays?: number;
}

class CashbackPreviewDto {
  @IsString()
  periodStart!: string;

  @IsString()
  periodEnd!: string;
}

class ResettlePreviewDto {
  @IsOptional()
  @IsNumber()
  htHome?: number;

  @IsOptional()
  @IsNumber()
  htAway?: number;

  @IsOptional()
  @IsNumber()
  ftHome?: number;

  @IsOptional()
  @IsNumber()
  ftAway?: number;

  @IsOptional()
  @IsNumber()
  homeCorners?: number;

  @IsOptional()
  @IsNumber()
  awayCorners?: number;

  @IsOptional()
  @IsNumber()
  homeYellowCards?: number;

  @IsOptional()
  @IsNumber()
  awayYellowCards?: number;

  @IsOptional()
  @IsNumber()
  homeRedCards?: number;

  @IsOptional()
  @IsNumber()
  awayRedCards?: number;

  @IsOptional()
  @IsNumber()
  homeCards?: number;

  @IsOptional()
  @IsNumber()
  awayCards?: number;

  @IsOptional()
  @IsString()
  reason?: string;

  @IsOptional()
  @IsNumber()
  winnerTeamId?: number;
}

class BettingLimitsDto {
  @IsOptional()
  @IsNumber()
  @Min(0)
  minStake?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxStakeSingle?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxStakeParlay?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxPayoutSingle?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  maxPayoutParlay?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  dailyStakeLimit?: number;
}

@ApiTags('Admin')
@Controller('admin')
@UseGuards(JwtAuthGuard, AdminGuard, PermissionsGuard)
@ApiBearerAuth()
export class AdminController {
  constructor(
    private users: UsersService,
    private agents: AgentsService,
    private wallet: WalletService,
    private matches: MatchesService,
    private catalogArchive: CatalogArchiveService,
    private outright: OutrightService,
    private markets: MarketsService,
    private settlement: SettlementService,
    private cashback: CashbackService,
    private content: ContentService,
    private i18n: I18nService,
    private audit: AuditService,
    private bets: BetsService,
    private prisma: PrismaService,
    private readonly dashboardService: AdminDashboardService,
    private systemConfig: SystemConfigService,
    private bettingLimits: BettingLimitsService,
    private databaseReset: DatabaseResetService,
    private smokeTests: SmokeTestService,
    private depositService: DepositService,
    private playerMessages: PlayerMessagesService,
    private staff: AdminStaffService,
    private presence: PresenceService,
    private depositCleanup: DepositScreenshotCleanupService,
  ) {}

  @Get('presence/online-count')
  @RequirePermissions(P.usersView)
  async getOnlinePlayerCount() {
    const count = await this.presence.getOnlineCount();
    return jsonResponse({ count, asOf: new Date().toISOString() });
  }

  @Get('dashboard')
  @RequirePermissions(P.reports)
  async getDashboard() {
    const overview = await this.dashboardService.getOverview();
    return jsonResponse(overview);
  }

  @Get('users/page-init')
  @RequirePermissions(P.agentsView, P.usersView)
  async getUsersPageInit() {
    const [
      playerSettings,
      bettingLimits,
      hierarchySettings,
      platformDirect,
      agentLevelCounts,
    ] = await Promise.all([
      this.systemConfig.getPlayerAccountSettings(),
      this.bettingLimits.getLimits(),
      this.systemConfig.getAgentHierarchySettings(),
      this.systemConfig.getPlatformDirectCashbackSettings(),
      this.agents.countAgentsByLevel(),
    ]);
    return jsonResponse({
      playerSettings,
      bettingLimits,
      hierarchySettings,
      platformDirect,
      agentLevelCounts,
    });
  }

  @Get('users/settings/account')
  @RequirePermissions(P.settings)
  async getPlayerAccountSettings() {
    const settings = await this.systemConfig.getPlayerAccountSettings();
    return jsonResponse(settings);
  }

  @Put('users/settings/account')
  @RequirePermissions(P.settings)
  async updatePlayerAccountSettings(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: PlayerAccountSettingsDto,
  ) {
    const settings = await this.systemConfig.updatePlayerAccountSettings(dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_PLAYER_ACCOUNT_SETTINGS',
      module: 'USERS',
      afterData: JSON.stringify(settings),
    });
    return jsonResponse(settings);
  }

  @Get('agents/settings/suspend')
  @RequirePermissions(P.settings)
  async getAgentSuspendSettings() {
    const settings = await this.systemConfig.getAgentSuspendSettings();
    return jsonResponse(settings);
  }

  @Put('agents/settings/suspend')
  @RequirePermissions(P.settings)
  async updateAgentSuspendSettings(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: AgentSuspendSettingsDto,
  ) {
    const settings = await this.systemConfig.updateAgentSuspendSettings(dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_AGENT_SUSPEND_SETTINGS',
      module: 'AGENTS',
      afterData: JSON.stringify(settings),
    });
    return jsonResponse(settings);
  }

  @Get('agents/settings/hierarchy')
  @RequirePermissions(P.settings)
  async getAgentHierarchySettings() {
    const settings = await this.systemConfig.getAgentHierarchySettings();
    return jsonResponse(settings);
  }

  @Put('agents/settings/hierarchy')
  @RequirePermissions(P.settings)
  async updateAgentHierarchySettings(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: AgentHierarchySettingsDto,
  ) {
    const settings = await this.systemConfig.updateAgentHierarchySettings(dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_AGENT_HIERARCHY_SETTINGS',
      module: 'AGENTS',
      afterData: JSON.stringify(settings),
    });
    return jsonResponse(settings);
  }

  @Get('settings/betting-limits')
  @RequirePermissions(P.settings)
  async getBettingLimits() {
    const limits = await this.bettingLimits.getLimits();
    return jsonResponse(limits);
  }

  @Put('settings/betting-limits')
  @RequirePermissions(P.settings)
  async updateBettingLimits(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: BettingLimitsDto,
  ) {
    const limits = await this.bettingLimits.updateLimits(dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_BETTING_LIMITS',
      module: 'SETTINGS',
      afterData: limits,
    });
    return jsonResponse(limits);
  }

  @Get('system/reset-database')
  @RequirePermissions(P.resetDatabase)
  getResetDatabaseStatus() {
    return jsonResponse({ allowed: this.databaseReset.isAllowed() });
  }

  @Post('system/reset-database')
  @RequirePermissions(P.resetDatabase)
  async resetDatabase(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: ResetDatabaseDto,
  ) {
    if (dto.confirmPhrase !== 'RESET') {
      throw appBadRequest('DB_RESET_PHRASE_INVALID');
    }
    const result = await this.databaseReset.resetDatabase();
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'RESET_DATABASE',
      module: 'SYSTEM',
      afterData: { demoAccounts: result.demoAccounts },
    });
    return jsonResponse(result);
  }

  @Get('users')
  @RequirePermissions(P.usersView)
  async listUsers(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('keyword') keyword?: string,
    @Query('parentId') parentId?: string,
    @Query('platformDirect') platformDirect?: string,
    @Query('status') status?: string,
  ) {
    const result = await this.users.listPlayers(
      page ? parseInt(page, 10) : 1,
      pageSize ? parseInt(pageSize, 10) : 10,
      {
        keyword,
        parentId: parentId ? BigInt(parentId) : undefined,
        platformDirect: platformDirect === 'true' || platformDirect === '1',
        status,
      },
    );
    return jsonResponse(result);
  }

  @Get('users/:id')
  @RequirePermissions(P.usersView)
  async getUserDetail(@Param('id') id: string) {
    const detail = await this.users.getPlayerAdminDetail(BigInt(id));
    return jsonResponse(detail);
  }

  @Put('users/:id')
  @RequirePermissions(P.usersCreate)
  async updateUser(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: UpdatePlayerAdminDto,
  ) {
    const detail = await this.users.updatePlayerAdmin(BigInt(id), {
      status: dto.status,
      locale: dto.locale,
      phone: dto.phone,
      email: dto.email,
      username: dto.username,
      password: dto.password,
      cashbackRate: dto.cashbackRate,
    });
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_PLAYER',
      module: 'USERS',
      targetId: id,
    });
    return jsonResponse(detail);
  }

  @Post('users/:id/reset-password')
  @RequirePermissions(P.usersResetPassword)
  async resetPlayerPassword(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: ResetPlayerPasswordDto,
  ) {
    const { password } = await this.staff.resetPlayerPassword(BigInt(id), dto.password);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'RESET_PLAYER_PASSWORD',
      module: 'USERS',
      targetId: id,
    });
    const detail = await this.users.getPlayerAdminDetail(BigInt(id));
    return jsonResponse({ ...detail, password });
  }

  @Get('staff/roles')
  @RequirePermissions(P.settings)
  async listStaffRoles() {
    const roles = await this.staff.listRoles();
    return jsonResponse(roles);
  }

  @Get('staff')
  @RequirePermissions(P.settings)
  async listStaff(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('keyword') keyword?: string,
  ) {
    const result = await this.staff.listStaff(
      page ? parseInt(page, 10) : 1,
      pageSize ? parseInt(pageSize, 10) : 20,
      keyword,
    );
    return jsonResponse(result);
  }

  @Post('staff')
  @RequirePermissions(P.settings)
  async createStaff(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: CreateStaffDto,
  ) {
    const created = await this.staff.createStaff(dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'CREATE_STAFF',
      module: 'STAFF',
      targetId: created.id,
      afterData: { username: created.username, role: created.role },
    });
    return jsonResponse(created);
  }

  @Patch('staff/:id')
  @RequirePermissions(P.settings)
  async updateStaff(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: UpdateStaffDto,
  ) {
    const updated = await this.staff.updateStaff(BigInt(id), dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_STAFF',
      module: 'STAFF',
      targetId: id,
      afterData: JSON.stringify({ status: dto.status, roleCode: dto.roleCode }),
    });
    return jsonResponse(updated);
  }

  @Delete('staff/:id')
  @RequirePermissions(P.settings)
  async deleteStaff(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
  ) {
    await this.staff.deleteStaff(BigInt(id), operatorId);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'DELETE_STAFF',
      module: 'STAFF',
      targetId: id,
    });
    return jsonResponse({ deleted: true });
  }

  @Delete('users/:id')
  @RequirePermissions(P.usersCreate)
  async deletePlayer(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
  ) {
    await this.users.softDeletePlayer(BigInt(id));
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'DELETE_PLAYER',
      module: 'USERS',
      targetId: id,
    });
    return jsonResponse({ deleted: true });
  }

  @Post('users')
  @RequirePermissions(P.usersCreate)
  async createPlayer(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: CreatePlayerAdminDto,
  ) {
    const user = await this.agents.createPlayer(operatorId, {
      username: dto.username,
      password: dto.password,
      parentId: dto.parentId ? BigInt(dto.parentId) : undefined,
      locale: dto.locale,
      phone: dto.phone,
      email: dto.email,
      initialDeposit: dto.initialDeposit,
      depositRemark: dto.remark,
      depositRequestId: `create-player-${dto.username}-${Date.now()}`,
      asTier1Agent: dto.asTier1Agent,
      asSubAgent: dto.asSubAgent,
      parentAgentId: dto.parentAgentId ? BigInt(dto.parentAgentId) : undefined,
      creditLimit: dto.creditLimit,
      cashbackRate: dto.cashbackRate,
      maxSingleDeposit: dto.maxSingleDeposit,
      maxDailyDeposit: dto.maxDailyDeposit,
    });
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: dto.asTier1Agent || dto.asSubAgent ? 'CREATE_AGENT' : 'CREATE_PLAYER',
      module: dto.asTier1Agent || dto.asSubAgent ? 'AGENTS' : 'USERS',
      targetId: user.id.toString(),
    });
    if (dto.asTier1Agent || dto.asSubAgent) {
      const detail = await this.agents.getAgentAdminDetail(user.id);
      return jsonResponse(detail);
    }
    const detail = await this.users.getPlayerAdminDetail(user.id);
    return jsonResponse(detail);
  }

  @Get('users/promotable-for-agent')
  @RequirePermissions(P.usersView)
  async listPromotableForAgent(@Query('keyword') keyword?: string) {
    const rows = await this.agents.listPromotablePlayers(keyword);
    return jsonResponse(
      rows.map((u) => ({
        id: u.id.toString(),
        username: u.username,
        status: u.status,
        parentId: u.parentId?.toString() ?? null,
        parentUsername: u.parent?.username ?? null,
        phone: u.preferences?.phone ?? null,
        email: u.preferences?.email ?? null,
      })),
    );
  }

  @Get('agents/options')
  @RequirePermissions(P.agentsView)
  async listAgentOptions(
    @Query('keyword') keyword?: string,
    @Query('limit') limit?: string,
  ) {
    const take = Math.min(100, Math.max(1, parseInt(limit ?? '50', 10) || 50));
    const kw = keyword?.trim();
    const agents = await this.prisma.user.findMany({
      where: {
        userType: 'AGENT',
        deletedAt: null,
        ...(kw
          ? { username: { contains: kw, mode: 'insensitive' as const } }
          : {}),
      },
      select: {
        id: true,
        username: true,
        agentLevel: true,
        parent: { select: { username: true } },
      },
      orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
      take,
    });
    return jsonResponse(
      agents.map((a) => ({
        id: a.id.toString(),
        username: a.username,
        level: a.agentLevel ?? 1,
        parentUsername: a.parent?.username ?? null,
      })),
    );
  }

  @Get('agents/level-counts')
  @RequirePermissions(P.agentsView)
  async getAgentLevelCounts() {
    const counts = await this.agents.countAgentsByLevel();
    return jsonResponse(counts);
  }

  @Get('agents')
  @RequirePermissions(P.agentsView)
  async listAgents(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('keyword') keyword?: string,
    @Query('status') status?: string,
    @Query('level') level?: string,
    @Query('minLevel') minLevel?: string,
    @Query('maxLevel') maxLevel?: string,
    @Query('parentAgentId') parentAgentId?: string,
  ) {
    const parsedLevel = level != null && level !== '' ? parseInt(level, 10) : undefined;
    const parsedMinLevel = minLevel != null && minLevel !== '' ? parseInt(minLevel, 10) : undefined;
    const parsedMaxLevel = maxLevel != null && maxLevel !== '' ? parseInt(maxLevel, 10) : undefined;
    const result = await this.agents.listAgentsAdmin({
      page: page ? parseInt(page, 10) : 1,
      pageSize: pageSize ? parseInt(pageSize, 10) : 10,
      keyword,
      status,
      level: Number.isFinite(parsedLevel) ? parsedLevel : undefined,
      minLevel: Number.isFinite(parsedMinLevel) ? parsedMinLevel : undefined,
      maxLevel: Number.isFinite(parsedMaxLevel) ? parsedMaxLevel : undefined,
      parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
    });
    return jsonResponse(result);
  }

  @Get('agents/credit-transactions')
  @RequirePermissions(P.agentsView, P.reports)
  async listAgentCreditTransactions(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('agentId') agentId?: string,
    @Query('keyword') keyword?: string,
    @Query('operatorKeyword') operatorKeyword?: string,
    @Query('transactionType') transactionType?: string,
    @Query('dateFrom') dateFrom?: string,
    @Query('dateTo') dateTo?: string,
  ) {
    const result = await this.agents.listCreditTransactions({
      page: page ? parseInt(page, 10) : 1,
      pageSize: pageSize ? parseInt(pageSize, 10) : 20,
      agentId: agentId ? BigInt(agentId) : undefined,
      keyword,
      operatorKeyword,
      transactionType,
      dateFrom: dateFrom ? new Date(dateFrom) : undefined,
      dateTo: dateTo ? new Date(dateTo) : undefined,
    });
    return jsonResponse(result);
  }

  @Get('agents/:id')
  @RequirePermissions(P.agentsView)
  async getAgentDetail(@Param('id') id: string) {
    const detail = await this.agents.getAgentAdminDetail(BigInt(id));
    return jsonResponse(detail);
  }

  @Put('agents/:id')
  @RequirePermissions(P.agentsCreate)
  async updateAgent(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: UpdateAgentAdminDto,
  ) {
    const detail = await this.agents.updateAgentAdmin(BigInt(id), dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_AGENT',
      module: 'AGENTS',
      targetId: id,
    });
    return jsonResponse(detail);
  }

  @Post('agents')
  @RequirePermissions(P.agentsCreate)
  async createAgent(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: CreateAgentAdminDto,
  ) {
    const user = await this.agents.promotePlayerToTier1Agent(BigInt(dto.userId), {
      creditLimit: dto.creditLimit,
      phone: dto.phone,
      email: dto.email,
      cashbackRate: dto.cashbackRate,
      maxSingleDeposit: dto.maxSingleDeposit,
      maxDailyDeposit: dto.maxDailyDeposit,
    });
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'CREATE_AGENT',
      module: 'AGENTS',
      targetId: user.id.toString(),
    });
    const detail = await this.agents.getAgentAdminDetail(user.id);
    return jsonResponse(detail);
  }

  @Post('agents/:id/credit')
  @RequirePermissions(P.agentsCredit)
  async adjustCredit(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: DepositDto,
  ) {
    const result = await this.agents.adjustCredit(
      BigInt(id),
      dto.amount,
      operatorId,
      dto.requestId,
      dto.remark,
    );
    return jsonResponse(result);
  }

  @Post('wallet/deposit')
  @RequirePermissions(P.walletDeposit)
  async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
    const result = await this.agents.adminDepositToPlayer(
      BigInt(dto.userId),
      dto.amount,
      operatorId,
      dto.remark,
      dto.requestId,
    );
    return jsonResponse(result);
  }

  @Get('wallet/transfer-context/:userId')
  @RequirePermissions(P.walletDeposit, P.walletWithdraw)
  async walletTransferContext(@Param('userId') userId: string) {
    const ctx = await this.agents.getPlayerTransferContext(BigInt(userId), { forAdmin: true });
    return jsonResponse(ctx);
  }

  @Post('wallet/withdraw')
  @RequirePermissions(P.walletWithdraw)
  async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
    const result = await this.agents.adminWithdrawFromPlayer(
      BigInt(dto.userId),
      dto.amount,
      operatorId,
      dto.remark,
      dto.requestId,
    );
    return jsonResponse(result);
  }

  @Get('wallet/transactions')
  @RequirePermissions(P.walletDeposit, P.walletWithdraw, P.reports)
  async walletTransactions(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('playerId') playerId?: string,
    @Query('parentAgentId') parentAgentId?: string,
    @Query('parentAgentKeyword') parentAgentKeyword?: string,
    @Query('keyword') keyword?: string,
    @Query('operatorKeyword') operatorKeyword?: string,
    @Query('transactionType') transactionType?: string,
    @Query('typeCategory') typeCategory?: string,
    @Query('dateFrom') dateFrom?: string,
    @Query('dateTo') dateTo?: string,
  ) {
    const result = await this.wallet.listWalletTransactionsAdmin({
      page: page ? parseInt(page, 10) : 1,
      pageSize: pageSize ? parseInt(pageSize, 10) : 20,
      playerId: playerId ? BigInt(playerId) : undefined,
      parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
      parentAgentKeyword,
      keyword,
      operatorKeyword,
      transactionType,
      typeCategory,
      dateFrom: dateFrom ? new Date(dateFrom) : undefined,
      dateTo: dateTo ? new Date(dateTo) : undefined,
    });
    return jsonResponse(result);
  }

  @Get('wallet/transfer-transactions')
  @RequirePermissions(P.walletDeposit, P.walletWithdraw, P.reports)
  async listWalletTransferTransactions(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('playerId') playerId?: string,
    @Query('parentAgentId') parentAgentId?: string,
    @Query('parentAgentKeyword') parentAgentKeyword?: string,
    @Query('keyword') keyword?: string,
    @Query('operatorKeyword') operatorKeyword?: string,
    @Query('transactionType') transactionType?: string,
    @Query('dateFrom') dateFrom?: string,
    @Query('dateTo') dateTo?: string,
  ) {
    const result = await this.wallet.listTransferTransactions({
      page: page ? parseInt(page, 10) : 1,
      pageSize: pageSize ? parseInt(pageSize, 10) : 20,
      playerId: playerId ? BigInt(playerId) : undefined,
      parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
      parentAgentKeyword,
      keyword,
      operatorKeyword,
      transactionType,
      dateFrom: dateFrom ? new Date(dateFrom) : undefined,
      dateTo: dateTo ? new Date(dateTo) : undefined,
    });
    return jsonResponse(result);
  }

  @Post('leagues')
  @RequirePermissions(P.matches)
  async createLeague(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
  ) {
    let league;
    if ('leagueZh' in dto || 'leagueEn' in dto) {
      const body = dto as CreatePlatformLeagueDto;
      league = await this.matches.createPlatformLeague({
        leagueEn: body.leagueEn,
        leagueZh: body.leagueZh,
        leagueMs: body.leagueMs,
        logoUrl: body.logoUrl,
        displayOrder: body.displayOrder,
        isActive: body.isActive,
      });
    } else {
      const legacy = dto as { code: string; translations: Record<string, string> };
      league = await this.matches.createLeague(legacy.code, legacy.translations);
    }
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.CREATE_LEAGUE,
      targetId: league.id,
      afterData:
        'leagueZh' in dto || 'leagueEn' in dto
          ? { leagueEn: (dto as CreatePlatformLeagueDto).leagueEn, leagueZh: (dto as CreatePlatformLeagueDto).leagueZh }
          : { code: (dto as { code: string }).code },
    });
    return jsonResponse(league);
  }

  @Put('leagues/:leagueId')
  @RequirePermissions(P.matches)
  async updateLeague(
    @CurrentUser('id') operatorId: bigint,
    @Param('leagueId') leagueId: string,
    @Body() dto: CreatePlatformLeagueDto,
  ) {
    const league = await this.matches.updatePlatformLeague(BigInt(leagueId), {
      leagueEn: dto.leagueEn,
      leagueZh: dto.leagueZh,
      leagueMs: dto.leagueMs,
      logoUrl: dto.logoUrl,
      displayOrder: dto.displayOrder,
      isActive: dto.isActive,
    });
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_LEAGUE,
      targetId: leagueId,
      afterData: { leagueEn: dto.leagueEn, leagueZh: dto.leagueZh, isActive: dto.isActive },
    });
    return jsonResponse(league);
  }

  @Get('leagues/:leagueId/archive-preview')
  @RequirePermissions(P.matches)
  async getLeagueArchivePreview(@Param('leagueId') leagueId: string) {
    const preview = await this.catalogArchive.getLeagueArchivePreview(BigInt(leagueId));
    return jsonResponse(preview);
  }

  @Post('leagues/:leagueId/archive')
  @RequirePermissions(P.matches)
  async archiveLeague(
    @CurrentUser('id') operatorId: bigint,
    @Param('leagueId') leagueId: string,
  ) {
    const result = await this.catalogArchive.archiveLeague(BigInt(leagueId));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.ARCHIVE_LEAGUE,
      targetId: leagueId,
      afterData: result,
    });
    return jsonResponse(result);
  }

  @Get('leagues')
  @RequirePermissions(P.matches, P.reports)
  async listLeagues(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('status') status?: string,
    @Query('keyword') keyword?: string,
  ) {
    const p = Math.max(1, page ? parseInt(page, 10) : 1);
    const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
    const result = await this.matches.listAdminLeagues({
      page: p,
      pageSize: size,
      status: status || undefined,
      keyword: keyword || undefined,
    });
    return jsonResponse(result);
  }

  @Get('leagues/:leagueId/outright')
  @RequirePermissions(P.matches, P.reports)
  async getLeagueOutright(@Param('leagueId') leagueId: string) {
    const data = await this.outright.getOrCreateAndSyncForLeague(
      BigInt(leagueId),
    );
    return jsonResponse(data);
  }

  @Get('leagues/:leagueId/matches')
  @RequirePermissions(P.matches, P.reports)
  async listLeagueMatches(
    @Param('leagueId') leagueId: string,
    @Query('status') status?: string,
    @Query('keyword') keyword?: string,
    @Query('locale') locale?: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('hasBets') hasBets?: string,
    @Query('orderBy') orderBy?: string,
    @Query('startFrom') startFrom?: string,
    @Query('startTo') startTo?: string,
  ) {
    const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
      status: status || undefined,
      keyword: keyword || undefined,
      locale: locale || undefined,
      page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
      pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
      hasBets: hasBets || undefined,
      orderBy: orderBy || undefined,
      startFrom: startFrom ? new Date(startFrom) : undefined,
      startTo: startTo ? new Date(startTo) : undefined,
    });
    return jsonResponse(result);
  }

  @Post('teams')
  @RequirePermissions(P.matches)
  async createTeam(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: { code: string; translations: Record<string, string> },
  ) {
    const team = await this.matches.createTeam(dto.code, dto.translations);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.CREATE_TEAM,
      targetId: team.id,
      afterData: { code: dto.code },
    });
    return jsonResponse(team);
  }

  @Get('matches')
  @RequirePermissions(P.matches, P.reports)
  async listMatches(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('status') status?: string,
    @Query('keyword') keyword?: string,
  ) {
    const p = Math.max(1, page ? parseInt(page, 10) : 1);
    const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
    const skip = (p - 1) * size;
    const where: { deletedAt: null; status?: string; OR?: object[] } = { deletedAt: null };
    if (status) where.status = status;
    const kw = keyword?.trim();
    if (kw) {
      where.OR = [
        { matchName: { contains: kw, mode: 'insensitive' } },
        { homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
        { awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
      ];
    }
    const [items, total] = await Promise.all([
      this.prisma.match.findMany({
        where,
        include: {
          homeTeam: true,
          awayTeam: true,
        },
        orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
        skip,
        take: size,
      }),
      this.prisma.match.count({ where }),
    ]);
    return jsonResponse({ items, total, page: p, pageSize: size });
  }

  @Get('matches/:id')
  @RequirePermissions(P.matches, P.reports)
  async getMatch(@Param('id') id: string) {
    const match = await this.matches.getAdminMatchDetail(BigInt(id));
    return jsonResponse(match);
  }

  @Put('matches/:id')
  @RequirePermissions(P.matches)
  async updateMatch(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: UpdatePlatformMatchDto,
  ) {
    const match = await this.matches.updatePlatformMatch(BigInt(id), {
      homeTeamEn: dto.homeTeamEn,
      homeTeamZh: dto.homeTeamZh,
      homeTeamMs: dto.homeTeamMs,
      awayTeamEn: dto.awayTeamEn,
      awayTeamZh: dto.awayTeamZh,
      awayTeamMs: dto.awayTeamMs,
      startTime: parseMatchStartTime(dto.startTime),
      isHot: dto.isHot,
      displayOrder: dto.displayOrder,
      matchName: dto.matchName,
      stage: dto.stage,
      groupName: dto.groupName,
      homeTeamLogoUrl: dto.homeTeamLogoUrl,
      awayTeamLogoUrl: dto.awayTeamLogoUrl,
      updatedBy: operatorId,
    });
    await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_MATCH,
      targetId: id,
      afterData: {
        status: match.status,
        startTime: match.startTime,
        isHot: match.isHot,
        matchName: match.matchName,
      },
    });
    return jsonResponse(match);
  }

  @Delete('matches/:id')
  @RequirePermissions(P.matches)
  async deleteMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
    await this.matches.deleteMatch(BigInt(id));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.DELETE_MATCH,
      targetId: id,
    });
    return jsonResponse({ deleted: true });
  }

  @Get('matches/:id/archive-preview')
  @RequirePermissions(P.matches)
  async getMatchArchivePreview(@Param('id') id: string) {
    const preview = await this.catalogArchive.getMatchArchivePreview(BigInt(id));
    return jsonResponse(preview);
  }

  @Post('matches/:id/archive')
  @RequirePermissions(P.matches)
  async archiveMatch(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: ArchiveMatchDto,
  ) {
    const matchId = BigInt(id);
    const result = await this.catalogArchive.archiveMatch(matchId, {
      force: dto.force === true,
    });
    let voidedCount = 0;
    if (dto.refundPendingBets) {
      const voided = await this.settlement.voidMatchBets(matchId);
      voidedCount = voided.voidedCount;
    }
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.ARCHIVE_MATCH,
      targetId: id,
      afterData: { ...result, force: dto.force === true, voidedCount },
    });
    return jsonResponse({ ...result, voidedCount });
  }

  @Post('matches')
  @RequirePermissions(P.matches)
  async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
    const match = await this.matches.createPlatformMatch({
      leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
      leagueEn: dto.leagueEn ?? '',
      leagueZh: dto.leagueZh ?? '',
      leagueMs: dto.leagueMs,
      homeTeamCode: dto.homeTeamCode,
      awayTeamCode: dto.awayTeamCode,
      homeTeamEn: dto.homeTeamEn,
      homeTeamZh: dto.homeTeamZh,
      homeTeamMs: dto.homeTeamMs,
      awayTeamEn: dto.awayTeamEn,
      awayTeamZh: dto.awayTeamZh,
      awayTeamMs: dto.awayTeamMs,
      startTime: parseMatchStartTime(dto.startTime),
      isHot: dto.isHot,
      displayOrder: dto.displayOrder,
      matchName: dto.matchName,
      stage: dto.stage,
      groupName: dto.groupName,
      leagueLogoUrl: dto.leagueLogoUrl,
      homeTeamLogoUrl: dto.homeTeamLogoUrl,
      awayTeamLogoUrl: dto.awayTeamLogoUrl,
      createdBy: operatorId,
    });
    await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.CREATE_MATCH,
      targetId: match.id,
      afterData: {
        status: match.status,
        startTime: match.startTime,
        leagueId: match.leagueId?.toString(),
        matchName: match.matchName,
      },
    });
    return jsonResponse(match);
  }

  @Post('matches/import')
  @RequirePermissions(P.matches)
  async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
    if (!isZhiboBundlePayload(dto)) {
      throw appBadRequest('IMPORT_MATCHES_REQUIRED');
    }
    const result = await this.matches.importZhiboMatchesBundle(dto, operatorId);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.IMPORT_MATCHES,
      afterData: {
        total: result.total,
        imported: result.imported,
        skipped: result.skipped,
        failed: result.failed,
      },
    });
    return jsonResponse(result);
  }

  @Post('matches/:id/publish')
  @RequirePermissions(P.matches)
  async publishMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
    const match = await this.matches.publishMatch(BigInt(id));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.PUBLISH_MATCH,
      targetId: id,
      afterData: { status: match.status, publishTime: match.publishTime },
    });
    return jsonResponse(match);
  }

  @Post('matches/:id/unpublish')
  @RequirePermissions(P.matches)
  async unpublishMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
    const match = await this.matches.unpublishMatch(BigInt(id));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UNPUBLISH_MATCH,
      targetId: id,
      afterData: { status: match.status },
    });
    return jsonResponse(match);
  }

  @Post('matches/:id/close')
  @RequirePermissions(P.matches)
  async closeMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
    const match = await this.matches.closeMatch(BigInt(id));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.CLOSE_MATCH,
      targetId: id,
      afterData: { status: match.status, closeTime: match.closeTime },
    });
    return jsonResponse(match);
  }

  @Post('matches/:id/reopen')
  @RequirePermissions(P.matches)
  async reopenMatch(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: ReopenMatchDto,
  ) {
    const startTime = dto.startTime ? parseMatchStartTime(dto.startTime) : undefined;
    const match = await this.matches.reopenMatch(BigInt(id), startTime);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.REOPEN_MATCH,
      targetId: id,
      afterData: { status: match.status, startTime: match.startTime },
    });
    return jsonResponse(match);
  }

  @Post('matches/:id/cancel')
  @RequirePermissions(P.matches)
  async cancelMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
    const voided = await this.settlement.cancelMatchAndVoidBets(BigInt(id));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.CANCEL_MATCH,
      targetId: id,
      afterData: voided,
    });
    return jsonResponse(voided);
  }

  @Get('market-definitions')
  @RequirePermissions(P.matches)
  async listMarketDefinitions() {
    return jsonResponse(this.markets.listMarketDefinitions());
  }

  @Get('market-templates')
  @RequirePermissions(P.matches)
  async listMarketTemplates() {
    const templates = await this.markets.listTemplates();
    return jsonResponse(templates);
  }

  @Post('market-templates')
  @RequirePermissions(P.matches)
  async createMarketTemplate(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: MarketTemplateSaveDto,
  ) {
    const template = await this.markets.createTemplate(dto);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.CREATE_MARKET_TEMPLATE,
      targetId: template.id,
      afterData: { name: template.name, sportType: template.sportType },
    });
    return jsonResponse(template);
  }

  @Get('market-templates/:id')
  @RequirePermissions(P.matches)
  async getMarketTemplate(@Param('id') id: string) {
    const template = await this.markets.getTemplate(BigInt(id));
    return jsonResponse(template);
  }

  @Put('market-templates/:id')
  @RequirePermissions(P.matches)
  async updateMarketTemplate(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: MarketTemplateSaveDto,
  ) {
    const template = await this.markets.updateTemplate(BigInt(id), dto);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_MARKET_TEMPLATE,
      targetId: id,
      afterData: { name: template.name, sportType: template.sportType },
    });
    return jsonResponse(template);
  }

  @Post('market-templates/:id/duplicate')
  @RequirePermissions(P.matches)
  async duplicateMarketTemplate(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
  ) {
    const template = await this.markets.duplicateTemplate(BigInt(id));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.DUPLICATE_MARKET_TEMPLATE,
      targetId: template.id,
      afterData: { sourceTemplateId: id, name: template.name },
    });
    return jsonResponse(template);
  }

  @Post('market-templates/:id/set-default')
  @RequirePermissions(P.matches)
  async setDefaultMarketTemplate(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
  ) {
    const template = await this.markets.setDefaultTemplate(BigInt(id));
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.SET_DEFAULT_MARKET_TEMPLATE,
      targetId: id,
      afterData: { name: template.name },
    });
    return jsonResponse(template);
  }

  @Post('matches/:id/markets/templates')
  @RequirePermissions(P.matches)
  async generateTemplates(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: MarketTemplatesDto,
  ) {
    const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.GENERATE_MATCH_MARKETS,
      targetId: id,
      afterData: { marketTypes: dto.marketTypes, ...markets },
    });
    return jsonResponse(markets);
  }

  @Post('matches/:id/markets/apply-template')
  @RequirePermissions(P.matches)
  async applyMarketTemplate(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: ApplyMarketTemplateDto,
  ) {
    const result = await this.markets.applyTemplateToMatch(
      BigInt(id),
      dto.templateId ? BigInt(dto.templateId) : null,
    );
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.APPLY_MARKET_TEMPLATE,
      targetId: id,
      afterData: { requestedTemplateId: dto.templateId, ...result },
    });
    return jsonResponse(result);
  }

  @Put('matches/:id/markets/bulk')
  @RequirePermissions(P.matches)
  async bulkSaveMatchMarkets(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: BulkMatchMarketsDto,
  ) {
    const result = await this.markets.saveMatchMarkets(BigInt(id), dto.markets, operatorId);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.BULK_SAVE_MATCH_MARKETS,
      targetId: id,
      afterData: { ...result, marketCount: dto.markets.length },
    });
    return jsonResponse(result);
  }

  @Put('matches/:id/odds')
  @RequirePermissions(P.matches)
  async batchUpdateMatchOdds(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: BatchMatchOddsDto,
  ) {
    const updates = dto.updates.map((u) => ({
      selectionId: BigInt(u.selectionId),
      odds: u.odds,
    }));
    const results = await this.markets.batchUpdateOdds(updates, operatorId);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_MATCH_ODDS,
      targetId: id,
      afterData: summarizeOddsUpdates(updates.map((u) => u.selectionId)),
    });
    return jsonResponse({ matchId: id, updated: results.length });
  }

  @Patch('markets/:id')
  @RequirePermissions(P.matches)
  async updateMarket(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: UpdateMarketDto,
  ) {
    const market = await this.markets.updateMarket(BigInt(id), {
      promoLabel: dto.promoLabel,
      promoLabelI18n: dto.promoLabelI18n,
      nameI18n: dto.nameI18n,
      status: dto.status,
      lineValue: dto.lineValue,
      showOnPlayer: dto.showOnPlayer,
    });
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_MARKET,
      targetId: id,
      afterData: {
        matchId: market.matchId.toString(),
        status: market.status,
        lineValue: market.lineValue,
        showOnPlayer: market.showOnPlayer,
      },
    });
    return jsonResponse(market);
  }

  @Patch('selections/:id')
  @RequirePermissions(P.matches)
  async updateSelection(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: UpdateSelectionDto,
  ) {
    const selection = await this.markets.updateSelection(
      BigInt(id),
      {
        selectionName: dto.selectionName,
        nameI18n: dto.nameI18n,
        odds: dto.odds,
        status: dto.status,
      },
      operatorId,
    );
    const market = await this.prisma.market.findUnique({
      where: { id: selection.marketId },
      select: { matchId: true },
    });
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_SELECTION,
      targetId: id,
      afterData: {
        matchId: market?.matchId?.toString(),
        odds: dto.odds,
        status: dto.status,
      },
    });
    return jsonResponse(selection);
  }

  @Put('selections/:id/odds')
  @RequirePermissions(P.matches)
  async updateOdds(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: UpdateOddsDto,
  ) {
    const selection = await this.markets.updateOdds(BigInt(id), dto.odds, operatorId);
    const market = await this.prisma.market.findUnique({
      where: { id: selection.marketId },
      select: { matchId: true },
    });
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_MATCH_ODDS,
      targetId: market?.matchId ?? id,
      afterData: { selectionId: id, odds: dto.odds },
    });
    return jsonResponse(selection);
  }

  @Get('outrights')
  @RequirePermissions(P.matches, P.reports)
  async listOutrights() {
    const data = await this.outright.listForAdmin();
    return jsonResponse(data);
  }

  @Get('outrights/leagues')
  @RequirePermissions(P.matches, P.reports)
  async listOutrightLeagues() {
    const data = await this.outright.listLeagueOptions();
    return jsonResponse(data);
  }

  @Post('outrights')
  @RequirePermissions(P.matches)
  async createOutright(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: CreateOutrightDto,
  ) {
    const data = await this.outright.createForAdmin({
      leagueId: BigInt(dto.leagueId),
      titleZh: dto.titleZh,
      titleEn: dto.titleEn,
      titleMs: dto.titleMs,
      status: dto.status,
    });
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.CREATE_OUTRIGHT,
      targetId: data.id,
      afterData: { leagueId: dto.leagueId, status: dto.status, titleZh: dto.titleZh },
    });
    return jsonResponse(data);
  }

  @Post('outrights/import/wc2026')
  @RequirePermissions(P.matches)
  async importWc2026Outright(@CurrentUser('id') operatorId: bigint) {
    const data = await this.outright.importWc2026Canonical();
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT,
      afterData: data,
    });
    return jsonResponse(data);
  }

  /** @deprecated */
  @Get('outrights/wc2026')
  @RequirePermissions(P.matches, P.reports)
  async getWc2026OutrightLegacy() {
    const list = await this.outright.listForAdmin();
    const wc = list.find((e) => e.leagueCode === 'WC2026');
    if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND');
    return jsonResponse(await this.outright.getForAdmin(BigInt(wc.id)));
  }

  /** @deprecated */
  @Put('outrights/wc2026/odds')
  @RequirePermissions(P.matches)
  async updateWc2026OutrightOddsLegacy(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: BatchOutrightOddsDto,
  ) {
    const list = await this.outright.listForAdmin();
    const wc = list.find((e) => e.leagueCode === 'WC2026');
    if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND');
    const data = await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_OUTRIGHT_ODDS,
      targetId: wc.id,
      afterData: summarizeOddsUpdates(dto.updates.map((u) => u.selectionId)),
    });
    return jsonResponse(data);
  }

  /** @deprecated */
  @Post('outrights/wc2026/apply-canonical')
  @RequirePermissions(P.matches)
  async applyWc2026CanonicalLegacy(@CurrentUser('id') operatorId: bigint) {
    const data = await this.outright.importWc2026Canonical();
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT,
      afterData: data,
    });
    return jsonResponse(data);
  }

  @Get('outrights/:matchId')
  @RequirePermissions(P.matches, P.reports)
  async getOutright(@Param('matchId') matchId: string) {
    const data = await this.outright.getForAdmin(BigInt(matchId));
    return jsonResponse(data);
  }

  @Put('outrights/:matchId')
  @RequirePermissions(P.matches)
  async updateOutright(
    @CurrentUser('id') operatorId: bigint,
    @Param('matchId') matchId: string,
    @Body() dto: UpdateOutrightDto,
  ) {
    const data = await this.outright.updateForAdmin(BigInt(matchId), dto);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_OUTRIGHT,
      targetId: matchId,
      afterData: { status: dto.status, matchName: dto.matchName, isHot: dto.isHot },
    });
    return jsonResponse(data);
  }

  @Put('outrights/:matchId/odds')
  @RequirePermissions(P.matches)
  async updateOutrightOdds(
    @CurrentUser('id') operatorId: bigint,
    @Param('matchId') matchId: string,
    @Body() dto: BatchOutrightOddsDto,
  ) {
    const data = await this.outright.batchUpdateOdds(
      BigInt(matchId),
      dto.updates,
      operatorId,
    );
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_OUTRIGHT_ODDS,
      targetId: matchId,
      afterData: summarizeOddsUpdates(dto.updates.map((u) => u.selectionId)),
    });
    return jsonResponse(data);
  }

  @Post('outrights/:matchId/selections')
  @RequirePermissions(P.matches)
  async addOutrightSelection(
    @CurrentUser('id') operatorId: bigint,
    @Param('matchId') matchId: string,
    @Body() dto: AddOutrightSelectionDto,
  ) {
    const data = await this.outright.addSelection(BigInt(matchId), dto);
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.ADD_OUTRIGHT_SELECTION,
      targetId: matchId,
      afterData: dto,
    });
    return jsonResponse(data);
  }

  @Post('outrights/:matchId/selections/batch')
  @RequirePermissions(P.matches)
  async addOutrightSelectionsBatch(
    @CurrentUser('id') operatorId: bigint,
    @Param('matchId') matchId: string,
    @Body() dto: AddOutrightSelectionsBatchDto,
  ) {
    if (!dto.items?.length) {
      throw appBadRequest('OUTRIGHT_TEAMS_REQUIRED');
    }
    const data = await this.outright.addSelectionsBatch(
      BigInt(matchId),
      dto.items,
    );
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.BATCH_ADD_OUTRIGHT_SELECTIONS,
      targetId: matchId,
      afterData: { count: dto.items.length },
    });
    return jsonResponse(data);
  }

  @Patch('outrights/:matchId/selections/:selectionId')
  @RequirePermissions(P.matches)
  async updateOutrightSelectionTeam(
    @CurrentUser('id') operatorId: bigint,
    @Param('matchId') matchId: string,
    @Param('selectionId') selectionId: string,
    @Body() dto: UpdateOutrightSelectionTeamDto,
  ) {
    const data = await this.outright.updateSelectionTeam(
      BigInt(matchId),
      BigInt(selectionId),
      dto,
    );
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.UPDATE_OUTRIGHT_SELECTION,
      targetId: selectionId,
      afterData: { matchId, ...dto },
    });
    return jsonResponse(data);
  }

  @Delete('outrights/:matchId/selections/:selectionId')
  @RequirePermissions(P.matches)
  async removeOutrightSelection(
    @CurrentUser('id') operatorId: bigint,
    @Param('matchId') matchId: string,
    @Param('selectionId') selectionId: string,
  ) {
    const data = await this.outright.closeSelection(
      BigInt(matchId),
      BigInt(selectionId),
    );
    await logCatalogAudit(this.audit, {
      operatorId,
      action: CatalogAuditAction.REMOVE_OUTRIGHT_SELECTION,
      targetId: selectionId,
      afterData: { matchId },
    });
    return jsonResponse(data);
  }

  @Get('matches/:id/settlement/summary')
  @RequirePermissions(P.settlement, P.reports)
  async getMatchSettlementSummary(@Param('id') id: string) {
    const data = await this.settlement.getMatchBetStatsSummary(BigInt(id));
    return jsonResponse(data);
  }

  @Get('matches/:id/settlement/bets')
  @RequirePermissions(P.settlement, P.reports)
  async getMatchSettlementBets(
    @Param('id') id: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    const data = await this.settlement.getMatchBetStatsBets(BigInt(id), {
      page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
      pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
    });
    return jsonResponse(data);
  }

  @Get('matches/:id/settlement/stats')
  @RequirePermissions(P.settlement, P.reports)
  async getMatchSettlementStats(
    @Param('id') id: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    const data = await this.settlement.getMatchBetStats(BigInt(id), {
      page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
      pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
    });
    return jsonResponse(data);
  }

  // 智能比分推荐已关闭
  // @Post('matches/:id/settlement/smart-score')
  // async suggestSmartScore(...) { ... }

  @Post('matches/:id/settlement/score')
  @RequirePermissions(P.settlement)
  async recordScore(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: ScoreDto,
  ) {
    const result = await this.settlement.recordScore(
      BigInt(id),
      dto.htHome ?? 0,
      dto.htAway ?? 0,
      dto.ftHome ?? 0,
      dto.ftAway ?? 0,
      operatorId,
      dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
      {
        homeCorners: dto.homeCorners ?? null,
        awayCorners: dto.awayCorners ?? null,
        homeYellowCards: dto.homeYellowCards ?? null,
        awayYellowCards: dto.awayYellowCards ?? null,
        homeRedCards: dto.homeRedCards ?? null,
        awayRedCards: dto.awayRedCards ?? null,
        homeCards: dto.homeCards ?? null,
        awayCards: dto.awayCards ?? null,
      },
    );
    return jsonResponse(result);
  }

  @Post('matches/:id/settlement/preview')
  @RequirePermissions(P.settlement)
  async settlementPreview(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto?: SettlementPreviewDto,
  ) {
    const matchId = BigInt(id);
    const preview = await this.settlement.previewSettlement(matchId, operatorId, {
      htHome: dto?.htHome,
      htAway: dto?.htAway,
      ftHome: dto?.ftHome,
      ftAway: dto?.ftAway,
      homeCorners: dto?.homeCorners,
      awayCorners: dto?.awayCorners,
      homeYellowCards: dto?.homeYellowCards,
      awayYellowCards: dto?.awayYellowCards,
      homeRedCards: dto?.homeRedCards,
      awayRedCards: dto?.awayRedCards,
      homeCards: dto?.homeCards,
      awayCards: dto?.awayCards,
      winnerTeamId: dto?.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
      page: dto?.page ? Math.max(1, dto.page) : 1,
      pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
    });
    return jsonResponse(preview);
  }

  @Get('matches/:id/settlement/preview')
  @RequirePermissions(P.settlement, P.reports)
  async getActiveSettlementPreview(
    @Param('id') id: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    const matchId = BigInt(id);
    const preview = await this.settlement.getActivePreview(matchId, {
      page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
      pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
    });
    return jsonResponse(preview);
  }

  @Get('matches/:id/settlement/history')
  @RequirePermissions(P.settlement, P.reports)
  async getMatchSettlementHistory(@Param('id') id: string) {
    const matchId = BigInt(id);
    const history = await this.settlement.getMatchSettlementHistory(matchId);
    return jsonResponse(history);
  }

  @Get('settlement/:batchId/preview-items')
  @RequirePermissions(P.settlement)
  async getSettlementPreviewItems(
    @Param('batchId') batchId: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    const data = await this.settlement.getPreviewSettlementItems(BigInt(batchId), {
      page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
      pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
    });
    return jsonResponse(data);
  }

  @Post('settlement/:batchId/confirm')
  @RequirePermissions(P.settlement)
  async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
    const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'CONFIRM_SETTLEMENT',
      module: 'SETTLEMENT',
      targetId: batchId,
    });
    return jsonResponse(result);
  }

  @Post('matches/:id/resettle/preview')
  @RequirePermissions(P.resettle)
  async resettlePreview(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto: ResettlePreviewDto,
  ) {
    const preview = await this.settlement.previewResettlement(
      BigInt(id),
      {
        htHome: dto.htHome ?? 0,
        htAway: dto.htAway ?? 0,
        ftHome: dto.ftHome ?? 0,
        ftAway: dto.ftAway ?? 0,
        homeCorners: dto.homeCorners ?? null,
        awayCorners: dto.awayCorners ?? null,
        homeYellowCards: dto.homeYellowCards ?? null,
        awayYellowCards: dto.awayYellowCards ?? null,
        homeRedCards: dto.homeRedCards ?? null,
        awayRedCards: dto.awayRedCards ?? null,
        homeCards: dto.homeCards ?? null,
        awayCards: dto.awayCards ?? null,
      },
      operatorId,
      dto.reason,
      dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
    );
    return jsonResponse(preview);
  }

  @Post('resettle/:batchId/confirm')
  @RequirePermissions(P.resettle)
  async confirmResettlement(
    @CurrentUser('id') operatorId: bigint,
    @Param('batchId') batchId: string,
  ) {
    const result = await this.settlement.confirmResettlement(BigInt(batchId), operatorId);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'CONFIRM_RESETTLE',
      module: 'SETTLEMENT',
      targetId: batchId,
    });
    return jsonResponse(result);
  }

  @Get('bets')
  @RequirePermissions(P.bets)
  async listBets(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('keyword') keyword?: string,
    @Query('status') status?: string,
    @Query('betType') betType?: string,
    @Query('placedFrom') placedFrom?: string,
    @Query('placedTo') placedTo?: string,
  ) {
    const result = await this.bets.listBetsAdmin({
      page: page ? parseInt(page, 10) : 1,
      pageSize: pageSize ? parseInt(pageSize, 10) : 10,
      keyword,
      status: status || undefined,
      betType: betType || undefined,
      placedFrom,
      placedTo,
    });
    return jsonResponse(result);
  }

  @Get('bets/:id')
  @RequirePermissions(P.bets)
  async getBet(@Param('id') id: string) {
    const detail = await this.bets.getBetAdminDetail(BigInt(id));
    return jsonResponse(detail);
  }

  @Get('settings/cashback/platform-direct')
  @RequirePermissions(P.cashback, P.reports)
  async getPlatformDirectCashbackSettings() {
    const settings = await this.systemConfig.getPlatformDirectCashbackSettings();
    return jsonResponse(settings);
  }

  @Put('settings/cashback/platform-direct')
  @RequirePermissions(P.cashback)
  async updatePlatformDirectCashbackSettings(@Body() dto: PlatformDirectCashbackSettingsDto) {
    const settings = await this.systemConfig.updatePlatformDirectCashbackSettings(dto);
    return jsonResponse(settings);
  }

  @Post('cashbacks/preview')
  @RequirePermissions(P.cashback, P.reports)
  async cashbackPreview(@Body() dto: CashbackPreviewDto) {
    const preview = await this.cashback.previewBatch(
      new Date(dto.periodStart),
      new Date(dto.periodEnd),
    );
    return jsonResponse(preview);
  }

  @Get('cashbacks')
  @RequirePermissions(P.cashback, P.reports)
  async listCashbacks(
    @Query('page') page = '1',
    @Query('pageSize') pageSize = '10',
    @Query('status') status?: string,
  ) {
    const result = await this.cashback.listBatches({
      page: Number(page) || 1,
      pageSize: Number(pageSize) || 10,
      status,
    });
    return jsonResponse(result);
  }

  @Get('cashbacks/:batchId')
  @RequirePermissions(P.cashback, P.reports)
  async getCashbackBatch(@Param('batchId') batchId: string) {
    const detail = await this.cashback.getBatchDetail(BigInt(batchId));
    return jsonResponse(detail);
  }

  @Post('cashbacks/:batchId/confirm')
  @RequirePermissions(P.cashback)
  async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
    const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'CONFIRM_CASHBACK',
      module: 'CASHBACK',
      targetId: batchId,
    });
    return jsonResponse(result);
  }

  @Post('cashbacks/:batchId/cancel')
  @RequirePermissions(P.cashback)
  async cashbackCancel(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
    const result = await this.cashback.cancelBatch(BigInt(batchId));
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'CANCEL_CASHBACK',
      module: 'CASHBACK',
      targetId: batchId,
    });
    return jsonResponse(result);
  }

  @Post('uploads')
  @RequirePermissions(P.content, P.matches)
  @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
  async uploadAsset(
    @CurrentUser() user: AdminUploadUser & { id?: bigint },
    @UploadedFile() file: UploadedImage | undefined,
    @Query('category') rawCategory?: string,
  ) {
    const category = uploadCategory(rawCategory);
    assertUploadPermission(user, category);
    assertImageFile(file);

    const filename = uploadFilename(file);
    const root = getUploadRoot();
    const targetDir = join(root, category);
    await mkdir(targetDir, { recursive: true });
    await writeFile(join(targetDir, filename), file.buffer);

    const url = `/uploads/${category}/${filename}`;
    await this.prisma.uploadedFile.create({
      data: {
        filename,
        category,
        mimeType: file.mimetype,
        size: file.size,
        url,
        uploadedBy: user.id ?? null,
      },
    });

    return jsonResponse({ category, filename, size: file.size, mimeType: file.mimetype, url });
  }

  @Get('files')
  @RequirePermissions(P.content, P.matches)
  async listFiles(
    @Query('category') category?: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('imagesOnly') imagesOnly?: string,
  ) {
    const where: {
      category?: string | { in: string[] };
      mimeType?: { startsWith: string };
    } = {};

    if (category && UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
      where.category = category;
    } else if (imagesOnly === '1' || imagesOnly === 'true') {
      // 媒体库选择器：默认可选 banners/teams/contents/payments，不含 deposits
      where.category = { in: ['banners', 'teams', 'contents', 'payments'] };
    }

    if (imagesOnly === '1' || imagesOnly === 'true') {
      where.mimeType = { startsWith: 'image/' };
    }

    const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
    const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;

    const [files, total] = await Promise.all([
      this.prisma.uploadedFile.findMany({ where, orderBy: { createdAt: 'desc' }, take, skip }),
      this.prisma.uploadedFile.count({ where }),
    ]);

    const usedUrls = await this.getUsedFileUrls();
    const items = files.map((f) => ({ ...f, inUse: usedUrls.has(f.url) }));

    return jsonResponse({ items, total, page: skip / take + 1, pageSize: take });
  }

  @Delete('files/unused')
  @RequirePermissions(P.content)
  async purgeUnusedFiles(@CurrentUser('id') operatorId: bigint) {
    const all = await this.prisma.uploadedFile.findMany();
    const usedUrls = await this.getUsedFileUrls();
    const unused = all.filter((f) => !usedUrls.has(f.url));

    const root = getUploadRoot();
    let deleted = 0;
    for (const f of unused) {
      try {
        await unlink(join(root, f.category, f.filename));
      } catch { /* file already missing from disk */ }
      await this.prisma.uploadedFile.delete({ where: { id: f.id } });
      deleted++;
    }

    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'PURGE_UNUSED_FILES',
      module: 'MEDIA',
      afterData: JSON.stringify({ deleted }),
    });

    return jsonResponse({ deleted });
  }

  @Delete('files/:id')
  @RequirePermissions(P.content, P.matches)
  async deleteFile(
    @CurrentUser() user: AdminUploadUser,
    @Param('id') id: string,
  ) {
    const record = await this.prisma.uploadedFile.findUnique({ where: { id } });
    if (!record) throw appBadRequest('FILE_NOT_FOUND');
    assertUploadPermission(user, record.category as any);

    const root = getUploadRoot();
    try {
      await unlink(join(root, record.category, record.filename));
    } catch { /* already gone */ }
    await this.prisma.uploadedFile.delete({ where: { id } });

    return jsonResponse({ ok: true });
  }

  @Delete('uploads/by-url')
  @RequirePermissions(P.content, P.matches)
  async deleteFileByUrl(@Body() body: { url: string }) {
    const { url } = body;
    if (!url || typeof url !== 'string') throw appBadRequest('URL_REQUIRED');

    const record = await this.prisma.uploadedFile.findFirst({ where: { url } });
    if (!record) return jsonResponse({ ok: true, note: 'not_found' });

    const root = getUploadRoot();
    try {
      await unlink(join(root, record.category, record.filename));
    } catch { /* already gone */ }
    await this.prisma.uploadedFile.delete({ where: { id: record.id } });

    return jsonResponse({ ok: true });
  }

  private async getUsedFileUrls(): Promise<Set<string>> {
    const [ctRows, leagueRows, teamRows, prefRows] = await Promise.all([
      this.prisma.contentTranslation.findMany({ select: { imageUrl: true } }),
      this.prisma.league.findMany({ select: { logoUrl: true } }),
      this.prisma.team.findMany({ select: { logoUrl: true } }),
      this.prisma.userPreference.findMany({ select: { avatarKey: true } }),
    ]);

    const urls = new Set<string>();
    for (const r of ctRows) if (r.imageUrl) urls.add(r.imageUrl);
    for (const r of leagueRows) if (r.logoUrl) urls.add(r.logoUrl);
    for (const r of teamRows) if (r.logoUrl) urls.add(r.logoUrl);
    for (const r of prefRows) if (r.avatarKey) urls.add(r.avatarKey);
    return urls;
  }

  @Get('files/storage-stats')
  @RequirePermissions(P.content)
  async getStorageStats() {
    const statsGroup = await this.prisma.uploadedFile.groupBy({
      by: ['category'],
      _count: { _all: true },
      _sum: { size: true }
    });

    const categories = statsGroup.map((g) => ({
      category: g.category,
      count: g._count._all,
      sizeBytes: g._sum.size ?? 0,
    }));

    // Calculate deposits on disk
    let depositCount = 0;
    let depositSizeBytes = 0;
    const depositsDir = join(getUploadRoot(), 'deposits');
    try {
      const files = await readdir(depositsDir);
      for (const file of files) {
        const filePath = join(depositsDir, file);
        try {
          const fileStats = await stat(filePath);
          if (fileStats.isFile()) {
            depositCount++;
            depositSizeBytes += fileStats.size;
          }
        } catch {}
      }
    } catch {}

    categories.push({
      category: 'deposits',
      count: depositCount,
      sizeBytes: depositSizeBytes,
    });

    const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
    const totalSizeBytes = categories.reduce((sum, c) => sum + c.sizeBytes, 0);

    return jsonResponse({
      categories,
      total: {
        count: totalCount,
        sizeBytes: totalSizeBytes,
      },
    });
  }

  @Get('deposits/screenshot-cleanup-config')
  @RequirePermissions(P.content)
  async getScreenshotCleanupConfig() {
    const config = await this.systemConfig.getDepositScreenshotCleanupConfig();
    return jsonResponse(config);
  }

  @Put('deposits/screenshot-cleanup-config')
  @RequirePermissions(P.content)
  async updateScreenshotCleanupConfig(@Body() body: UpdateDepositCleanupConfigDto) {
    const config = await this.systemConfig.updateDepositScreenshotCleanupConfig(body);
    return jsonResponse(config);
  }

  @Delete('deposits/screenshots')
  @RequirePermissions(P.content)
  async cleanOldScreenshots(
    @CurrentUser('id') operatorId: bigint,
    @Query('before') beforeStr?: string,
  ) {
    if (!beforeStr) throw appBadRequest('BEFORE_DATE_REQUIRED');
    const beforeDate = new Date(beforeStr);
    if (Number.isNaN(beforeDate.getTime())) {
      throw appBadRequest('INVALID_BEFORE_DATE');
    }
    if (beforeDate.getTime() > Date.now()) {
      throw appBadRequest('BEFORE_DATE_CANNOT_BE_FUTURE');
    }

    const result = await this.depositCleanup.cleanOldDepositScreenshots(beforeDate);

    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'PURGE_DEPOSIT_SCREENSHOTS',
      module: 'MEDIA',
      afterData: JSON.stringify({
        before: beforeDate.toISOString(),
        cleanedCount: result.cleaned,
        freedBytes: result.freedBytes,
      }),
    });

    return jsonResponse(result);
  }

  @Get('contents/inbox-notify-settings')
  @RequirePermissions(P.content, P.reports)
  async getInboxNotifySettings() {
    const settings = await this.systemConfig.getInboxNotifySettings();
    return jsonResponse(settings);
  }

  @Put('contents/inbox-notify-settings')
  @RequirePermissions(P.content)
  async updateInboxNotifySettings(@Body() dto: InboxNotifySettingsDto) {
    const settings = await this.systemConfig.updateInboxNotifySettings(dto);
    return jsonResponse(settings);
  }

  @Get('player-message-broadcasts')
  @RequirePermissions(P.content, P.reports)
  async listPlayerMessageBroadcasts(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    const p = Math.max(1, page ? parseInt(page, 10) || 1 : 1);
    const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 20), 50);
    const result = await this.playerMessages.listBroadcasts(p, size);
    return jsonResponse(result);
  }

  @Post('player-message-broadcasts')
  @RequirePermissions(P.content)
  async createPlayerMessageBroadcast(
    @CurrentUser('id') operatorId: bigint,
    @Body() dto: CreatePlayerMessageBroadcastDto,
  ) {
    const operator = await this.prisma.user.findUnique({
      where: { id: operatorId },
      select: { username: true },
    });
    const item = await this.playerMessages.createCustomBroadcast({
      translations: dto.translations,
      targetType: dto.targetType,
      targetUsername: dto.targetUsername,
      createdById: operatorId,
      createdByUsername: operator?.username ?? null,
    });
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'SEND_PLAYER_MESSAGE_BROADCAST',
      module: 'CONTENT',
      afterData: JSON.stringify({
        id: item.id,
        title: item.title,
        targetType: item.targetType,
        recipientCount: item.recipientCount,
      }),
    });
    return jsonResponse(item);
  }

  @Delete('player-message-broadcasts/:id')
  @RequirePermissions(P.content)
  async deletePlayerMessageBroadcast(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
  ) {
    const result = await this.playerMessages.deleteBroadcast(BigInt(id));
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'DELETE_PLAYER_MESSAGE_BROADCAST',
      module: 'CONTENT',
      targetId: id,
      afterData: JSON.stringify(result),
    });
    return jsonResponse(result);
  }

  @Get('contents')
  @RequirePermissions(P.content, P.reports)
  async listContents(
    @Query('type') type?: string,
    @Query('status') status?: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    const p = Math.max(1, page ? parseInt(page, 10) || 1 : 1);
    const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 20), 100);
    const result = await this.content.listForAdmin(type, status, p, size);
    return jsonResponse(result);
  }

  @Get('contents/:id')
  @RequirePermissions(P.content, P.reports)
  async getContent(@Param('id') id: string) {
    const item = await this.content.getForAdmin(BigInt(id));
    return jsonResponse(item);
  }

  @Post('contents')
  @RequirePermissions(P.content)
  async createContent(@Body() dto: CreateContentDto) {
    const { notifyInbox, ...createDto } = dto;
    const item = await this.content.create(createDto);
    let notifiedCount: number | undefined;
    if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
      const inboxNotify = await this.systemConfig.getInboxNotifySettings();
      if (inboxNotify.inboxEnabled) {
        const translations = createDto.translations.map((tr) => ({
          locale: tr.locale,
          title: tr.title,
          body: tr.body,
        }));
        if (createDto.contentType === 'BANNER' && inboxNotify.banner) {
          notifiedCount = await this.playerMessages.broadcastBannerPromotion({
            contentId: BigInt(item.id),
            translations,
          });
        } else if (
          (createDto.contentType === 'NOTICE' || createDto.contentType === 'TICKER') &&
          inboxNotify.announcement
        ) {
          notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
            contentId: BigInt(item.id),
            translations,
          });
        }
      }
    }
    return jsonResponse({ ...item, notifiedCount });
  }

  @Put('contents/:id')
  @RequirePermissions(P.content)
  async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
    const item = await this.content.update(BigInt(id), dto);
    return jsonResponse(item);
  }

  @Patch('contents/:id/status')
  @RequirePermissions(P.content)
  async updateContentStatus(
    @Param('id') id: string,
    @Body() dto: ContentStatusDto,
  ) {
    const item = await this.content.updateStatus(BigInt(id), dto.status);
    return jsonResponse(item);
  }

  @Delete('contents/:id')
  @RequirePermissions(P.content)
  async deleteContent(@Param('id') id: string) {
    const result = await this.content.remove(BigInt(id));
    return jsonResponse(result);
  }

  @Get('i18n/messages')
  @RequirePermissions(P.settings, P.reports)
  async getMessages(@Query('locale') locale = 'en-US') {
    const messages = await this.i18n.getMessages(locale);
    return jsonResponse(messages);
  }

  @Get('system/smoke-tests')
  @RequirePermissions(P.settings)
  getSmokeTestsStatus() {
    return jsonResponse({ allowed: this.smokeTests.isAllowed() });
  }

  @Get('smoke-tests/suites')
  @RequirePermissions(P.settings)
  async smokeTestSuites() {
    this.smokeTests.assertAllowed();
    return jsonResponse({
      suites: this.smokeTests.listSuites(),
      cases: this.smokeTests.listCases(),
      lastRun: this.smokeTests.getLastRun(),
    });
  }

  @Get('smoke-tests/last-run')
  @RequirePermissions(P.settings)
  async smokeTestLastRun() {
    this.smokeTests.assertAllowed();
    return jsonResponse(this.smokeTests.getLastRun());
  }

  @Post('smoke-tests/run')
  @RequirePermissions(P.settings)
  async runSmokeTests(
    @CurrentUser('id') operatorId: bigint,
    @Body() body: { suites?: string[] },
  ) {
    this.smokeTests.assertAllowed();
    const summary = await this.smokeTests.run(body?.suites, operatorId);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'RUN_SMOKE_TESTS',
      module: 'SYSTEM',
      targetId: summary.runId,
      afterData: {
        passed: summary.passed,
        failed: summary.failed,
        total: summary.total,
        suites: summary.suites,
      },
    });
    return jsonResponse(summary);
  }

  @Get('audit-logs')
  @RequirePermissions(P.audit)
  async auditLogs(
    @CurrentUser('id') viewerId: bigint,
    @CurrentUser('role') viewerRole: string | undefined,
    @CurrentUser('userType') viewerUserType: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('module') module?: string,
  ) {
    const result = await this.audit.list(
      page ? parseInt(page, 10) : 1,
      pageSize ? parseInt(pageSize, 10) : 10,
      {
        module: module || undefined,
        excludeModule: CATALOG_AUDIT_MODULE,
      },
      { viewerId, viewerRole, viewerUserType },
    );
    return jsonResponse(result);
  }

  @Get('catalog-audit-logs')
  @RequirePermissions(P.matches)
  async catalogAuditLogs(
    @CurrentUser('id') viewerId: bigint,
    @CurrentUser('role') viewerRole: string | undefined,
    @CurrentUser('userType') viewerUserType: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    const result = await this.audit.list(
      page ? parseInt(page, 10) : 1,
      pageSize ? parseInt(pageSize, 10) : 10,
      { module: CATALOG_AUDIT_MODULE },
      { viewerId, viewerRole, viewerUserType },
    );
    return jsonResponse(result);
  }

  // ============ Payment Methods ============

  @Post('payment-methods')
  @RequirePermissions(P.depositManage)
  async createPaymentMethod(
    @CurrentUser('id') operatorId: bigint,
    @Body() body: {
      methodType: string;
      bankName?: string;
      accountHolder?: string;
      accountNumber?: string;
      usdtAddress?: string;
      qrCodeUrl?: string;
      displayName?: string;
      sortOrder?: number;
      isActive?: boolean;
      showOnPlayer?: boolean;
      translations?: {
        displayName?: Record<string, string>;
        bankName?: Record<string, string>;
      };
    },
  ) {
    if (!body.methodType || !['BANK', 'USDT'].includes(body.methodType)) {
      throw appBadRequest('INVALID_METHOD_TYPE');
    }
    const method = await this.depositService.createPaymentMethod({
      ...body,
      createdBy: operatorId,
    });
    return jsonResponse(method);
  }

  @Get('payment-methods')
  @RequirePermissions(P.depositManage)
  async listPaymentMethods(@Query('methodType') methodType?: string) {
    const items = await this.depositService.listPaymentMethods({
      methodType: methodType || undefined,
    });
    return jsonResponse(items);
  }

  @Put('payment-methods/:id')
  @RequirePermissions(P.depositManage)
  async updatePaymentMethod(
    @Param('id') id: string,
    @Body() body: {
      bankName?: string;
      accountHolder?: string;
      accountNumber?: string;
      usdtAddress?: string;
      qrCodeUrl?: string;
      displayName?: string;
      sortOrder?: number;
      isActive?: boolean;
      showOnPlayer?: boolean;
      translations?: {
        displayName?: Record<string, string>;
        bankName?: Record<string, string>;
      };
    },
  ) {
    const method = await this.depositService.updatePaymentMethod(BigInt(id), body);
    return jsonResponse(method);
  }

  @Delete('payment-methods/:id')
  @RequirePermissions(P.depositManage)
  async deletePaymentMethod(@Param('id') id: string) {
    await this.depositService.deletePaymentMethod(BigInt(id));
    return jsonResponse({ success: true });
  }

  // ============ Deposit Orders (Admin Review) ============

  @Get('deposit-orders')
  @RequirePermissions(P.depositReview)
  async listDepositOrders(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('status') status?: string,
    @Query('keyword') keyword?: string,
    @Query('methodType') methodType?: string,
    @Query('dateFrom') dateFrom?: string,
    @Query('dateTo') dateTo?: string,
  ) {
    const result = await this.depositService.listDepositOrders({
      page: page ? parseInt(page, 10) : undefined,
      pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
      status: status || undefined,
      keyword: keyword || undefined,
      methodType: methodType || undefined,
      dateFrom: dateFrom ? new Date(dateFrom) : undefined,
      dateTo: dateTo ? new Date(dateTo) : undefined,
    });
    return jsonResponse(result);
  }

  @Get('deposit-orders/pending-count')
  @RequirePermissions(P.depositReview)
  async depositPendingCount() {
    const count = await this.depositService.countPendingDepositOrders();
    return jsonResponse({ count });
  }

  @Get('deposit-orders/:id/audit-logs')
  @RequirePermissions(P.depositReview)
  async depositOrderAuditLogs(@Param('id') id: string) {
    const items = await this.depositService.getDepositOrderAuditLogs(BigInt(id));
    return jsonResponse({ items });
  }

  @Post('deposit-orders/:id/approve')
  @RequirePermissions(P.depositReview)
  async approveDepositOrder(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() body: { approvedAmount?: number; remark?: string },
  ) {
    const result = await this.depositService.approveDepositOrder(
      BigInt(id),
      operatorId,
      body.approvedAmount,
      body.remark,
    );
    return jsonResponse(result);
  }

  @Post('deposit-orders/:id/reject')
  @RequirePermissions(P.depositReview)
  async rejectDepositOrder(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() body: { reason: string },
  ) {
    if (!body.reason?.trim()) throw appBadRequest('REASON_REQUIRED');
    const result = await this.depositService.rejectDepositOrder(
      BigInt(id),
      operatorId,
      body.reason.trim(),
    );
    return jsonResponse(result);
  }

  @Post('deposit-orders/:id/reopen')
  @RequirePermissions(P.depositReview)
  async reopenDepositOrder(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
  ) {
    const result = await this.depositService.reopenDepositOrderForReview(
      BigInt(id),
      operatorId,
    );
    return jsonResponse(result);
  }

  @Delete('deposit-orders/:id')
  @RequirePermissions(P.depositReview)
  async deleteDepositOrder(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
  ) {
    const result = await this.depositService.deleteDepositOrder(
      BigInt(id),
      operatorId,
    );
    return jsonResponse(result);
  }
}
