<?php

namespace App\Services\AgentSettlement;

use Carbon\Carbon;
use App\Models\AdminUser;
use App\Support\LimitedQuery;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Query\Builder;
use App\Support\AdminAgentSettlementScope;
use App\Support\AgentSettlementPeriodWindow;

/** 结算中心统一账务流水（credit_ledger + 收付 + 调账）。 */
final class SettlementCenterLedgerService
{
    private const CREDIT_BIZ_TYPES = [
        'bet_hold',
        'bet_hold_release',
        'game_settlement_loss',
        'game_settlement_win',
        'game_settlement_reversal',
        'settlement_confirm',
        'settlement_payout',
    ];

    private const ADJUSTMENT_BIZ_TYPES = [
        'adjustment',
        'reversal',
        'bad_debt',
    ];

    private const SHARE_BIZ_TYPE = 'share_ledger';

    public function __construct(
        private readonly SettlementPartyEnrichment $partyEnrichment,
        private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
    ) {}

    /**
     * @return array{
     *   items: list<array<string, mixed>>,
     *   total: int,
     *   page: int,
     *   per_page: int,
     *   ledger_source: string,
     * }
     */
    public function listUnified(
        AdminUser $admin,
        string $siteCode,
        int $page,
        int $perPage,
        SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
    ): array {
        $periodId = $filters->settlementPeriodId;
        $range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
        $settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo);
        $playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId);
        $playerBills = $playerBillsResult['map'];
        $billsTruncated = $playerBillsResult['truncated'];

        $stubQueries = [];
        if ($this->shouldIncludeLedgerStub($filters, 'credit')) {
            $creditStub = $this->creditStubQuery($admin, $siteCode, $range, $filters);
            if ($creditStub !== null) {
                $stubQueries[] = $creditStub;
            }
        }
        if ($this->shouldIncludeLedgerStub($filters, 'payment')) {
            $paymentStub = $this->paymentStubQuery($admin, $siteCode, $periodId, $filters);
            if ($paymentStub !== null) {
                $stubQueries[] = $paymentStub;
            }
        }
        if ($this->shouldIncludeLedgerStub($filters, 'adjustment')) {
            $adjustmentStub = $this->adjustmentStubQuery($admin, $siteCode, $periodId, $filters);
            if ($adjustmentStub !== null) {
                $stubQueries[] = $adjustmentStub;
            }
        }
        if ($this->shouldIncludeLedgerStub($filters, 'share')) {
            $shareStub = $this->shareStubQuery($admin, $siteCode, $settledRange, $filters);
            if ($shareStub !== null) {
                $stubQueries[] = $shareStub;
            }
        }

        if ($stubQueries === []) {
            return [
                'items' => [],
                'total' => 0,
                'page' => $page,
                'per_page' => $perPage,
                'ledger_source' => 'settlement_ledger',
            ];
        }

        if ($filters->actionableOnly) {
            return $this->listUnifiedActionableFiltered(
                $admin,
                $siteCode,
                $page,
                $perPage,
                $filters,
                $stubQueries,
                $playerBills,
                $billsTruncated,
            );
        }

        $offset = max(0, ($page - 1) * $perPage);
        if (count($stubQueries) === 1) {
            $base = $stubQueries[0];
            $total = (int) (clone $base)->count();
            $stubs = (clone $base)
                ->orderByDesc('sort_at')
                ->orderByDesc('entry_id')
                ->offset($offset)
                ->limit($perPage)
                ->get();
        } else {
            $union = null;
            foreach ($stubQueries as $stubQuery) {
                $union = $union === null ? $stubQuery : $union->unionAll($stubQuery);
            }
            $wrapped = DB::query()->fromSub($union, 'ledger_page');
            $total = (int) (clone $wrapped)->count();
            $stubs = $wrapped
                ->orderByDesc('sort_at')
                ->orderByDesc('entry_id')
                ->offset($offset)
                ->limit($perPage)
                ->get();
        }

        $items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills);
        $items = $this->applyFilters($items, $filters);

        return [
            'items' => array_values($items),
            'total' => $total,
            'page' => $page,
            'per_page' => $perPage,
            'ledger_source' => 'settlement_ledger',
            'truncated' => $billsTruncated || $total > 5000,
        ];
    }

    /**
     * 下注流水简化展示：下注占用 + 按注单合并的开奖结算。
     *
     * @return array{
     *   items: list<array<string, mixed>>,
     *   total: int,
     *   page: int,
     *   per_page: int,
     *   ledger_source: string,
     * }
     */
    public function listBetFlowSimplified(
        AdminUser $admin,
        string $siteCode,
        int $page,
        int $perPage,
        SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
    ): array {
        $periodId = $filters->settlementPeriodId;
        $range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
        $fetched = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters);
        $rows = $fetched['rows'];
        $sourceTruncated = $fetched['truncated'];
        $playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId);
        $playerBills = $playerBillsResult['map'];
        $billsTruncated = $playerBillsResult['truncated'];

        $ticketIds = [];
        foreach ($rows as $row) {
            if ((string) ($row->ref_type ?? '') === 'ticket_item') {
                $ticketId = (int) ($row->ref_id ?? 0);
                if ($ticketId > 0) {
                    $ticketIds[] = $ticketId;
                }
            }
        }

        $ticketRefs = $this->partyEnrichment->loadTicketRefs(array_values(array_unique($ticketIds)));

        $items = $this->betFlowPresenter->simplifyCreditRows(
            $rows,
            $ticketRefs,
            function (object $row) use ($playerBills, $ticketRefs): array {
                $pid = (int) ($row->player_id ?? 0);

                return $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs);
            },
            function (object $row) use ($playerBills, $ticketRefs): array {
                $pid = (int) ($row->player_id ?? 0);
                $formatted = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs);
                $ticketId = (int) ($row->ref_id ?? 0);
                if ($ticketId > 0) {
                    $formatted['txn_no'] = 'CLS-T'.$ticketId;
                    $formatted['row_key'] = 'settlement-'.$ticketId;
                }

                return $formatted;
            },
        );

        if ($filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD
            || $filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT) {
            $items = array_values(array_filter(
                $items,
                static fn (array $item): bool => ($item['biz_type'] ?? '') === $filters->bizType,
            ));
        }

        $total = count($items);
        $offset = max(0, ($page - 1) * $perPage);
        $pageItems = array_slice($items, $offset, $perPage);

        return [
            'items' => $pageItems,
            'total' => $total,
            'page' => $page,
            'per_page' => $perPage,
            'ledger_source' => 'credit_ledger',
            'truncated' => $sourceTruncated || $billsTruncated,
        ];
    }

    private function shouldIncludeLedgerStub(SettlementLedgerListFilters $filters, string $kind): bool
    {
        if (! $this->includeEntryKind($filters, $kind)) {
            return false;
        }

        if ($filters->bizType === null || $filters->bizType === '') {
            return true;
        }

        return match ($kind) {
            'credit' => in_array($filters->bizType, self::CREDIT_BIZ_TYPES, true),
            'payment' => $filters->bizType === 'payment_record',
            'adjustment' => in_array($filters->bizType, self::ADJUSTMENT_BIZ_TYPES, true),
            'share' => $filters->bizType === self::SHARE_BIZ_TYPE
                || ($filters->entryKind === 'share' && ($filters->bizType === null || $filters->bizType === '')),
            default => false,
        };
    }

    /**
     * @param  array{0: Carbon, 1: Carbon}|null  $range
     */
    private function shareStubQuery(
        AdminUser $admin,
        string $siteCode,
        ?array $range,
        SettlementLedgerListFilters $filters,
    ): ?Builder {
        $query = DB::table('share_ledger as sl')
            ->join('players as p', 'p.id', '=', 'sl.player_id')
            ->where('p.site_code', $siteCode)
            ->whereNull('sl.reversal_of_id')
            ->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at");

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
        $this->applyLedgerPlayerFilters($query, 'p', $filters);

        if ($range !== null) {
            $query->whereBetween('sl.settled_at', $range);
        }

        $this->applyTxnNoStubFilter($query, 'sl.id', 'SL', $filters->txnNo);

        return $query;
    }

    /**
     * @param  array{0: Carbon, 1: Carbon}|null  $range
     */
    private function creditStubQuery(
        AdminUser $admin,
        string $siteCode,
        ?array $range,
        SettlementLedgerListFilters $filters,
    ): ?Builder {
        $query = DB::table('credit_ledger as cl')
            ->join('players as p', function ($join): void {
                $join->on('p.id', '=', 'cl.owner_id')
                    ->where('cl.owner_type', '=', 'player');
            })
            ->where('p.site_code', $siteCode)
            ->where('p.funding_mode', PlayerFundingMode::CREDIT)
            ->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at");

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
        $this->applyLedgerPlayerFilters($query, 'p', $filters);

        if ($range !== null) {
            $query->whereBetween('cl.created_at', $range);
        }

        if ($filters->bizType !== null && $filters->bizType !== '') {
            $query->where('cl.reason', $filters->bizType);
        } elseif ($filters->betFlowOnly) {
            $query->whereIn('cl.reason', [
                'bet_hold',
                'bet_hold_release',
                'game_settlement_loss',
                'game_settlement_win',
            ]);
        }

        $this->applyTxnNoStubFilter($query, 'cl.id', 'CL', $filters->txnNo);

        return $query;
    }

    private function paymentStubQuery(
        AdminUser $admin,
        string $siteCode,
        ?int $periodId,
        SettlementLedgerListFilters $filters,
    ): ?Builder {
        $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
        if ($adminSiteId <= 0) {
            return null;
        }

        $query = DB::table('payment_records as pr')
            ->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
            ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
            ->where('sp.admin_site_id', $adminSiteId)
            ->leftJoin('players as p', function ($join): void {
                $join->on('p.id', '=', 'sb.owner_id')
                    ->where('sb.owner_type', '=', 'player');
            })
            ->selectRaw("'payment' as entry_kind, pr.id as entry_id, pr.created_at as sort_at");

        if ($periodId !== null && $periodId > 0) {
            $query->where('sb.settlement_period_id', $periodId);
        }

        $this->applyLedgerSiteScope($query, $admin, 'sp');
        AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
        $this->applyPaymentPlayerFilters($query, $filters);

        if ($filters->billStatus !== null && $filters->billStatus !== '') {
            $query->where('sb.status', $filters->billStatus);
        }

        $this->applyTxnNoStubFilter($query, 'pr.id', 'PAY', $filters->txnNo);

        return $query;
    }

    private function applyPaymentPlayerFilters(
        Builder $query,
        SettlementLedgerListFilters $filters,
    ): void {
        if ($filters->playerId !== null && $filters->playerId > 0) {
            $query->where('sb.owner_type', 'player')
                ->where('sb.owner_id', $filters->playerId);
        }

        if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
            $query->where('sb.owner_type', 'player');
            $this->applyLedgerPlayerFilters($query, 'p', $filters);
        }
    }

    private function adjustmentStubQuery(
        AdminUser $admin,
        string $siteCode,
        ?int $periodId,
        SettlementLedgerListFilters $filters,
    ): ?Builder {
        $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
        if ($adminSiteId <= 0) {
            return null;
        }

        $query = DB::table('settlement_adjustments as sa')
            ->join('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
            ->where('sp.admin_site_id', $adminSiteId)
            ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
            ->leftJoin('players as p', function ($join): void {
                $join->on('p.id', '=', 'sb.owner_id')
                    ->where('sb.owner_type', '=', 'player');
            })
            ->selectRaw("'adjustment' as entry_kind, sa.id as entry_id, sa.created_at as sort_at");

        if ($periodId !== null && $periodId > 0) {
            $query->where('sa.settlement_period_id', $periodId);
        }

        if ($filters->badDebtOnly) {
            $query->where('sa.adjustment_type', 'bad_debt');
        } elseif ($filters->entryKind === 'adjustment') {
            $query->where('sa.adjustment_type', '!=', 'bad_debt');
        }

        $this->applyLedgerSiteScope($query, $admin, 'sp');
        $this->applyAdjustmentPlayerScope($query, $admin, $siteCode, $filters);

        if ($filters->billStatus !== null && $filters->billStatus !== '') {
            $query->where('sb.status', $filters->billStatus);
        }

        if ($filters->bizType !== null && $filters->bizType !== '') {
            $query->where('sa.adjustment_type', $filters->bizType);
        }

        $this->applyTxnNoStubFilter($query, 'sa.id', 'ADJ', $filters->txnNo);

        return $query;
    }

    private function applyAdjustmentPlayerScope(
        Builder $query,
        AdminUser $admin,
        string $siteCode,
        SettlementLedgerListFilters $filters,
    ): void {
        $query->where(function (Builder $outer) use ($admin, $siteCode, $filters): void {
            $outer->whereNull('p.id')
                ->orWhere(function (Builder $scoped) use ($admin, $siteCode, $filters): void {
                    $scoped->where('p.site_code', $siteCode);
                    AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
                    $this->applyLedgerPlayerFilters($scoped, 'p', $filters);
                });
        });
    }

    private function applyTxnNoStubFilter(
        Builder $query,
        string $idColumn,
        string $prefix,
        ?string $txnNo,
    ): void {
        if ($txnNo === null || $txnNo === '') {
            return;
        }

        $needle = strtolower(trim($txnNo));
        $query->where(function (Builder $match) use ($idColumn, $prefix, $needle): void {
            if (ctype_digit($needle)) {
                $match->where($idColumn, (int) $needle);
            }

            $match->orWhereRaw(
                'LOWER(CONCAT(?, \'-\', '.$idColumn.')) LIKE ?',
                [$prefix, '%'.$needle.'%'],
            );
        });
    }

    private function applyLedgerSiteScope(Builder $query, AdminUser $admin, string $periodsAlias): void
    {
        $siteIds = $admin->accessibleAdminSiteIds();
        if ($siteIds === null) {
            return;
        }

        if ($siteIds === []) {
            $query->whereRaw('0 = 1');

            return;
        }

        $query->whereIn($periodsAlias.'.admin_site_id', $siteIds);
    }

    private function applyLedgerPlayerFilters(
        Builder $query,
        string $playerAlias,
        SettlementLedgerListFilters $filters,
    ): void {
        if ($filters->playerId !== null && $filters->playerId > 0) {
            $query->where("{$playerAlias}.id", $filters->playerId);
        }

        if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
            $like = '%'.addcslashes($filters->playerAccount, '%_\\').'%';
            $query->where(function (Builder $match) use ($playerAlias, $like): void {
                $match->where("{$playerAlias}.username", 'like', $like)
                    ->orWhere("{$playerAlias}.site_player_id", 'like', $like)
                    ->orWhere("{$playerAlias}.nickname", 'like', $like);
            });
        }
    }

    /**
     * @param  Collection<int, object>  $stubs
     * @param  array<int, object>  $playerBills
     * @return list<array<string, mixed>>
     */
    private function hydrateLedgerStubs(
        AdminUser $admin,
        string $siteCode,
        Collection $stubs,
        array $playerBills,
    ): array {
        if ($stubs->isEmpty()) {
            return [];
        }

        $creditIds = [];
        $paymentIds = [];
        $adjustmentIds = [];
        $shareIds = [];
        foreach ($stubs as $stub) {
            $kind = (string) $stub->entry_kind;
            $id = (int) $stub->entry_id;
            if ($kind === 'credit') {
                $creditIds[] = $id;
            } elseif ($kind === 'payment') {
                $paymentIds[] = $id;
            } elseif ($kind === 'adjustment') {
                $adjustmentIds[] = $id;
            } elseif ($kind === 'share') {
                $shareIds[] = $id;
            }
        }

        $creditById = [];
        if ($creditIds !== []) {
            foreach ($this->fetchCreditRowsByIds($admin, $siteCode, $creditIds) as $row) {
                $creditById[(int) $row->id] = $row;
            }
        }

        $ticketRefs = $this->partyEnrichment->loadTicketRefs(
            array_values(array_filter(array_map(
                static fn (object $row): int => (string) ($row->ref_type ?? '') === 'ticket_item'
                    ? (int) ($row->ref_id ?? 0)
                    : 0,
                array_values($creditById),
            ), static fn (int $id): bool => $id > 0)),
        );
        $creditBillRefs = $this->settlementBillsByIds(
            $admin,
            array_values(array_filter(array_map(
                static fn (object $row): int => (string) ($row->ref_type ?? '') === 'settlement_bill'
                    ? (int) ($row->ref_id ?? 0)
                    : 0,
                array_values($creditById),
            ), static fn (int $id): bool => $id > 0)),
        );

        $paymentById = [];
        if ($paymentIds !== []) {
            foreach ($this->fetchPaymentRowsByIds($admin, $siteCode, $paymentIds) as $row) {
                $paymentById[(int) $row->id] = $row;
            }
        }

        $adjustmentById = [];
        if ($adjustmentIds !== []) {
            foreach ($this->fetchAdjustmentRowsByIds($admin, $siteCode, $adjustmentIds) as $row) {
                $adjustmentById[(int) $row->id] = $row;
            }
        }

        $shareById = [];
        if ($shareIds !== []) {
            foreach ($this->fetchShareRowsByIds($admin, $siteCode, $shareIds) as $row) {
                $shareById[(int) $row->id] = $row;
            }
        }

        $shareTicketRefs = $this->partyEnrichment->loadTicketRefs(
            array_values(array_filter(array_map(
                static fn (object $row): int => (int) ($row->ticket_item_id ?? 0),
                array_values($shareById),
            ), static fn (int $id): bool => $id > 0)),
        );

        $items = [];
        foreach ($stubs as $stub) {
            $kind = (string) $stub->entry_kind;
            $id = (int) $stub->entry_id;
            if ($kind === 'credit' && isset($creditById[$id])) {
                $row = $creditById[$id];
                $bill = (string) ($row->ref_type ?? '') === 'settlement_bill'
                    ? ($creditBillRefs[(int) ($row->ref_id ?? 0)] ?? null)
                    : null;
                $items[] = $this->formatCreditEntry($row, $bill, $ticketRefs);
            } elseif ($kind === 'payment' && isset($paymentById[$id])) {
                $items[] = $this->formatPaymentEntry($paymentById[$id]);
            } elseif ($kind === 'adjustment' && isset($adjustmentById[$id])) {
                $items[] = $this->formatAdjustmentEntry($adjustmentById[$id]);
            } elseif ($kind === 'share' && isset($shareById[$id])) {
                $items[] = $this->formatShareEntry($shareById[$id], $shareTicketRefs);
            }
        }

        return $items;
    }

    /**
     * @param  list<int>  $ids
     * @return array<int, object>
     */
    private function settlementBillsByIds(AdminUser $admin, array $ids): array
    {
        $ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
        if ($ids === []) {
            return [];
        }

        $query = DB::table('settlement_bills as sb')
            ->whereIn('sb.id', $ids)
            ->select([
                'sb.id',
                'sb.owner_id as player_id',
                'sb.status',
                'sb.bill_type',
                'sb.net_amount',
                'sb.unpaid_amount',
                'sb.paid_amount',
                'sb.settlement_period_id',
            ]);

        AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');

        $map = [];
        foreach ($query->get() as $bill) {
            $map[(int) $bill->id] = $bill;
        }

        return $map;
    }

    /**
     * @return array{0: Carbon|null, 1: Carbon|null}
     */
    private function includeEntryKind(SettlementLedgerListFilters $filters, string $kind): bool
    {
        if ($filters->badDebtOnly) {
            return $kind === 'adjustment';
        }

        $selected = $filters->entryKind;
        if ($selected === null || $selected === '' || $selected === 'all') {
            return true;
        }

        return $selected === $kind;
    }

    /**
     * actionable_only 依赖 hydrated 行的 available_actions，需在内存中过滤后再分页。
     *
     * @param  list<Builder>  $stubQueries
     * @param  array<int, object>  $playerBills
     * @return array{
     *   items: list<array<string, mixed>>,
     *   total: int,
     *   page: int,
     *   per_page: int,
     *   ledger_source: string,
     *   truncated: bool,
     * }
     */
    private function listUnifiedActionableFiltered(
        AdminUser $admin,
        string $siteCode,
        int $page,
        int $perPage,
        SettlementLedgerListFilters $filters,
        array $stubQueries,
        array $playerBills,
        bool $billsTruncated,
    ): array {
        $stubs = $this->fetchAllLedgerStubs($stubQueries);
        $items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills);
        $items = $this->applyFilters($items, $filters);

        $total = count($items);
        $offset = max(0, ($page - 1) * $perPage);
        $pageItems = array_slice($items, $offset, $perPage);

        return [
            'items' => array_values($pageItems),
            'total' => $total,
            'page' => $page,
            'per_page' => $perPage,
            'ledger_source' => 'settlement_ledger',
            'truncated' => $billsTruncated || $total > 5000,
        ];
    }

    /**
     * @param  list<Builder>  $stubQueries
     */
    private function fetchAllLedgerStubs(array $stubQueries): Collection
    {
        if (count($stubQueries) === 1) {
            return (clone $stubQueries[0])
                ->orderByDesc('sort_at')
                ->orderByDesc('entry_id')
                ->get();
        }

        $union = null;
        foreach ($stubQueries as $stubQuery) {
            $union = $union === null ? $stubQuery : $union->unionAll($stubQuery);
        }

        return DB::query()->fromSub($union, 'ledger_page')
            ->orderByDesc('sort_at')
            ->orderByDesc('entry_id')
            ->get();
    }

    /**
     * @param  list<array<string, mixed>>  $items
     * @return list<array<string, mixed>>
     */
    private function applyFilters(array $items, SettlementLedgerListFilters $filters): array
    {
        return array_values(array_filter($items, function (array $row) use ($filters): bool {
            if ($filters->badDebtOnly) {
                if (($row['entry_kind'] ?? '') !== 'adjustment' || ($row['biz_type'] ?? '') !== 'bad_debt') {
                    return false;
                }
            } elseif ($filters->entryKind === 'adjustment') {
                if (($row['entry_kind'] ?? '') === 'adjustment' && ($row['biz_type'] ?? '') === 'bad_debt') {
                    return false;
                }
            }

            if ($filters->actionableOnly) {
                $actions = $row['available_actions'] ?? [];
                $operational = array_filter(
                    $actions,
                    static fn (string $a): bool => ! in_array($a, ['view_player', 'view_bill'], true),
                );
                if ($operational === []) {
                    return false;
                }
            }

            return true;
        }));
    }

    private function resolveCreatedRange(
        ?int $settlementPeriodId,
        ?string $createdFrom,
        ?string $createdTo,
    ): ?array {
        return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo);
    }

    /**
     * @return array{0: Carbon, 1: Carbon}|null
     */
    private function resolveSettledRange(
        ?int $settlementPeriodId,
        ?string $createdFrom,
        ?string $createdTo,
    ): ?array {
        return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo);
    }

    /**
     * @return array{0: Carbon, 1: Carbon}|null
     */
    private function resolvePeriodRange(
        ?int $settlementPeriodId,
        ?string $rangeFrom,
        ?string $rangeTo,
    ): ?array {
        if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
            $period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
            if ($period === null) {
                return null;
            }

            return AgentSettlementPeriodWindow::bounds(
                (string) $period->period_start,
                (string) $period->period_end,
            );
        }

        $from = $rangeFrom !== null && $rangeFrom !== ''
            ? Carbon::parse($rangeFrom)->startOfDay()
            : null;
        $to = $rangeTo !== null && $rangeTo !== ''
            ? Carbon::parse($rangeTo)->endOfDay()
            : null;

        if ($from === null && $to === null) {
            return null;
        }

        return [
            $from ?? Carbon::parse('1970-01-01')->startOfDay(),
            $to ?? Carbon::now()->endOfDay(),
        ];
    }

    /**
     * @param  list<int>  $ids
     * @return list<object>
     */
    private function fetchShareRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
    {
        if ($ids === []) {
            return [];
        }

        $query = DB::table('share_ledger as sl')
            ->join('players as p', 'p.id', '=', 'sl.player_id')
            ->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
            ->leftJoin('draws as d', 'd.id', '=', 'ti.draw_id')
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->leftJoin('agent_nodes as sla', 'sla.id', '=', 'sl.agent_node_id')
            ->where('p.site_code', $siteCode)
            ->whereIn('sl.id', $ids)
            ->select([
                'sl.id',
                'sl.ticket_item_id',
                'sl.player_id',
                'sl.agent_node_id as share_agent_node_id',
                'sl.shared_net_win_loss',
                'sl.game_win_loss',
                'sl.settled_at',
                'p.site_code',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.funding_mode',
                'p.auth_source',
                'p.default_currency',
                'ti.play_code',
                'd.draw_no',
                'da.id as direct_agent_id',
                'da.code as direct_agent_code',
                'da.name as direct_agent_name',
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
                'sla.code as share_agent_code',
                'sla.name as share_agent_name',
            ]);

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');

        return $query->get()->all();
    }

    /**
     * @param  array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}>  $ticketRefs
     * @return array<string, mixed>
     */
    private function formatShareEntry(object $row, array $ticketRefs): array
    {
        $ticketId = (int) ($row->ticket_item_id ?? 0);
        $ticketRef = $ticketRefs[$ticketId] ?? null;
        $signed = (int) ($row->shared_net_win_loss ?? 0);

        return array_merge(
            $this->baseRow(
                entryKind: 'share',
                entryId: (int) $row->id,
                txnPrefix: 'SL',
                playerId: (int) $row->player_id,
                row: $row,
                bizType: self::SHARE_BIZ_TYPE,
                signedAmount: $signed,
                createdAt: $row->settled_at,
                ledgerSource: 'share_ledger',
                settlementBillId: null,
                billStatus: null,
                billType: null,
                billUnpaid: null,
                refLabel: $ticketId > 0 ? '#'.$ticketId : null,
                refType: $ticketId > 0 ? 'ticket_item' : null,
                refId: $ticketId > 0 ? $ticketId : null,
            ),
            $this->partyFieldsFromRow($row),
            [
                'play_code' => $ticketRef['play_code'] ?? $row->play_code ?? null,
                'draw_no' => $ticketRef['draw_no'] ?? $row->draw_no ?? null,
                'ticket_item_id' => $ticketId > 0 ? $ticketId : null,
                'available_actions' => ['view_player'],
            ],
        );
    }

    /**
     * @return array{map: array<int, object>, truncated: bool}
     */
    private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
    {
        $query = DB::table('settlement_bills as sb')
            ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
            ->join('players as p', function ($join): void {
                $join->on('p.id', '=', 'sb.owner_id')->where('sb.owner_type', '=', 'player');
            })
            ->where('p.site_code', $siteCode)
            ->where('sb.bill_type', 'player')
            ->select([
                'sb.id',
                'sb.owner_id as player_id',
                'sb.status',
                'sb.bill_type',
                'sb.net_amount',
                'sb.unpaid_amount',
                'sb.paid_amount',
                'sb.settlement_period_id',
            ])
            ->orderByDesc('sb.id');

        if ($periodId !== null && $periodId > 0) {
            abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
            $query->where('sb.settlement_period_id', $periodId);
        }

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');

        $limited = LimitedQuery::get($query, 500);
        $map = [];
        foreach ($limited['rows'] as $bill) {
            $pid = (int) $bill->player_id;
            if (! isset($map[$pid])) {
                $map[$pid] = $bill;

                continue;
            }
            $existing = $map[$pid];
            if ((string) $bill->status === 'pending_confirm') {
                $map[$pid] = $bill;
            } elseif ((string) $existing->status !== 'pending_confirm'
                && (int) $bill->unpaid_amount > 0
                && (int) $existing->unpaid_amount <= 0) {
                $map[$pid] = $bill;
            }
        }

        return [
            'map' => $map,
            'truncated' => $limited['truncated'],
        ];
    }

    /**
     * @param  array{0: Carbon, 1: Carbon}|null  $range
     * @return list<object>
     */
    private function fetchCreditRows(
        AdminUser $admin,
        string $siteCode,
        ?array $range,
        ?int $playerId,
    ): array {
        $query = DB::table('credit_ledger as cl')
            ->join('players as p', function ($join): void {
                $join->on('p.id', '=', 'cl.owner_id')
                    ->where('cl.owner_type', '=', 'player');
            })
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->where('p.site_code', $siteCode)
            ->where('p.funding_mode', PlayerFundingMode::CREDIT)
            ->select([
                'cl.id',
                'cl.amount',
                'cl.reason',
                'cl.ref_type',
                'cl.ref_id',
                'cl.created_at',
                'p.id as player_id',
                'p.site_code',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.funding_mode',
                'p.auth_source',
                'p.default_currency',
                'da.id as direct_agent_id',
                'da.code as direct_agent_code',
                'da.name as direct_agent_name',
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
            ])
            ->orderByDesc('cl.id');

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');

        if ($playerId !== null && $playerId > 0) {
            $query->where('p.id', $playerId);
        }

        if ($range !== null) {
            $query->whereBetween('cl.created_at', $range);
        }

        return $query->limit(500)->get()->all();
    }

    /**
     * @param  array{0: Carbon, 1: Carbon}|null  $range
     * @return array{rows: list<object>, truncated: bool}
     */
    private function fetchBetFlowCreditRows(
        AdminUser $admin,
        string $siteCode,
        ?array $range,
        SettlementLedgerListFilters $filters,
    ): array {
        $query = DB::table('credit_ledger as cl')
            ->join('players as p', function ($join): void {
                $join->on('p.id', '=', 'cl.owner_id')
                    ->where('cl.owner_type', '=', 'player');
            })
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->where('p.site_code', $siteCode)
            ->where('p.funding_mode', PlayerFundingMode::CREDIT)
            ->whereIn('cl.reason', [
                'bet_hold',
                'bet_hold_release',
                'game_settlement_loss',
                'game_settlement_win',
            ])
            ->select([
                'cl.id',
                'cl.amount',
                'cl.reason',
                'cl.ref_type',
                'cl.ref_id',
                'cl.created_at',
                'p.id as player_id',
                'p.site_code',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.funding_mode',
                'p.auth_source',
                'p.default_currency',
                'da.id as direct_agent_id',
                'da.code as direct_agent_code',
                'da.name as direct_agent_name',
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
            ])
            ->orderByDesc('cl.id');

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
        $this->applyLedgerPlayerFilters($query, 'p', $filters);

        if ($range !== null) {
            $query->whereBetween('cl.created_at', $range);
        }

        $limited = LimitedQuery::get($query, 5000);

        return [
            'rows' => $limited['rows']->all(),
            'truncated' => $limited['truncated'],
        ];
    }

    /**
     * @param  list<int>  $ids
     * @return list<object>
     */
    private function fetchCreditRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
    {
        if ($ids === []) {
            return [];
        }

        $query = DB::table('credit_ledger as cl')
            ->join('players as p', function ($join): void {
                $join->on('p.id', '=', 'cl.owner_id')
                    ->where('cl.owner_type', '=', 'player');
            })
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->where('p.site_code', $siteCode)
            ->where('p.funding_mode', PlayerFundingMode::CREDIT)
            ->whereIn('cl.id', $ids)
            ->select([
                'cl.id',
                'cl.amount',
                'cl.reason',
                'cl.ref_type',
                'cl.ref_id',
                'cl.created_at',
                'p.id as player_id',
                'p.site_code',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.funding_mode',
                'p.auth_source',
                'p.default_currency',
                'da.id as direct_agent_id',
                'da.code as direct_agent_code',
                'da.name as direct_agent_name',
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
            ]);

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');

        return $query->get()->all();
    }

    /**
     * @return list<object>
     */
    private function fetchPaymentRows(
        AdminUser $admin,
        string $siteCode,
        ?int $periodId,
        ?int $playerId,
    ): array {
        $query = DB::table('payment_records as pr')
            ->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
            ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
            ->join('players as p', function ($join): void {
                $join->on('p.id', '=', 'sb.owner_id')
                    ->where('sb.owner_type', '=', 'player');
            })
            ->where('p.site_code', $siteCode)
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->select([
                'pr.id',
                'pr.amount',
                'pr.method',
                'pr.status',
                'pr.created_at',
                'pr.settlement_bill_id',
                'sb.status as bill_status',
                'sb.bill_type',
                'sb.unpaid_amount',
                'p.id as player_id',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.auth_source',
                'p.funding_mode',
                'p.default_currency',
                'da.id as direct_agent_id',
                'da.code as direct_agent_code',
                'da.name as direct_agent_name',
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
            ])
            ->orderByDesc('pr.id');

        if ($periodId !== null && $periodId > 0) {
            $query->where('sb.settlement_period_id', $periodId);
        }

        if ($playerId !== null && $playerId > 0) {
            $query->where('p.id', $playerId);
        }

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');

        $siteIds = $admin->accessibleAdminSiteIds();
        if ($siteIds !== null) {
            if ($siteIds === []) {
                return [];
            }
            $query->whereIn('sp.admin_site_id', $siteIds);
        }

        return $query->limit(300)->get()->all();
    }

    /**
     * @param  list<int>  $ids
     * @return list<object>
     */
    private function fetchPaymentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
    {
        if ($ids === []) {
            return [];
        }

        $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
        if ($adminSiteId <= 0) {
            return [];
        }

        $query = DB::table('payment_records as pr')
            ->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
            ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
            ->where('sp.admin_site_id', $adminSiteId)
            ->leftJoin('players as p', function ($join): void {
                $join->on('p.id', '=', 'sb.owner_id')
                    ->where('sb.owner_type', '=', 'player');
            })
            ->leftJoin('agent_nodes as owner_an', function ($join): void {
                $join->on('owner_an.id', '=', 'sb.owner_id')
                    ->where('sb.owner_type', '=', 'agent');
            })
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->whereIn('pr.id', $ids)
            ->select([
                'pr.id',
                'pr.amount',
                'pr.method',
                'pr.status',
                'pr.created_at',
                'pr.settlement_bill_id',
                'sb.status as bill_status',
                'sb.bill_type',
                'sb.unpaid_amount',
                'p.id as player_id',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.auth_source',
                'p.funding_mode',
                'p.default_currency',
                DB::raw('COALESCE(da.id, owner_an.id) as direct_agent_id'),
                DB::raw('COALESCE(da.code, owner_an.code) as direct_agent_code'),
                DB::raw('COALESCE(da.name, owner_an.name) as direct_agent_name'),
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
            ])
            ->selectRaw('COALESCE(p.site_code, ?) as site_code', [$siteCode]);

        AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
        $this->applyLedgerSiteScope($query, $admin, 'sp');

        return $query->get()->all();
    }

    /**
     * @return list<object>
     */
    private function fetchAdjustmentRows(
        AdminUser $admin,
        string $siteCode,
        ?int $periodId,
        ?int $playerId,
    ): array {
        $query = DB::table('settlement_adjustments as sa')
            ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
            ->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
            ->leftJoin('players as p', function ($join): void {
                $join->on('p.id', '=', 'sb.owner_id')
                    ->where('sb.owner_type', '=', 'player');
            })
            ->where('p.site_code', $siteCode)
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->select([
                'sa.id',
                'sa.amount',
                'sa.adjustment_type',
                'sa.reason',
                'sa.created_at',
                'sa.original_bill_id as settlement_bill_id',
                'sb.status as bill_status',
                'sb.bill_type',
                'sb.unpaid_amount',
                'p.id as player_id',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.auth_source',
                'p.funding_mode',
                'p.default_currency',
                'da.id as direct_agent_id',
                'da.code as direct_agent_code',
                'da.name as direct_agent_name',
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
            ])
            ->orderByDesc('sa.id');

        if ($periodId !== null && $periodId > 0) {
            $query->where('sa.settlement_period_id', $periodId);
        }

        if ($playerId !== null && $playerId > 0) {
            $query->where('p.id', $playerId);
        }

        AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');

        $siteIds = $admin->accessibleAdminSiteIds();
        if ($siteIds !== null) {
            if ($siteIds === []) {
                return [];
            }
            $query->whereIn('sp.admin_site_id', $siteIds);
        }

        return $query->limit(300)->get()->all();
    }

    /**
     * @param  list<int>  $ids
     * @return list<object>
     */
    private function fetchAdjustmentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
    {
        if ($ids === []) {
            return [];
        }

        $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
        if ($adminSiteId <= 0) {
            return [];
        }

        $query = DB::table('settlement_adjustments as sa')
            ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
            ->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
            ->where('sp.admin_site_id', $adminSiteId)
            ->leftJoin('players as p', function ($join): void {
                $join->on('p.id', '=', 'sb.owner_id')
                    ->where('sb.owner_type', '=', 'player');
            })
            ->whereIn('sa.id', $ids)
            ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
            ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
            ->select([
                'sa.id',
                'sa.amount',
                'sa.adjustment_type',
                'sa.reason',
                'sa.created_at',
                'sa.original_bill_id as settlement_bill_id',
                'sb.status as bill_status',
                'sb.bill_type',
                'sb.unpaid_amount',
                'p.id as player_id',
                'p.site_player_id',
                'p.username',
                'p.nickname',
                'p.agent_node_id',
                'p.auth_source',
                'p.funding_mode',
                'p.default_currency',
                DB::raw('COALESCE(da.id, owner_an.id) as direct_agent_id'),
                DB::raw('COALESCE(da.code, owner_an.code) as direct_agent_code'),
                DB::raw('COALESCE(da.name, owner_an.name) as direct_agent_name'),
                'pa.id as parent_agent_id',
                'pa.code as parent_agent_code',
                'pa.name as parent_agent_name',
            ])
            ->selectRaw('COALESCE(p.site_code, ?) as site_code', [$siteCode]);

        $query->leftJoin('agent_nodes as owner_an', function ($join): void {
            $join->on('owner_an.id', '=', 'sb.owner_id')
                ->where('sb.owner_type', '=', 'agent');
        });

        AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
        $this->applyLedgerSiteScope($query, $admin, 'sp');

        return $query->get()->all();
    }

    /**
     * @return array<string, mixed>
     */
    /**
     * @param  array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}>  $ticketRefs
     */
    private function formatCreditEntry(object $row, ?object $bill, array $ticketRefs = []): array
    {
        $amount = (int) $row->amount;
        $billId = $bill !== null ? (int) $bill->id : null;
        $ticketRef = $this->resolveTicketRef($row, $ticketRefs);

        return array_merge(
            $this->baseRow(
                entryKind: 'credit',
                entryId: (int) $row->id,
                txnPrefix: 'CL',
                playerId: (int) ($row->player_id ?? 0),
                row: $row,
                bizType: (string) $row->reason,
                signedAmount: $amount,
                createdAt: $row->created_at,
                ledgerSource: 'credit_ledger',
                settlementBillId: $billId,
                billStatus: $bill !== null ? (string) $bill->status : null,
                billType: $bill !== null ? (string) $bill->bill_type : null,
                billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null,
                refType: isset($row->ref_type) ? (string) $row->ref_type : null,
                refId: isset($row->ref_id) ? (int) $row->ref_id : null,
            ),
            $this->partyFieldsFromRow($row),
            $ticketRef,
        );
    }

    /**
     * @return array<string, mixed>
     */
    private function formatPaymentEntry(object $row): array
    {
        $amount = (int) $row->amount;
        $method = trim((string) ($row->method ?? ''));
        $methodLabel = $method !== '' && ! ctype_digit($method) ? ' · '.$method : '';

        return array_merge(
            $this->baseRow(
                entryKind: 'payment',
                entryId: (int) $row->id,
                txnPrefix: 'PAY',
                playerId: (int) ($row->player_id ?? 0),
                row: $row,
                bizType: 'payment_record',
                signedAmount: $amount,
                createdAt: $row->created_at,
                ledgerSource: 'payment_record',
                settlementBillId: (int) $row->settlement_bill_id,
                billStatus: (string) ($row->bill_status ?? ''),
                billType: (string) ($row->bill_type ?? ''),
                billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
                refLabel: 'bill#'.$row->settlement_bill_id.$methodLabel,
            ),
            $this->partyFieldsFromRow($row),
        );
    }

    /**
     * @return array<string, mixed>
     */
    private function formatAdjustmentEntry(object $row): array
    {
        $amount = (int) $row->amount;
        $type = (string) $row->adjustment_type;

        return array_merge(
            $this->baseRow(
                entryKind: 'adjustment',
                entryId: (int) $row->id,
                txnPrefix: 'ADJ',
                playerId: (int) ($row->player_id ?? 0),
                row: $row,
                bizType: $type,
                signedAmount: $amount,
                createdAt: $row->created_at,
                ledgerSource: 'settlement_adjustment',
                settlementBillId: (int) $row->settlement_bill_id,
                billStatus: (string) ($row->bill_status ?? ''),
                billType: (string) ($row->bill_type ?? ''),
                billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
                refLabel: $row->reason !== null && $row->reason !== ''
                    ? (string) $row->reason
                    : 'bill#'.$row->settlement_bill_id,
            ),
            $this->partyFieldsFromRow($row),
        );
    }

    /**
     * @param  array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}>  $ticketRefs
     * @return array{play_code: string|null, draw_no: string|null, ticket_item_id: int|null}
     */
    private function resolveTicketRef(object $row, array $ticketRefs): array
    {
        if ((string) ($row->ref_type ?? '') !== 'ticket_item') {
            return ['play_code' => null, 'draw_no' => null, 'ticket_item_id' => null];
        }

        $ticketId = (int) ($row->ref_id ?? 0);
        $ref = $ticketRefs[$ticketId] ?? null;

        return [
            'play_code' => $ref['play_code'] ?? null,
            'draw_no' => $ref['draw_no'] ?? null,
            'ticket_item_id' => $ticketId > 0 ? $ticketId : null,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function partyFieldsFromRow(object $row): array
    {
        $directId = (int) ($row->direct_agent_id ?? $row->agent_node_id ?? 0);
        $parentId = (int) ($row->parent_agent_id ?? 0);

        return [
            'direct_agent_id' => $directId > 0 ? $directId : null,
            'direct_agent_label' => $directId > 0
                ? $this->partyEnrichment->formatAgent((object) [
                    'name' => $row->direct_agent_name ?? null,
                    'code' => $row->direct_agent_code ?? null,
                ], $directId)
                : null,
            'parent_agent_id' => $parentId > 0 ? $parentId : null,
            'parent_agent_label' => $parentId > 0
                ? $this->partyEnrichment->formatAgent((object) [
                    'name' => $row->parent_agent_name ?? null,
                    'code' => $row->parent_agent_code ?? null,
                ], $parentId)
                : null,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function baseRow(
        string $entryKind,
        int $entryId,
        string $txnPrefix,
        int $playerId,
        object $row,
        string $bizType,
        int $signedAmount,
        mixed $createdAt,
        string $ledgerSource,
        ?int $settlementBillId,
        ?string $billStatus,
        ?string $billType,
        ?int $billUnpaid,
        ?string $refLabel = null,
        ?string $refType = null,
        ?int $refId = null,
    ): array {
        $amountAbs = abs($signedAmount);
        $currency = (string) ($row->default_currency ?? '');

        return [
            'entry_kind' => $entryKind,
            'id' => $entryId,
            'row_key' => $entryKind.'-'.$entryId,
            'txn_no' => $txnPrefix.'-'.$entryId,
            'player_id' => $playerId,
            'site_code' => $row->site_code ?? null,
            'site_player_id' => $row->site_player_id ?? null,
            'username' => $row->username ?? null,
            'nickname' => $row->nickname ?? null,
            'biz_type' => $bizType,
            'ref_type' => $refType,
            'ref_id' => $refId,
            'biz_no' => $refLabel ?? $this->creditRefLabel($row),
            'direction' => $signedAmount >= 0 ? 1 : 2,
            'amount' => $amountAbs,
            'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
            'signed_amount' => $signedAmount,
            'currency_code' => $currency,
            'status' => 'posted',
            'created_at' => $createdAt !== null ? Carbon::parse($createdAt)->toIso8601String() : null,
            'ledger_source' => $ledgerSource,
            'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
            'auth_source' => $row->auth_source ?? null,
            'settlement_bill_id' => $settlementBillId,
            'bill_status' => $billStatus,
            'bill_type' => $billType,
            'bill_unpaid_amount' => $billUnpaid,
            'available_actions' => $this->resolveActions(
                $entryKind,
                $settlementBillId,
                $billStatus,
                $billType,
                $billUnpaid,
            ),
        ];
    }

    /**
     * @return list<string>
     */
    private function resolveActions(
        string $entryKind,
        ?int $billId,
        ?string $billStatus,
        ?string $billType,
        ?int $billUnpaid,
    ): array {
        $actions = ['view_player'];

        if ($billId === null || $billId <= 0) {
            return $actions;
        }

        $actions[] = 'view_bill';

        if ($billStatus === 'pending_confirm') {
            $actions[] = 'confirm';
        }

        if ($billStatus !== null
            && in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
            && ($billUnpaid ?? 0) > 0) {
            $actions[] = 'payment';
        }

        if ($billStatus !== null
            && in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
            && ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
            $actions[] = 'adjustment';
            $actions[] = 'reversal';
        }

        if ($billStatus !== null
            && in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
            && ($billUnpaid ?? 0) > 0
            && ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
            $actions[] = 'bad_debt';
        }

        return array_values(array_unique($actions));
    }

    private function creditRefLabel(object $row): ?string
    {
        if (! isset($row->ref_type) || $row->ref_type === null || $row->ref_id === null) {
            return null;
        }

        return (string) $row->ref_type.'#'.$row->ref_id;
    }
}
