import {
  BadRequestException,
  Controller,
  Delete,
  ForbiddenException,
  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 } from 'fs/promises';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.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 { 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 {
  IsString,
  IsNumber,
  IsOptional,
  IsArray,
  IsBoolean,
  MinLength,
  IsIn,
  Min,
  Equals,
  ValidateIf,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';

const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] 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 uploadCategory(value?: string): UploadCategory {
  const category = (value || 'contents').trim();
  if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
    return category as UploadCategory;
  }
  throw new BadRequestException('Unsupported upload category');
}

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 new ForbiddenException('Insufficient permissions');
  }
}

function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage {
  if (!file?.buffer?.length) {
    throw new BadRequestException('Image file is required');
  }
  if (!IMAGE_MIME_EXT[file.mimetype]) {
    throw new BadRequestException('Only PNG, JPG, WEBP, GIF or SVG images are allowed');
  }
  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 new BadRequestException('Unsafe SVG content is not allowed');
    }
  }
}

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

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

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

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

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

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

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

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 BatchMatchOddsDto {
  @IsArray()
  updates!: OutrightOddsUpdateItemDto[];
}

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

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

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

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

  @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;

  /** 冠军盘结算：获胜球队 ID */
  @IsOptional()
  @IsNumber()
  winnerTeamId?: 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 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;

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

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

  @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('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 new BadRequestException('确认短语不正确，请输入 RESET');
    }
    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), dto);
    await this.audit.log({
      operatorId,
      operatorType: 'ADMIN',
      action: 'UPDATE_PLAYER',
      module: 'USERS',
      targetId: id,
    });
    return jsonResponse(detail);
  }

  @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() {
    const agents = await this.prisma.user.findMany({
      where: { userType: 'AGENT', deletedAt: null },
      select: {
        id: true,
        username: true,
        agentLevel: true,
        parent: { select: { username: true } },
      },
      orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
    });
    return jsonResponse(
      agents.map((a) => ({
        id: a.id.toString(),
        username: a.username,
        level: a.agentLevel ?? 1,
        parentUsername: a.parent?.username ?? null,
      })),
    );
  }

  @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('parentAgentId') parentAgentId?: string,
  ) {
    const parsedLevel = level === '2' ? 2 : level === '1' ? 1 : undefined;
    const result = await this.agents.listAgentsAdmin({
      page: page ? parseInt(page, 10) : 1,
      pageSize: pageSize ? parseInt(pageSize, 10) : 10,
      keyword,
      status,
      level: parsedLevel,
      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('transactionType') transactionType?: 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,
      transactionType,
    });
    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.wallet.withdraw(
      BigInt(dto.userId),
      dto.amount,
      operatorId,
      dto.remark,
      dto.requestId,
    );
    return jsonResponse(result);
  }

  @Get('wallet/transactions')
  @RequirePermissions(P.walletDeposit, P.walletWithdraw)
  async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
    const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
    return jsonResponse(result);
  }

  @Post('leagues')
  @RequirePermissions(P.matches)
  async createLeague(
    @Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
  ) {
    if ('leagueZh' in dto || 'leagueEn' in dto) {
      const body = dto as CreatePlatformLeagueDto;
      const league = await this.matches.createPlatformLeague({
        leagueEn: body.leagueEn,
        leagueZh: body.leagueZh,
        leagueMs: body.leagueMs,
        logoUrl: body.logoUrl,
        displayOrder: body.displayOrder,
      });
      return jsonResponse(league);
    }
    const legacy = dto as { code: string; translations: Record<string, string> };
    const league = await this.matches.createLeague(legacy.code, legacy.translations);
    return jsonResponse(league);
  }

  @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,
  ) {
    const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
      status: status || undefined,
      keyword: keyword || undefined,
      locale: locale || undefined,
    });
    return jsonResponse({ items });
  }

  @Post('teams')
  @RequirePermissions(P.matches)
  async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
    const team = await this.matches.createTeam(dto.code, dto.translations);
    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: CreatePlatformMatchDto,
  ) {
    const match = await this.matches.updatePlatformMatch(BigInt(id), {
      leagueEn: dto.leagueEn ?? '',
      leagueZh: dto.leagueZh ?? '',
      leagueMs: dto.leagueMs,
      homeTeamEn: dto.homeTeamEn,
      homeTeamZh: dto.homeTeamZh,
      homeTeamMs: dto.homeTeamMs,
      awayTeamEn: dto.awayTeamEn,
      awayTeamZh: dto.awayTeamZh,
      awayTeamMs: dto.awayTeamMs,
      startTime: new Date(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,
      updatedBy: operatorId,
    });
    return jsonResponse(match);
  }

  @Delete('matches/:id')
  @RequirePermissions(P.matches)
  async deleteMatch(@Param('id') id: string) {
    await this.matches.deleteMatch(BigInt(id));
    return jsonResponse({ deleted: true });
  }

  @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: new Date(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,
    });
    return jsonResponse(match);
  }

  @Post('matches/import')
  @RequirePermissions(P.matches)
  async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
    if (!isZhiboBundlePayload(dto)) {
      throw new BadRequestException('Invalid import payload: matches[] required');
    }
    const result = await this.matches.importZhiboMatchesBundle(dto, operatorId);
    return jsonResponse(result);
  }

  @Post('matches/:id/publish')
  @RequirePermissions(P.matches)
  async publishMatch(@Param('id') id: string) {
    const match = await this.matches.publishMatch(BigInt(id));
    return jsonResponse(match);
  }

  @Post('matches/:id/close')
  @RequirePermissions(P.matches)
  async closeMatch(@Param('id') id: string) {
    const match = await this.matches.closeMatch(BigInt(id));
    return jsonResponse(match);
  }

  @Post('matches/:id/cancel')
  @RequirePermissions(P.matches)
  async cancelMatch(@Param('id') id: string) {
    await this.matches.cancelMatch(BigInt(id));
    const voided = await this.settlement.voidMatchBets(BigInt(id));
    return jsonResponse(voided);
  }

  @Post('matches/:id/markets/templates')
  @RequirePermissions(P.matches)
  async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
    const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
    return jsonResponse(markets);
  }

  @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);
    return jsonResponse({ matchId: id, updated: results.length });
  }

  @Patch('markets/:id')
  @RequirePermissions(P.matches)
  async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
    const market = await this.markets.updateMarket(BigInt(id), {
      promoLabel: dto.promoLabel,
      status: dto.status,
      lineValue: dto.lineValue,
    });
    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,
        odds: dto.odds,
        status: dto.status,
      },
      operatorId,
    );
    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);
    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(@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,
    });
    return jsonResponse(data);
  }

  @Post('outrights/import/wc2026')
  @RequirePermissions(P.matches)
  async importWc2026Outright() {
    const data = await this.outright.importWc2026Canonical();
    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 new BadRequestException('WC2026 outright not found — run import');
    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 new BadRequestException('WC2026 outright not found');
    return jsonResponse(
      await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId),
    );
  }

  /** @deprecated */
  @Post('outrights/wc2026/apply-canonical')
  @RequirePermissions(P.matches)
  async applyWc2026CanonicalLegacy() {
    return jsonResponse(await this.outright.importWc2026Canonical());
  }

  @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(
    @Param('matchId') matchId: string,
    @Body() dto: UpdateOutrightDto,
  ) {
    const data = await this.outright.updateForAdmin(BigInt(matchId), dto);
    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,
    );
    return jsonResponse(data);
  }

  @Post('outrights/:matchId/selections')
  @RequirePermissions(P.matches)
  async addOutrightSelection(
    @Param('matchId') matchId: string,
    @Body() dto: AddOutrightSelectionDto,
  ) {
    const data = await this.outright.addSelection(BigInt(matchId), dto);
    return jsonResponse(data);
  }

  @Post('outrights/:matchId/selections/batch')
  @RequirePermissions(P.matches)
  async addOutrightSelectionsBatch(
    @Param('matchId') matchId: string,
    @Body() dto: AddOutrightSelectionsBatchDto,
  ) {
    if (!dto.items?.length) {
      throw new BadRequestException('At least one team required');
    }
    const data = await this.outright.addSelectionsBatch(
      BigInt(matchId),
      dto.items,
    );
    return jsonResponse(data);
  }

  @Patch('outrights/:matchId/selections/:selectionId')
  @RequirePermissions(P.matches)
  async updateOutrightSelectionTeam(
    @Param('matchId') matchId: string,
    @Param('selectionId') selectionId: string,
    @Body() dto: UpdateOutrightSelectionTeamDto,
  ) {
    const data = await this.outright.updateSelectionTeam(
      BigInt(matchId),
      BigInt(selectionId),
      dto,
    );
    return jsonResponse(data);
  }

  @Delete('outrights/:matchId/selections/:selectionId')
  @RequirePermissions(P.matches)
  async removeOutrightSelection(
    @Param('matchId') matchId: string,
    @Param('selectionId') selectionId: string,
  ) {
    const data = await this.outright.closeSelection(
      BigInt(matchId),
      BigInt(selectionId),
    );
    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,
    );
    return jsonResponse(result);
  }

  @Post('matches/:id/settlement/preview')
  @RequirePermissions(P.settlement)
  async settlementPreview(
    @CurrentUser('id') operatorId: bigint,
    @Param('id') id: string,
    @Body() dto?: { page?: number; pageSize?: number },
  ) {
    const preview = await this.settlement.previewSettlement(BigInt(id), operatorId, {
      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('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,
      },
      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);
  }

  @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,
  ) {
    const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
    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 new BadRequestException('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 });
  }

  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('contents')
  @RequirePermissions(P.content, P.reports)
  async listContents(
    @Query('type') type?: string,
    @Query('status') status?: string,
  ) {
    const items = await this.content.listForAdmin(type, status);
    return jsonResponse(items);
  }

  @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 item = await this.content.create(dto);
    return jsonResponse(item);
  }

  @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('smoke-tests/suites')
  @RequirePermissions(P.settings)
  async smokeTestSuites() {
    return jsonResponse({
      suites: this.smokeTests.listSuites(),
      cases: this.smokeTests.listCases(),
      lastRun: this.smokeTests.getLastRun(),
    });
  }

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

  @Post('smoke-tests/run')
  @RequirePermissions(P.settings)
  async runSmokeTests(
    @CurrentUser('id') operatorId: bigint,
    @Body() body: { suites?: string[] },
  ) {
    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(
    @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 || undefined,
    );
    return jsonResponse(result);
  }
}
