import { Injectable, BadRequestException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuthService } from '../identity/auth.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../shared/common/decorators';
import { assertPlayerUsername, validateInitialDepositRemark } from '@thebet365/shared';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
import { AgentNetworkService, type AgentScope } from './agent-network.service';
import { AgentCreditService } from './agent-credit.service';

function dec(v: Decimal | null | undefined) {
  return v?.toString() ?? '0';
}

function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
  return new Decimal(a ?? 0).sub(b ?? 0).toString();
}

@Injectable()
export class AgentsService {
  constructor(
    private prisma: PrismaService,
    private auth: AuthService,
    private systemConfig: SystemConfigService,
    private network: AgentNetworkService,
    private credit: AgentCreditService,
  ) {}

  async getMaxAgentLevel(): Promise<number> {
    const settings = await this.systemConfig.getAgentHierarchySettings();
    return settings.maxAgentLevel;
  }

  canCreateSubAgent(agentLevel: number, maxLevel: number): boolean {
    if (maxLevel === 0) return true;
    return agentLevel < maxLevel;
  }

  /** 与 Admin 创建下级代理预填逻辑一致：可用授信 × 比例（%），非 100% 时向下取整到百位 */
  computeDefaultSubAgentCredit(availableCredit: number, ratioPercent: number): number {
    if (availableCredit <= 0) return 0;
    const pct = Math.min(100, Math.max(1, ratioPercent)) / 100;
    const raw = availableCredit * pct;
    const rounded = pct >= 1 ? availableCredit : Math.floor(raw / 100) * 100;
    return Math.min(availableCredit, Math.max(0, rounded));
  }

  private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
    return this.network.buildAgentAncestorChainMap(parentAgentIds);
  }

  private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
    if (agentUserIds.length === 0) return new Map();
    const profiles = await this.prisma.agentProfile.findMany({
      where: { userId: { in: agentUserIds } },
      select: { userId: true, cashbackRate: true },
    });
    return new Map(profiles.map((p) => [p.userId.toString(), p.cashbackRate.toString()]));
  }

  private async playerEffectiveCashbackRateMap(
    players: Array<{ id: bigint; parentId: bigint | null }>,
    parentCashbackMap: Map<string, string>,
  ): Promise<Map<string, string>> {
    if (players.length === 0) return new Map();
    const playerIds = players.map((p) => p.id);
    const customRules = await this.prisma.cashbackRule.findMany({
      where: {
        targetType: 'USER',
        targetId: { in: playerIds },
        isActive: true,
        marketType: null,
      },
      orderBy: { updatedAt: 'desc' },
    });
    const customMap = new Map<string, string>();
    for (const rule of customRules) {
      if (!rule.targetId) continue;
      const id = rule.targetId.toString();
      if (!customMap.has(id) && new Decimal(rule.rate).gt(0)) {
        customMap.set(id, rule.rate.toString());
      }
    }

    const result = new Map<string, string>();
    for (const p of players) {
      const key = p.id.toString();
      const custom = customMap.get(key);
      if (custom) {
        result.set(key, custom);
        continue;
      }
      if (p.parentId) {
        result.set(key, parentCashbackMap.get(p.parentId.toString()) ?? '0');
      } else {
        result.set(key, '0');
      }
    }
    return result;
  }

  /** 代理端：从当前登录代理向下构建上级链，不包含更上层代理 */
  private async buildScopedAncestorChainMap(
    parentAgentIds: (bigint | null | undefined)[],
    rootAgentId: bigint,
  ) {
    return this.network.buildScopedAncestorChainMap(parentAgentIds, rootAgentId);
  }

  private async getAgentPortalScope(rootAgentId: bigint): Promise<AgentScope> {
    return this.network.resolveScope(rootAgentId);
  }

  private assertAgentInPortalSubtree(
    scope: AgentScope,
    agentId: bigint,
  ) {
    this.network.assertAgentInScope(scope, agentId);
  }

  /** 代理端：玩家必须挂在当前代理子树内某代理下（自己或下级） */
  async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
    return this.network.requirePlayerInPortalSubtree(rootAgentId, playerId);
  }

  private async validateAgentLevel(level: number, parentAgentId?: bigint) {
    if (!Number.isInteger(level) || level < 1) {
      throw appBadRequest('AGENT_LEVEL_INVALID');
    }

    const maxLevel = await this.getMaxAgentLevel();
    if (maxLevel > 0 && level > maxLevel) {
      throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
    }

    if (level === 1) {
      if (parentAgentId) throw appBadRequest('AGENT_LEVEL_ROOT_INVALID');
      return;
    }

    if (!parentAgentId) {
      throw appBadRequest('LEVEL2_REQUIRES_PARENT');
    }

    const parent = await this.prisma.agentProfile.findUnique({
      where: { userId: parentAgentId },
    });
    if (!parent) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
    if (parent.level !== level - 1) {
      throw appBadRequest('AGENT_PARENT_LEVEL_MISMATCH');
    }
    if (maxLevel > 0 && !this.canCreateSubAgent(parent.level, maxLevel)) {
      throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
    }
  }

  async getProfile(agentId: bigint) {
    return this.credit.getProfile(agentId);
  }

  async recalculateUsedCredit(agentId: bigint) {
    return this.credit.recalculateUsedCredit(agentId);
  }

  async adjustCredit(
    agentId: bigint,
    amount: Decimal | number,
    operatorId: bigint,
    requestId: string,
    remark?: string,
  ) {
    return this.credit.adjustCredit(agentId, amount, operatorId, requestId, remark);
  }

  /** 代理只能操作直属玩家（parentId === 当前代理） */
  private async requireDirectPlayer(agentId: bigint, playerId: bigint) {
    const player = await this.prisma.user.findFirst({
      where: { id: playerId, userType: 'PLAYER', deletedAt: null },
      include: { auth: true, wallet: true, preferences: true },
    });
    if (!player) throw appNotFound('PLAYER_NOT_FOUND');
    if (player.parentId !== agentId) {
      throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
    }
    return player;
  }

  private async assertChildAgentWithinParent(
    parentAgentId: bigint,
    child: {
      creditLimit?: number | Decimal;
      cashbackRate?: number | Decimal;
      maxSingleDeposit?: number | Decimal | null;
      maxDailyDeposit?: number | Decimal | null;
    },
  ) {
    const parent = await this.prisma.agentProfile.findUnique({
      where: { userId: parentAgentId },
    });
    if (!parent) throw appBadRequest('PARENT_AGENT_NOT_FOUND');

    if (child.creditLimit !== undefined) {
      const limit = new Decimal(child.creditLimit);
      if (limit.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
      if (limit.gt(parent.creditLimit)) {
        throw appBadRequest('CREDIT_EXCEEDS_PARENT');
      }
    }

    if (child.cashbackRate !== undefined) {
      const rate = new Decimal(child.cashbackRate);
      if (rate.lt(0)) throw appBadRequest('CASHBACK_RATE_NEGATIVE');
      if (rate.gt(parent.cashbackRate)) {
        throw appBadRequest('CASHBACK_RATE_EXCEEDS_PARENT');
      }
    }

    if (child.maxSingleDeposit != null && parent.maxSingleDeposit != null) {
      if (new Decimal(child.maxSingleDeposit).gt(parent.maxSingleDeposit)) {
        throw appBadRequest('BET_LIMIT_EXCEEDS_PARENT');
      }
    }
    if (child.maxSingleDeposit != null && new Decimal(child.maxSingleDeposit).lt(0)) {
      throw appBadRequest('BET_LIMIT_NEGATIVE');
    }

    if (child.maxDailyDeposit != null && parent.maxDailyDeposit != null) {
      if (new Decimal(child.maxDailyDeposit).gt(parent.maxDailyDeposit)) {
        throw appBadRequest('DAILY_LIMIT_EXCEEDS_PARENT');
      }
    }
    if (child.maxDailyDeposit != null && new Decimal(child.maxDailyDeposit).lt(0)) {
      throw appBadRequest('DAILY_LIMIT_NEGATIVE');
    }
  }

  private resolveEffectiveDepositLimits(
    profile: {
      maxSingleDeposit: Decimal | null;
      maxDailyDeposit: Decimal | null;
    },
    parent?: { maxSingleDeposit: Decimal | null; maxDailyDeposit: Decimal | null } | null,
  ) {
    let maxSingleDeposit = profile.maxSingleDeposit;
    let maxDailyDeposit = profile.maxDailyDeposit;

    if (parent) {
      if (parent.maxSingleDeposit != null) {
        maxSingleDeposit =
          maxSingleDeposit != null
            ? Decimal.min(maxSingleDeposit, parent.maxSingleDeposit)
            : parent.maxSingleDeposit;
      }
      if (parent.maxDailyDeposit != null) {
        maxDailyDeposit =
          maxDailyDeposit != null
            ? Decimal.min(maxDailyDeposit, parent.maxDailyDeposit)
            : parent.maxDailyDeposit;
      }
    }

    return { maxSingleDeposit, maxDailyDeposit };
  }

  private normalizeOptionalLimit(value?: number | null) {
    if (value == null || value <= 0) return null;
    return new Decimal(value);
  }

  /** 玩家有所属代理时，上分金额不得超过该代理当前可用授信（会先重算 usedCredit） */
  async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
    return this.credit.assertPlayerParentCreditForDeposit(playerId, amount);
  }

  /** 管理员给玩家上分：校验上级授信后入账，并刷新代理占用额度 */
  async adminDepositToPlayer(
    playerId: bigint,
    amount: number,
    operatorId: bigint,
    remark?: string,
    requestId?: string,
  ) {
    return this.credit.adminDepositToPlayer(playerId, amount, operatorId, remark, requestId);
  }

  /** 管理员给玩家下分：扣款后刷新上级代理占用额度 */
  async adminWithdrawFromPlayer(
    playerId: bigint,
    amount: number,
    operatorId: bigint,
    remark?: string,
    requestId?: string,
  ) {
    return this.credit.adminWithdrawFromPlayer(playerId, amount, operatorId, remark, requestId);
  }

  /** 上下分弹窗：玩家余额 + 授信代理可用额度/限额上下文 */
  async getPlayerTransferContext(
    playerId: bigint,
    options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
  ) {
    return this.credit.getPlayerTransferContext(playerId, options);
  }

  private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
    const profile = await this.prisma.agentProfile.findUnique({
      where: { userId: creditAgentId },
    });
    if (!profile) return;

    const parent = profile.parentAgentId
      ? await this.prisma.agentProfile.findUnique({
          where: { userId: profile.parentAgentId },
        })
      : null;
    const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);

    if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
      throw appBadRequest('AGENT_SINGLE_TOPUP_LIMIT');
    }

    if (maxDailyDeposit) {
      const today = new Date();
      today.setHours(0, 0, 0, 0);
      const dailyAgg = await this.prisma.walletTransaction.aggregate({
        where: {
          operatorId: creditAgentId,
            transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
            createdAt: { gte: today },
          },
        _sum: { amount: true },
      });
      const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
      if (dailyTotal.gt(maxDailyDeposit)) {
        throw appBadRequest('AGENT_DAILY_TOPUP_LIMIT');
      }
    }
  }

  private async assertChildCreditWithinParent(
    parentAgentId: bigint,
    childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
    creditAfter: Decimal,
  ) {
    await this.assertChildAgentWithinParent(parentAgentId, { creditLimit: creditAfter });

    const parent = await this.getProfile(parentAgentId);
    const parentAvailable = new Decimal(parent.creditLimit).sub(parent.usedCredit);
    const oldExposure = Decimal.max(childProfile.creditLimit, childProfile.usedCredit);
    const newExposure = Decimal.max(creditAfter, childProfile.usedCredit);
    const exposureDelta = newExposure.sub(oldExposure);
    if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
      throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
    }
  }

  async depositToPlayer(
    agentId: bigint,
    playerId: bigint,
    amount: number,
    requestId: string,
    remark?: string,
  ) {
    return this.credit.depositToPlayer(agentId, playerId, amount, requestId, remark);
  }

  async withdrawFromPlayer(
    agentId: bigint,
    playerId: bigint,
    amount: number,
    requestId: string,
    remark?: string,
  ) {
    return this.credit.withdrawFromPlayer(agentId, playerId, amount, requestId, remark);
  }

  async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
    const user = await this.requireDirectPlayer(agentId, playerId);

    const [betCount, betStake] = await Promise.all([
      this.prisma.bet.count({ where: { userId: playerId } }),
      this.prisma.bet.aggregate({
        where: { userId: playerId },
        _sum: { stake: true, actualReturn: true },
      }),
    ]);

    return {
      id: user.id.toString(),
      username: user.username,
      status: user.status,
      phone: user.preferences?.phone ?? null,
      email: user.preferences?.email ?? null,
      managedPassword: user.preferences?.managedPassword ?? null,
      availableBalance: user.wallet?.availableBalance?.toString() ?? '0',
      frozenBalance: user.wallet?.frozenBalance?.toString() ?? '0',
      lastLoginAt: user.auth?.lastLoginAt ?? null,
      loginFailCount: user.auth?.loginFailCount ?? 0,
      betCount,
      totalStake: betStake._sum.stake?.toString() ?? '0',
      totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
      createdAt: user.createdAt,
    };
  }

  async updateDirectPlayer(
    agentId: bigint,
    playerId: bigint,
    data: {
      username?: string;
      password?: string;
      phone?: string;
      email?: string;
      status?: string;
    },
  ) {
    const user = await this.requireDirectPlayer(agentId, playerId);

    if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
      throw appBadRequest('INVALID_STATUS');
    }

    if (data.username !== undefined) {
      const nextUsername = data.username.trim();
      if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
      try {
        assertPlayerUsername(nextUsername);
      } catch {
        throw appBadRequest('USERNAME_FORMAT_INVALID');
      }
      if (nextUsername !== user.username) {
        const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
        if (taken) throw appBadRequest('USERNAME_TAKEN');
        await this.prisma.user.update({
          where: { id: playerId },
          data: { username: nextUsername },
        });
      }
    }

    if (data.password !== undefined) {
      const nextPassword = data.password;
      if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
      if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
      const hash = await bcrypt.hash(nextPassword, 10);
      await this.prisma.userAuth.update({
        where: { userId: playerId },
        data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
      });
      await this.prisma.userPreference.upsert({
        where: { userId: playerId },
        create: { userId: playerId, managedPassword: nextPassword },
        update: { managedPassword: nextPassword },
      });
    }

    if (data.status) {
      await this.prisma.user.update({
        where: { id: playerId },
        data: { status: data.status },
      });
    }

    const prefPatch: { phone?: string | null; email?: string | null } = {};
    if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null;
    if (data.email !== undefined) prefPatch.email = data.email.trim() || null;

    if (Object.keys(prefPatch).length > 0) {
      await this.prisma.userPreference.upsert({
        where: { userId: playerId },
        create: {
          userId: playerId,
          phone: prefPatch.phone ?? null,
          email: prefPatch.email ?? null,
        },
        update: prefPatch,
      });
    }

    return this.getDirectPlayerDetail(agentId, playerId);
  }

  async deleteDirectPlayer(agentId: bigint, playerId: bigint) {
    await this.requireDirectPlayer(agentId, playerId);

    const betCount = await this.prisma.bet.count({
      where: {
        userId: playerId,
        status: 'PENDING',
      },
    });
    if (betCount > 0) {
      throw appBadRequest('PLAYER_HAS_PENDING_BETS');
    }

    const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } });
    if (wallet) {
      const available = new Decimal(wallet.availableBalance);
      const frozen = new Decimal(wallet.frozenBalance);
      if (available.gt(0) || frozen.gt(0)) {
        throw appBadRequest('PLAYER_HAS_BALANCE');
      }
    }

    return this.prisma.user.update({
      where: { id: playerId },
      data: { deletedAt: new Date(), status: 'SUSPENDED' },
    });
  }

  async listAgentsAdmin(params?: {
    page?: number;
    pageSize?: number;
    keyword?: string;
    status?: string;
    level?: number;
    minLevel?: number;
    maxLevel?: number;
    parentAgentId?: bigint;
  }) {
    const page = Math.max(1, params?.page ?? 1);
    const pageSize = Math.min(Math.max(1, params?.pageSize ?? 10), 100);
    const skip = (page - 1) * pageSize;

    const where: Prisma.AgentProfileWhereInput = {};
    if (params?.level != null) {
      where.level = params.level;
    } else if (params?.minLevel != null || params?.maxLevel != null) {
      const levelFilter: { gte?: number; lte?: number } = {};
      if (params.minLevel != null) levelFilter.gte = params.minLevel;
      if (params.maxLevel != null) levelFilter.lte = params.maxLevel;
      where.level = levelFilter;
    } else if (params?.parentAgentId !== undefined) {
      where.parentAgentId = params.parentAgentId;
    } else {
      where.parentAgentId = null;
    }
    const kw = params?.keyword?.trim();
    const status = params?.status?.trim();
    const userWhere: Prisma.UserWhereInput = {};
    if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
      userWhere.status = status;
    }
    if (kw) {
      userWhere.username = { contains: kw, mode: 'insensitive' };
    }
    if (Object.keys(userWhere).length > 0) {
      where.user = userWhere;
    }

    const [profiles, total] = await Promise.all([
      this.prisma.agentProfile.findMany({
        where,
        include: {
          user: { include: { preferences: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip,
        take: pageSize,
      }),
      this.prisma.agentProfile.count({ where }),
    ]);

    const agentIds = profiles.map((p) => p.userId);
    const playerCounts =
      agentIds.length > 0
        ? await this.prisma.user.groupBy({
            by: ['parentId'],
            where: {
              userType: 'PLAYER',
              parentId: { in: agentIds },
              deletedAt: null,
            },
            _count: { _all: true },
          })
        : [];

    const countMap = new Map(
      playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
    );

    const childAgentCounts =
      agentIds.length > 0
        ? await this.prisma.agentProfile.groupBy({
            by: ['parentAgentId'],
            where: { parentAgentId: { in: agentIds } },
            _count: { _all: true },
          })
        : [];
    const childAgentCountMap = new Map(
      childAgentCounts.map((g) => [g.parentAgentId?.toString(), g._count._all]),
    );

    const parentAgentIds = [
      ...new Set(profiles.map((p) => p.parentAgentId).filter((id): id is bigint => id != null)),
    ];
    const parentUsers =
      parentAgentIds.length > 0
        ? await this.prisma.user.findMany({
            where: { id: { in: parentAgentIds } },
            select: { id: true, username: true },
          })
        : [];
    const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
    const parentChainMap = await this.buildAgentAncestorChainMap(
      profiles.map((p) => p.parentAgentId),
    );

    const items = profiles.map((p) => {
      const available = new Decimal(p.creditLimit).sub(p.usedCredit);
      const parentChain = p.parentAgentId
        ? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
        : [];
      return {
        id: p.id.toString(),
        userId: p.userId.toString(),
        username: p.user.username,
        userStatus: p.user.status,
        level: p.level,
        status: p.status,
        parentAgentId: p.parentAgentId?.toString() ?? null,
        parentUsername: p.parentAgentId
          ? parentUsernameMap.get(p.parentAgentId.toString()) ?? null
          : null,
        parentChain,
        parentChainLabel: parentChain.length ? parentChain.join(' / ') : null,
        creditLimit: p.creditLimit.toString(),
        usedCredit: p.usedCredit.toString(),
        availableCredit: available.toString(),
        directPlayerLiability: p.directPlayerLiability.toString(),
        childAgentExposure: p.childAgentExposure.toString(),
        cashbackRate: p.cashbackRate.toString(),
        maxSingleDeposit: p.maxSingleDeposit?.toString() ?? null,
        maxDailyDeposit: p.maxDailyDeposit?.toString() ?? null,
        directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
        childAgentCount: childAgentCountMap.get(p.userId.toString()) ?? 0,
        phone: p.user.preferences?.phone ?? null,
        email: p.user.preferences?.email ?? null,
        locale: p.user.locale,
        inviteCode: p.user.inviteCode ?? null,
        createdAt: p.createdAt,
        updatedAt: p.updatedAt,
      };
    });

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

  async countAgentsByLevel(): Promise<Record<number, number>> {
    const groups = await this.prisma.agentProfile.groupBy({
      by: ['level'],
      where: { user: { deletedAt: null } },
      _count: { _all: true },
    });
    const out: Record<number, number> = {};
    for (const g of groups) {
      out[g.level] = g._count._all;
    }
    return out;
  }

  async getAgentAdminDetail(agentId: bigint) {
    const profile = await this.prisma.agentProfile.findUnique({
      where: { userId: agentId },
      include: { user: { include: { preferences: true, auth: true } } },
    });
    if (!profile) throw appNotFound('AGENT_NOT_FOUND');

    const [directPlayerCount, childAgentCount, recentCredits] = await Promise.all([
      this.prisma.user.count({
        where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
      }),
      this.prisma.agentProfile.count({
        where: { parentAgentId: agentId },
      }),
      this.prisma.agentCreditTransaction.findMany({
        where: { agentId },
        orderBy: { createdAt: 'desc' },
        take: 10,
      }),
    ]);

    const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
    let parentUsername: string | null = null;
    if (profile.parentAgentId) {
      const parent = await this.prisma.user.findUnique({
        where: { id: profile.parentAgentId },
        select: { username: true },
      });
      parentUsername = parent?.username ?? null;
    }

    return {
      id: profile.id.toString(),
      userId: profile.userId.toString(),
      username: profile.user.username,
      userStatus: profile.user.status,
      level: profile.level,
      status: profile.status,
      parentAgentId: profile.parentAgentId?.toString() ?? null,
      parentUsername,
      creditLimit: profile.creditLimit.toString(),
      usedCredit: profile.usedCredit.toString(),
      availableCredit: available.toString(),
      directPlayerLiability: profile.directPlayerLiability.toString(),
      childAgentExposure: profile.childAgentExposure.toString(),
      cashbackRate: profile.cashbackRate.toString(),
      maxSingleDeposit: profile.maxSingleDeposit?.toString() ?? null,
      maxDailyDeposit: profile.maxDailyDeposit?.toString() ?? null,
      directPlayerCount,
      childAgentCount,
      phone: profile.user.preferences?.phone ?? null,
      email: profile.user.preferences?.email ?? null,
      managedPassword: profile.user.preferences?.managedPassword ?? null,
      locale: profile.user.locale,
      inviteCode: profile.user.inviteCode ?? null,
      lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
      loginFailCount: profile.user.auth?.loginFailCount ?? 0,
      createdAt: profile.createdAt,
      updatedAt: profile.updatedAt,
      recentCreditTransactions: recentCredits.map((t) => ({
        id: t.id.toString(),
        transactionType: t.transactionType,
        amount: t.amount.toString(),
        creditBefore: t.creditBefore.toString(),
        creditAfter: t.creditAfter.toString(),
        remark: t.remark,
        createdAt: t.createdAt,
      })),
    };
  }

  async listCreditTransactions(params: {
    page?: number;
    pageSize?: number;
    agentId?: bigint;
    keyword?: string;
    operatorKeyword?: string;
    transactionType?: string;
    scopedAgentIds?: bigint[];
    dateFrom?: Date;
    dateTo?: Date;
  }) {
    const page = Math.max(1, params.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
    const skip = (page - 1) * pageSize;

    const where: Prisma.AgentCreditTransactionWhereInput = {};

    if (params.transactionType?.trim()) {
      where.transactionType = params.transactionType.trim();
    }

    if (params.dateFrom || params.dateTo) {
      where.createdAt = {};
      if (params.dateFrom) where.createdAt.gte = params.dateFrom;
      if (params.dateTo) where.createdAt.lte = params.dateTo;
    }

    const scopedIds = params.scopedAgentIds?.length ? params.scopedAgentIds : undefined;

    const operatorKeyword = params.operatorKeyword?.trim();
    if (operatorKeyword) {
      const matchedOps = await this.prisma.user.findMany({
        where: {
          deletedAt: null,
          username: { contains: operatorKeyword, mode: 'insensitive' },
        },
        select: { id: true },
        take: 50,
      });
      const operatorIds = matchedOps.map((u) => u.id);
      if (!operatorIds.length) {
        return { items: [], total: 0, page, pageSize };
      }
      where.operatorId = { in: operatorIds };
    }

    const keyword = params.keyword?.trim();
    if (keyword) {
      const matched = await this.prisma.user.findMany({
        where: {
          userType: 'AGENT',
          deletedAt: null,
          username: { contains: keyword, mode: 'insensitive' },
        },
        select: { id: true },
        take: 50,
      });
      let agentUserIds = matched.map((u) => u.id);
      if (scopedIds) {
        const scopedSet = new Set(scopedIds.map((id) => id.toString()));
        agentUserIds = agentUserIds.filter((id) => scopedSet.has(id.toString()));
      }
      if (!agentUserIds.length) {
        return { items: [], total: 0, page, pageSize };
      }
      if (params.agentId) {
        if (!agentUserIds.some((id) => id === params.agentId)) {
          return { items: [], total: 0, page, pageSize };
        }
        where.agentId = params.agentId;
      } else {
        where.agentId = { in: agentUserIds };
      }
    } else if (params.agentId) {
      if (scopedIds && !scopedIds.some((id) => id === params.agentId)) {
        return { items: [], total: 0, page, pageSize };
      }
      where.agentId = params.agentId;
    } else if (scopedIds) {
      where.agentId = { in: scopedIds };
    }

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

    const agentIds = [...new Set(rows.map((r) => r.agentId))];
    const operatorIds = [
      ...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
    ];

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

    const agentNameById = new Map(agentUsers.map((u) => [u.id.toString(), u.username]));
    const operatorNameById = new Map(operators.map((u) => [u.id.toString(), u.username]));

    return {
      items: rows.map((row) => ({
        id: row.id.toString(),
        agentId: row.agentId.toString(),
        agentUsername: agentNameById.get(row.agentId.toString()) ?? null,
        transactionType: row.transactionType,
        amount: row.amount.toString(),
        creditBefore: row.creditBefore.toString(),
        creditAfter: row.creditAfter.toString(),
        referenceType: row.referenceType,
        referenceId: row.referenceId,
        operatorId: row.operatorId?.toString() ?? null,
        operatorUsername: row.operatorId
          ? (operatorNameById.get(row.operatorId.toString()) ?? null)
          : null,
        requestId: row.requestId,
        remark: row.remark,
        createdAt: row.createdAt,
      })),
      total,
      page,
      pageSize,
    };
  }

  async updateAgentAdmin(
    agentId: bigint,
    data: {
      status?: string;
      locale?: string;
      phone?: string;
      email?: string;
      cashbackRate?: number;
      maxSingleDeposit?: number | null;
      maxDailyDeposit?: number | null;
      username?: string;
      password?: string;
      freezeDirectPlayers?: boolean;
      blockDirectPlayerLogin?: boolean;
      unfreezeDirectPlayers?: boolean;
    },
  ) {
    const profile = await this.prisma.agentProfile.findUnique({
      where: { userId: agentId },
      include: { user: true },
    });
    if (!profile) throw appNotFound('AGENT_NOT_FOUND');

    if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
      throw appBadRequest('INVALID_STATUS');
    }

    // Handle username change
    if (data.username !== undefined) {
      const nextUsername = data.username.trim();
      if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
      if (nextUsername !== profile.user.username) {
        const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
        if (taken) throw appBadRequest('USERNAME_TAKEN');
        await this.prisma.user.update({
          where: { id: agentId },
          data: { username: nextUsername },
        });
      }
    }

    // Handle password change
    if (data.password !== undefined) {
      const nextPassword = data.password;
      if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
      const hash = await bcrypt.hash(nextPassword, 10);
      await this.prisma.userAuth.upsert({
        where: { userId: agentId },
        create: { userId: agentId, passwordHash: hash, loginFailCount: 0, lockedUntil: null },
        update: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
      });
      await this.prisma.userPreference.upsert({
        where: { userId: agentId },
        create: { userId: agentId, managedPassword: nextPassword },
        update: { managedPassword: nextPassword },
      });
    }

    // Handle status change (per-action cascade freeze / login block; 未传参时用全局默认)
    if (data.status) {
      const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status };
      let freezeDirectPlayers = false;
      if (data.status === 'SUSPENDED') {
        const suspendDefaults = await this.systemConfig.getAgentSuspendSettings();
        const blockDirectPlayerLogin =
          data.blockDirectPlayerLogin ?? suspendDefaults.suspendBlockPlayerLogin;
        freezeDirectPlayers =
          data.freezeDirectPlayers ?? suspendDefaults.suspendFreezeDirectPlayers;
        profilePatch.blockDirectPlayerLogin = blockDirectPlayerLogin === true;
      } else if (data.status === 'ACTIVE') {
        profilePatch.blockDirectPlayerLogin = false;
      }

      await this.prisma.$transaction([
        this.prisma.user.update({
          where: { id: agentId },
          data: { status: data.status },
        }),
        this.prisma.agentProfile.update({
          where: { userId: agentId },
          data: profilePatch,
        }),
      ]);

      if (data.status === 'SUSPENDED' && freezeDirectPlayers) {
        await this.prisma.user.updateMany({
          where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
          data: { status: 'SUSPENDED' },
        });
      }

      if (data.status === 'ACTIVE' && data.unfreezeDirectPlayers) {
        await this.prisma.user.updateMany({
          where: { parentId: agentId, userType: 'PLAYER', deletedAt: null, status: 'SUSPENDED' },
          data: { status: 'ACTIVE' },
        });
      }
    }

    if (data.locale) {
      await this.prisma.user.update({
        where: { id: agentId },
        data: { locale: data.locale },
      });
    }

    if (data.cashbackRate !== undefined) {
      if (profile.parentAgentId) {
        await this.assertChildAgentWithinParent(profile.parentAgentId, {
          cashbackRate: data.cashbackRate,
        });
      }
      await this.prisma.agentProfile.update({
        where: { userId: agentId },
        data: { cashbackRate: data.cashbackRate },
      });
    }

    const limitPatch: {
      maxSingleDeposit?: Decimal | null;
      maxDailyDeposit?: Decimal | null;
    } = {};
    if (data.maxSingleDeposit !== undefined) {
      limitPatch.maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
    }
    if (data.maxDailyDeposit !== undefined) {
      limitPatch.maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);
    }
    if (Object.keys(limitPatch).length > 0) {
      if (profile.parentAgentId) {
        await this.assertChildAgentWithinParent(profile.parentAgentId, {
          maxSingleDeposit: limitPatch.maxSingleDeposit ?? undefined,
          maxDailyDeposit: limitPatch.maxDailyDeposit ?? undefined,
        });
      }
      await this.prisma.agentProfile.update({
        where: { userId: agentId },
        data: limitPatch,
      });
    }

    if (data.phone !== undefined || data.email !== undefined || data.locale) {
      const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
      const email = data.email !== undefined ? data.email?.trim() || null : undefined;
      await this.prisma.userPreference.upsert({
        where: { userId: agentId },
        create: {
          userId: agentId,
          locale: data.locale ?? profile.user.locale,
          phone: phone ?? null,
          email: email ?? null,
        },
        update: {
          ...(data.locale ? { locale: data.locale } : {}),
          ...(phone !== undefined ? { phone } : {}),
          ...(email !== undefined ? { email } : {}),
        },
      });
    }

    return this.getAgentAdminDetail(agentId);
  }

  /** 可升级为一级代理的玩家（尚无代理档案） */
  async listPromotablePlayers(keyword?: string) {
    const q = keyword?.trim();
    return this.prisma.user.findMany({
      where: {
        userType: 'PLAYER',
        deletedAt: null,
        agentProfile: null,
        ...(q
          ? { username: { contains: q, mode: 'insensitive' } }
          : {}),
      },
      select: {
        id: true,
        username: true,
        status: true,
        parentId: true,
        preferences: { select: { phone: true, email: true } },
        parent: { select: { username: true } },
      },
      orderBy: { id: 'desc' },
      take: 50,
    });
  }

  /** 将已有玩家账号升级为一级代理（不新建用户） */
  async promotePlayerToTier1Agent(
    userId: bigint,
    data: {
      creditLimit: number;
      cashbackRate?: number;
      maxSingleDeposit?: number | null;
      maxDailyDeposit?: number | null;
      phone?: string;
      email?: string;
    },
  ) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      include: { agentProfile: true, preferences: true },
    });
    if (!user || user.deletedAt) {
      throw appNotFound('USER_NOT_FOUND');
    }
    if (user.userType !== 'PLAYER') {
      throw appBadRequest('PROMOTE_PLAYER_ONLY');
    }
    if (user.agentProfile) {
      throw appBadRequest('ALREADY_AGENT');
    }

    const oldParentId = user.parentId;
    const phone =
      data.phone !== undefined
        ? data.phone.trim() || null
        : user.preferences?.phone ?? null;
    const email =
      data.email !== undefined
        ? data.email.trim() || null
        : user.preferences?.email ?? null;

    await this.prisma.$transaction(async (tx) => {
      await tx.user.update({
        where: { id: userId },
        data: {
          userType: 'AGENT',
          agentLevel: 1,
          parentId: null,
        },
      });
      await assignInviteCodeWithHistory(tx, userId);

      if (user.preferences) {
        await tx.userPreference.update({
          where: { userId },
          data: { phone, email },
        });
      } else {
        await tx.userPreference.create({
          data: {
            userId,
            locale: user.locale,
            phone,
            email,
          },
        });
      }

      await tx.agentProfile.create({
        data: {
          userId,
          level: 1,
          parentAgentId: null,
          creditLimit: data.creditLimit,
          cashbackRate: data.cashbackRate ?? 0,
          maxSingleDeposit: this.normalizeOptionalLimit(data.maxSingleDeposit),
          maxDailyDeposit: this.normalizeOptionalLimit(data.maxDailyDeposit),
        },
      });

      await tx.agentClosure.create({
        data: { ancestorId: userId, descendantId: userId, depth: 0 },
      });
    });

    if (oldParentId) {
      await this.recalculateUsedCredit(oldParentId);
    }

    const updated = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!updated) {
      throw appNotFound('USER_NOT_FOUND');
    }
    return updated;
  }

  async createAgent(
    operatorId: bigint,
    data: {
      username: string;
      password: string;
      level: number;
      parentAgentId?: bigint;
      creditLimit?: number;
      locale?: string;
      phone?: string;
      email?: string;
      cashbackRate?: number;
      maxSingleDeposit?: number | null;
      maxDailyDeposit?: number | null;
    },
  ) {
    await this.validateAgentLevel(data.level, data.parentAgentId);

    let resolvedCreditLimit = data.creditLimit;
    let resolvedCashbackRate = data.cashbackRate ?? 0;
    if (data.parentAgentId) {
      const parentProfile = await this.prisma.agentProfile.findUnique({
        where: { userId: data.parentAgentId },
        select: { cashbackRate: true, creditLimit: true, usedCredit: true },
      });
      resolvedCashbackRate =
        data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0);
      if (resolvedCreditLimit === undefined && parentProfile) {
        const hierarchy = await this.systemConfig.getAgentHierarchySettings();
        const available = new Decimal(parentProfile.creditLimit).sub(parentProfile.usedCredit);
        resolvedCreditLimit = this.computeDefaultSubAgentCredit(
          available.toNumber(),
          hierarchy.defaultSubAgentCreditRatio,
        );
      }
      await this.assertChildAgentWithinParent(data.parentAgentId, {
        creditLimit: resolvedCreditLimit ?? 0,
        cashbackRate: resolvedCashbackRate,
        maxSingleDeposit: data.maxSingleDeposit,
        maxDailyDeposit: data.maxDailyDeposit,
      });
    } else if (resolvedCreditLimit === undefined) {
      resolvedCreditLimit = 0;
    }

    const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
    const maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);

    const hash = await this.auth.hashPassword(data.password);

    const user = await this.prisma.$transaction(async (tx) => {
      const locale = data.locale ?? 'zh-CN';
      const user = await tx.user.create({
        data: {
          username: data.username,
          userType: 'AGENT',
          parentId: data.parentAgentId,
          agentLevel: data.level,
          locale,
        },
      });
      await assignInviteCodeWithHistory(tx, user.id);

      await tx.userAuth.create({
        data: { userId: user.id, passwordHash: hash },
      });

      await tx.userPreference.create({
        data: {
          userId: user.id,
          locale,
          phone: data.phone?.trim() || null,
          email: data.email?.trim() || null,
        },
      });

      await tx.agentProfile.create({
        data: {
          userId: user.id,
          level: data.level,
          parentAgentId: data.parentAgentId,
          creditLimit: resolvedCreditLimit ?? 0,
          cashbackRate: resolvedCashbackRate,
          maxSingleDeposit,
          maxDailyDeposit,
        },
      });

      // Build closure table
      await tx.agentClosure.create({
        data: { ancestorId: user.id, descendantId: user.id, depth: 0 },
      });

      if (data.parentAgentId) {
        const ancestors = await tx.agentClosure.findMany({
          where: { descendantId: data.parentAgentId },
        });
        for (const a of ancestors) {
          await tx.agentClosure.create({
            data: {
              ancestorId: a.ancestorId,
              descendantId: user.id,
              depth: a.depth + 1,
            },
          });
        }
      }

      return user;
    });

    if (data.parentAgentId) {
      await this.recalculateUsedCredit(data.parentAgentId);
    }

    return user;
  }

  async createPlayer(
    operatorId: bigint,
    data: {
      username: string;
      password: string;
      parentId?: bigint;
      locale?: string;
      phone?: string;
      email?: string;
      initialDeposit?: number;
      depositRemark?: string;
      depositRequestId?: string;
      asTier1Agent?: boolean;
      asSubAgent?: boolean;
      parentAgentId?: bigint;
      creditLimit?: number;
      cashbackRate?: number;
      maxSingleDeposit?: number | null;
      maxDailyDeposit?: number | null;
    },
  ) {
    if (data.asTier1Agent) {
      if (data.parentId != null) {
        throw appBadRequest('TIER1_NO_PARENT_PLAYER');
      }
      if (data.initialDeposit && data.initialDeposit > 0) {
        throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
      }
      return this.createAgent(operatorId, {
        username: data.username,
        password: data.password,
        level: 1,
        creditLimit: data.creditLimit ?? 0,
        cashbackRate: data.cashbackRate ?? 0,
        locale: data.locale,
        phone: data.phone,
        email: data.email,
      });
    }

    if (data.asSubAgent) {
      if (data.parentAgentId == null && data.parentId == null) {
        throw appBadRequest('TIER2_REQUIRES_PARENT_AGENT');
      }
      if (data.initialDeposit && data.initialDeposit > 0) {
        throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
      }
      const parentAgentId = data.parentAgentId ?? data.parentId;
      if (parentAgentId == null) {
        throw appBadRequest('TIER2_REQUIRES_PARENT_AGENT');
      }
      const parentProfile = await this.prisma.agentProfile.findUnique({
        where: { userId: parentAgentId },
      });
      if (!parentProfile) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
      return this.createAgent(operatorId, {
        username: data.username,
        password: data.password,
        level: parentProfile.level + 1,
        parentAgentId,
        creditLimit: data.creditLimit ?? 0,
        cashbackRate: data.cashbackRate ?? 0,
        maxSingleDeposit: data.maxSingleDeposit,
        maxDailyDeposit: data.maxDailyDeposit,
        locale: data.locale,
        phone: data.phone,
        email: data.email,
      });
    }

    let parentId: bigint | null = null;
    if (data.parentId != null) {
      const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
      if (!parent || parent.userType !== 'AGENT') {
        throw appBadRequest('PARENT_MUST_BE_AGENT');
      }
      parentId = data.parentId;

      const operator = await this.prisma.user.findUnique({ where: { id: operatorId } });
      if (operator?.userType === 'AGENT' && parentId !== operatorId) {
        throw appForbidden('CREATE_DIRECT_PLAYERS_ONLY');
      }
    }

    try {
      assertPlayerUsername(data.username);
    } catch {
      throw appBadRequest('USERNAME_FORMAT_INVALID');
    }

    const hash = await this.auth.hashPassword(data.password);
    const locale = data.locale ?? 'zh-CN';

    const user = await this.prisma.$transaction(async (tx) => {
      const created = await tx.user.create({
        data: {
          username: data.username,
          userType: 'PLAYER',
          parentId,
          locale,
        },
      });

      await tx.userAuth.create({
        data: { userId: created.id, passwordHash: hash },
      });

      await tx.wallet.create({
        data: { userId: created.id },
      });

      await tx.userPreference.create({
        data: {
          userId: created.id,
          locale,
          phone: data.phone?.trim() || null,
          email: data.email?.trim() || null,
          managedPassword: data.password,
        },
      });

      return created;
    });

    if (parentId) {
      await this.recalculateUsedCredit(parentId);
    }

    const initial = data.initialDeposit ?? 0;
    if (initial > 0) {
      const remarkResult = validateInitialDepositRemark(initial, data.depositRemark, 'admin');
      if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
      const requestId =
        data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
      await this.credit.adminDepositToPlayer(user.id, initial, operatorId, remarkResult.remark, requestId);
    }

    return user;
  }

  async getPortalAgentDirectPlayers(
    rootAgentId: bigint,
    targetAgentId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    await this.assertDescendantAgent(rootAgentId, targetAgentId);
    const page = Math.max(1, opts?.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
    const { items: players, total } = await this.getDirectPlayers(targetAgentId, {
      page,
      pageSize,
    });
    const profile = await this.prisma.agentProfile.findUnique({
      where: { userId: targetAgentId },
      select: {
        cashbackRate: true,
        user: { select: { username: true } },
      },
    });
    const rootKey = rootAgentId.toString();
    const targetKey = targetAgentId.toString();
    const parentAgentUsername = profile?.user.username ?? '—';
    const parentCashbackMap = await this.agentCashbackRateMap([targetAgentId]);
    const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
      players.map((p) => ({ id: BigInt(p.id), parentId: targetAgentId })),
      parentCashbackMap,
    );
    const mapped = players.map((p) => ({
      ...p,
      parentAgentId: targetKey,
      parentAgentUsername,
      cashbackRate: playerCashbackMap.get(p.id) ?? '0',
      inChain: true,
      isDirect: targetKey === rootKey,
    }));
    return { items: mapped, total, page, pageSize };
  }

  async getDirectPlayers(
    agentId: bigint,
    opts?: { page?: number; pageSize?: number },
  ) {
    const where = { parentId: agentId, userType: 'PLAYER' as const, deletedAt: null };
    const page = Math.max(1, opts?.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
    const [total, rows] = await Promise.all([
      this.prisma.user.count({ where }),
      this.prisma.user.findMany({
        where,
        include: {
          wallet: true,
          usedInvite: { select: { code: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * pageSize,
        take: pageSize,
      }),
    ]);
    const items = rows.map((u) => ({
      id: u.id.toString(),
      username: u.username,
      status: u.status,
      createdAt: u.createdAt,
      inviteCode: u.usedInvite?.code ?? null,
      wallet: u.wallet
        ? {
            availableBalance: u.wallet.availableBalance.toString(),
            frozenBalance: u.wallet.frozenBalance.toString(),
          }
        : undefined,
    }));
    return { items, total, page, pageSize };
  }

  async getChildAgents(agentId: bigint) {
    return this.network.getChildAgents(agentId);
  }

  async listChildAgentsSummary(parentAgentId: bigint) {
    const profiles = await this.getChildAgents(parentAgentId);
    const agentIds = profiles.map((p) => p.userId);
    const playerCounts =
      agentIds.length > 0
        ? await this.prisma.user.groupBy({
            by: ['parentId'],
            where: {
              userType: 'PLAYER',
              parentId: { in: agentIds },
              deletedAt: null,
            },
            _count: { _all: true },
          })
        : [];

    const countMap = new Map(
      playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
    );

    return profiles.map((p) => {
      const available = new Decimal(p.creditLimit).sub(p.usedCredit);
      return {
        userId: p.userId.toString(),
        username: p.user.username,
        userStatus: p.user.status,
        status: p.status,
        level: p.level,
        parentAgentId: p.parentAgentId?.toString() ?? null,
        cashbackRate: p.cashbackRate.toString(),
        creditLimit: dec(p.creditLimit),
        usedCredit: dec(p.usedCredit),
        availableCredit: available.toString(),
        directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
        createdAt: p.createdAt,
      };
    });
  }

  /** Read-only downline under a direct sub-agent (all descendant agents + subtree players). */
  async getDirectChildDownlineView(parentAgentId: bigint, subAgentId: bigint) {
    await this.assertDirectChildAgent(parentAgentId, subAgentId);

    const subtreeIds = await this.getSubtreeAgentIds(subAgentId);
    const descendantAgentIds = subtreeIds.filter((id) => id !== subAgentId);

    let agents: Array<{
      userId: string;
      username: string;
      userStatus: string;
      status: string;
      level: number;
      parentUsername: string;
      creditLimit: string;
      usedCredit: string;
      availableCredit: string;
      directPlayerCount: number;
      createdAt: Date;
    }> = [];

    if (descendantAgentIds.length > 0) {
      const profiles = await this.prisma.agentProfile.findMany({
        where: { userId: { in: descendantAgentIds } },
        include: { user: true },
        orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
      });

      const parentAgentIds = [
        ...new Set(
          profiles
            .map((p) => p.parentAgentId)
            .filter((id): id is bigint => id != null),
        ),
      ];
      const parentUsers =
        parentAgentIds.length > 0
          ? await this.prisma.user.findMany({
              where: { id: { in: parentAgentIds } },
              select: { id: true, username: true },
            })
          : [];
      const parentNameMap = new Map(
        parentUsers.map((u) => [u.id.toString(), u.username]),
      );

      const playerCounts = await this.prisma.user.groupBy({
        by: ['parentId'],
        where: {
          userType: 'PLAYER',
          parentId: { in: descendantAgentIds },
          deletedAt: null,
        },
        _count: { _all: true },
      });
      const countMap = new Map(
        playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
      );

      agents = profiles.map((p) => {
        const available = new Decimal(p.creditLimit).sub(p.usedCredit);
        return {
          userId: p.userId.toString(),
          username: p.user.username,
          userStatus: p.user.status,
          status: p.status,
          level: p.level,
          parentUsername: p.parentAgentId
            ? parentNameMap.get(p.parentAgentId.toString()) ?? '—'
            : '—',
          cashbackRate: p.cashbackRate.toString(),
          creditLimit: dec(p.creditLimit),
          usedCredit: dec(p.usedCredit),
          availableCredit: available.toString(),
          directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
          createdAt: p.createdAt,
        };
      });
    }

    const playerRows =
      subtreeIds.length > 0
        ? await this.prisma.user.findMany({
            where: {
              userType: 'PLAYER',
              deletedAt: null,
              parentId: { in: subtreeIds },
            },
            include: {
              wallet: true,
              usedInvite: { select: { code: true } },
              parent: { select: { username: true } },
            },
            orderBy: { createdAt: 'desc' },
          })
        : [];

    const parentAgentIdsForPlayers = [
      ...new Set(
        playerRows
          .map((u) => u.parentId)
          .filter((id): id is bigint => id != null),
      ),
    ];
    const parentCashbackMap = await this.agentCashbackRateMap(parentAgentIdsForPlayers);
    const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
      playerRows.map((u) => ({ id: u.id, parentId: u.parentId })),
      parentCashbackMap,
    );

    const players = playerRows.map((u) => ({
      id: u.id.toString(),
      username: u.username,
      status: u.status,
      createdAt: u.createdAt,
      inviteCode: u.usedInvite?.code ?? null,
      parentAgentUsername: u.parent?.username ?? '—',
      cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
      wallet: u.wallet
        ? {
            availableBalance: u.wallet.availableBalance.toString(),
            frozenBalance: u.wallet.frozenBalance.toString(),
          }
        : undefined,
    }));

    return {
      agents: agents.map((a) => ({
        ...a,
        createdAt: a.createdAt.toISOString(),
      })),
      players,
    };
  }

  async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
    return this.network.assertDescendantAgent(rootAgentId, targetAgentId);
  }

  async countSubtreeAgentsByLevel(rootAgentId: bigint) {
    const scope = await this.getAgentPortalScope(rootAgentId);
    if (scope.descendantIds.length === 0) return {} as Record<number, number>;

    const groups = await this.prisma.agentProfile.groupBy({
      by: ['level'],
      where: {
        userId: { in: scope.descendantIds },
        level: { gt: scope.rootLevel },
        user: { deletedAt: null },
      },
      _count: { _all: true },
    });
    const out: Record<number, number> = {};
    for (const g of groups) {
      if (g.level > scope.rootLevel) {
        out[g.level] = g._count._all;
      }
    }
    return out;
  }

  async listSubtreeAgentsAtLevel(
    rootAgentId: bigint,
    level: number,
    params?: { page?: number; pageSize?: number; keyword?: string; status?: string },
  ) {
    const page = Math.max(1, params?.page ?? 1);
    const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
    const skip = (page - 1) * pageSize;

    const scope = await this.getAgentPortalScope(rootAgentId);
    if (level <= scope.rootLevel || scope.descendantIds.length === 0) {
      return { items: [], total: 0, page, pageSize };
    }

    const where: Prisma.AgentProfileWhereInput = {
      userId: { in: scope.descendantIds },
      level,
    };
    const kw = params?.keyword?.trim();
    const status = params?.status?.trim();
    const userWhere: Prisma.UserWhereInput = { deletedAt: null };
    if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
      userWhere.status = status;
    }
    if (kw) {
      userWhere.username = { contains: kw, mode: 'insensitive' };
    }
    where.user = userWhere;

    const [profiles, total] = await Promise.all([
      this.prisma.agentProfile.findMany({
        where,
        include: { user: true },
        orderBy: { createdAt: 'desc' },
        skip,
        take: pageSize,
      }),
      this.prisma.agentProfile.count({ where }),
    ]);

    const agentIds = profiles.map((p) => p.userId);
    const playerCounts =
      agentIds.length > 0
        ? await this.prisma.user.groupBy({
            by: ['parentId'],
            where: {
              userType: 'PLAYER',
              parentId: { in: agentIds },
              deletedAt: null,
            },
            _count: { _all: true },
          })
        : [];
    const countMap = new Map(
      playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
    );

    const parentAgentIds = [
      ...new Set(
        profiles
          .map((p) => p.parentAgentId)
          .filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString())),
      ),
    ];
    const parentUsers =
      parentAgentIds.length > 0
        ? await this.prisma.user.findMany({
            where: { id: { in: parentAgentIds } },
            select: { id: true, username: true },
          })
        : [];
    const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
    const parentChainMap = await this.buildScopedAncestorChainMap(
      profiles.map((p) => p.parentAgentId),
      rootAgentId,
    );

    const items = profiles.map((p) => {
      const available = new Decimal(p.creditLimit).sub(p.usedCredit);
      const parentChain = p.parentAgentId
        ? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
        : [];
      const parentUsername =
        p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
          ? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
          : null;
      return {
        userId: p.userId.toString(),
        username: p.user.username,
        userStatus: p.user.status,
        status: p.status,
        level: p.level,
        parentAgentId: p.parentAgentId?.toString() ?? null,
        parentUsername,
        parentChainLabel: parentChain.length ? parentChain.join(' / ') : null,
        cashbackRate: p.cashbackRate.toString(),
        creditLimit: dec(p.creditLimit),
        usedCredit: dec(p.usedCredit),
        availableCredit: available.toString(),
        directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
        createdAt: p.createdAt.toISOString(),
      };
    });

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

  async listSubtreePlayersForPortal(
    rootAgentId: bigint,
    params?: {
      page?: number;
      pageSize?: number;
      keyword?: string;
      status?: string;
      parentAgentId?: string;
    },
  ) {
    const page = Math.max(1, params?.page ?? 1);
    const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
    const skip = (page - 1) * pageSize;

    const scope = await this.getAgentPortalScope(rootAgentId);
    const where: Prisma.UserWhereInput = {
      userType: 'PLAYER',
      deletedAt: null,
      parentId: { in: scope.subtreeIds },
    };
    if (params?.parentAgentId) {
      this.assertAgentInPortalSubtree(scope, BigInt(params.parentAgentId));
      where.parentId = BigInt(params.parentAgentId);
    }
    const kw = params?.keyword?.trim();
    if (kw) {
      where.username = { contains: kw, mode: 'insensitive' };
    }
    const status = params?.status?.trim();
    if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
      where.status = status;
    }

    const [rows, total] = await Promise.all([
      this.prisma.user.findMany({
        where,
        include: {
          wallet: true,
          usedInvite: { select: { code: true } },
          parent: { select: { id: true, username: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip,
        take: pageSize,
      }),
      this.prisma.user.count({ where }),
    ]);

    const parentIds = [
      ...new Set(
        rows
          .map((u) => u.parentId)
          .filter((id): id is bigint => id != null),
      ),
    ];
    const parentCashbackMap = await this.agentCashbackRateMap(parentIds);
    const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
      rows.map((u) => ({ id: u.id, parentId: u.parentId })),
      parentCashbackMap,
    );

    const rootKey = rootAgentId.toString();
    const items = rows.map((u) => {
      const parentId = u.parentId!.toString();
      return {
        id: u.id.toString(),
        username: u.username,
        status: u.status,
        createdAt: u.createdAt.toISOString(),
        inviteCode: u.usedInvite?.code ?? null,
        parentAgentId: parentId,
        parentAgentUsername: u.parent?.username ?? '—',
        cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
        inChain: true,
        isDirect: parentId === rootKey,
        wallet: u.wallet
          ? {
              availableBalance: u.wallet.availableBalance.toString(),
              frozenBalance: u.wallet.frozenBalance.toString(),
            }
          : undefined,
      };
    });

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

  async listSubtreeAgentOptions(rootAgentId: bigint) {
    const scope = await this.getAgentPortalScope(rootAgentId);
    const profiles = await this.prisma.agentProfile.findMany({
      where: {
        userId: { in: scope.subtreeIds },
        OR: [{ userId: rootAgentId }, { level: { gt: scope.rootLevel } }],
      },
      include: { user: { select: { username: true } } },
      orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
    });

    const parentIds = profiles
      .map((p) => p.parentAgentId)
      .filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString()));
    const parentUsers =
      parentIds.length > 0
        ? await this.prisma.user.findMany({
            where: { id: { in: [...new Set(parentIds)] } },
            select: { id: true, username: true },
          })
        : [];
    const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));

    return profiles.map((p) => ({
      id: p.userId.toString(),
      username: p.user.username,
      level: p.level,
      parentUsername:
        p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
          ? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
          : null,
    }));
  }

  async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
    return this.network.assertDirectChildAgent(parentAgentId, subAgentId);
  }

  async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
    await this.assertDirectChildAgent(parentAgentId, subAgentId);
    return this.getAgentAdminDetail(subAgentId);
  }

  async updateSubAgentForParent(
    parentAgentId: bigint,
    subAgentId: bigint,
    data: {
      username?: string;
      password?: string;
      phone?: string;
      email?: string;
      status?: string;
      freezeDirectPlayers?: boolean;
      blockDirectPlayerLogin?: boolean;
      unfreezeDirectPlayers?: boolean;
    },
  ) {
    await this.assertDirectChildAgent(parentAgentId, subAgentId);
    return this.updateAgentAdmin(subAgentId, data);
  }

  async getSubtreeAgentIds(agentId: bigint) {
    return this.network.getSubtreeAgentIds(agentId);
  }

  async getReportSummary(agentId: bigint) {
    const profile = await this.getProfile(agentId);
    const agentIds = await this.getSubtreeAgentIds(agentId);
    const betScope = { agentId: { in: agentIds } };
    const playerWhere = {
      parentId: agentId,
      userType: 'PLAYER' as const,
      deletedAt: null,
    };

    const today = new Date();
    today.setHours(0, 0, 0, 0);
    const yesterday = new Date(today.getTime() - 86400000);

    const trend7d = await Promise.all(
      Array.from({ length: 7 }, (_, i) => {
        const dayStart = new Date(today);
        dayStart.setDate(dayStart.getDate() - (6 - i));
        const dayEnd = new Date(dayStart);
        dayEnd.setDate(dayEnd.getDate() + 1);
        return this.prisma.bet
          .aggregate({
            where: { ...betScope, placedAt: { gte: dayStart, lt: dayEnd } },
            _sum: { stake: true, actualReturn: true },
            _count: true,
          })
          .then((agg) => ({
            date: dayStart.toISOString().slice(0, 10),
            label: `${dayStart.getMonth() + 1}/${dayStart.getDate()}`,
            betCount: agg._count,
            stake: dec(agg._sum.stake),
            payout: dec(agg._sum.actualReturn),
            ggr: sub(agg._sum.stake, agg._sum.actualReturn),
          }));
      }),
    );

    const [
      todayBets,
      yesterdayBets,
      pendingBets,
      betStatusToday,
      playerTotal,
      playerActive,
      playerSuspended,
      newPlayersToday,
      subAgentTotal,
      subAgentsActive,
      walletAgg,
      recentBets,
      recentPlayers,
    ] = await Promise.all([
      this.prisma.bet.aggregate({
        where: { ...betScope, placedAt: { gte: today } },
        _sum: { stake: true, actualReturn: true },
        _count: true,
      }),
      this.prisma.bet.aggregate({
        where: { ...betScope, placedAt: { gte: yesterday, lt: today } },
        _sum: { stake: true, actualReturn: true },
        _count: true,
      }),
      this.prisma.bet.count({ where: { ...betScope, status: 'PENDING' } }),
      this.prisma.bet.groupBy({
        by: ['status'],
        where: { ...betScope, placedAt: { gte: today } },
        _count: { _all: true },
        _sum: { stake: true },
      }),
      this.prisma.user.count({ where: playerWhere }),
      this.prisma.user.count({ where: { ...playerWhere, status: 'ACTIVE' } }),
      this.prisma.user.count({ where: { ...playerWhere, status: 'SUSPENDED' } }),
      this.prisma.user.count({
        where: { ...playerWhere, createdAt: { gte: today } },
      }),
      this.prisma.agentProfile.count({ where: { parentAgentId: agentId } }),
      this.prisma.agentProfile.count({
        where: { parentAgentId: agentId, status: 'ACTIVE' },
      }),
      this.prisma.wallet.aggregate({
        where: { user: playerWhere },
        _sum: { availableBalance: true, frozenBalance: true },
        _count: { _all: true },
      }),
      this.prisma.bet.findMany({
        where: betScope,
        take: 8,
        orderBy: { placedAt: 'desc' },
        include: { user: { select: { username: true } } },
      }),
      this.prisma.user.findMany({
        where: playerWhere,
        take: 6,
        orderBy: { createdAt: 'desc' },
        select: {
          id: true,
          username: true,
          status: true,
          createdAt: true,
        },
      }),
    ]);

    const todayBetByStatus: Record<string, { count: number; stake: string }> = {};
    for (const g of betStatusToday) {
      todayBetByStatus[g.status] = {
        count: g._count._all,
        stake: dec(g._sum.stake),
      };
    }

    const creditLimit = profile.creditLimit ?? new Decimal(0);
    const usedCredit = profile.usedCredit ?? new Decimal(0);
    const availableCredit = new Decimal(creditLimit).sub(usedCredit);

    return {
      generatedAt: new Date().toISOString(),
      trend7d,
      today: {
        betCount: todayBets._count,
        stake: dec(todayBets._sum.stake),
        payout: dec(todayBets._sum.actualReturn),
        ggr: sub(todayBets._sum.stake, todayBets._sum.actualReturn),
        newPlayers: newPlayersToday,
      },
      yesterday: {
        betCount: yesterdayBets._count,
        stake: dec(yesterdayBets._sum.stake),
        payout: dec(yesterdayBets._sum.actualReturn),
        ggr: sub(yesterdayBets._sum.stake, yesterdayBets._sum.actualReturn),
      },
      players: {
        directTotal: playerTotal,
        active: playerActive,
        suspended: playerSuspended,
        newToday: newPlayersToday,
      },
      subAgents: {
        total: subAgentTotal,
        active: subAgentsActive,
      },
      wallets: {
        totalAvailable: dec(walletAgg._sum.availableBalance),
        totalFrozen: dec(walletAgg._sum.frozenBalance),
        playerWalletCount: walletAgg._count._all,
      },
      credit: {
        creditLimit: dec(creditLimit),
        usedCredit: dec(usedCredit),
        availableCredit: availableCredit.toString(),
        directPlayerLiability: dec(profile.directPlayerLiability),
        childAgentExposure: dec(profile.childAgentExposure),
      },
      bets: {
        pendingTotal: pendingBets,
        todayByStatus: todayBetByStatus,
      },
      recentBets: recentBets.map((b) => ({
        betNo: b.betNo,
        username: b.user.username,
        stake: dec(b.stake),
        status: b.status,
        placedAt: b.placedAt,
      })),
      recentPlayers: recentPlayers.map((p) => ({
        id: p.id.toString(),
        username: p.username,
        status: p.status,
        createdAt: p.createdAt,
      })),
    };
  }
}
