<?php

require_once __DIR__ . '/XuiV3ApiClient.php';
require_once __DIR__ . '/PlanInboundIds.php';

/**
 * High-level 3x-ui v3 operations used by the bot.
 */
class XuiV3Service
{
    /** @var XuiV3ApiClient */
    private $client;

    /** @var array<string, mixed> */
    private $config;

    /**
     * @param array<string, mixed> $serverConfig row from server_config
     */
    public function __construct(array $serverConfig)
    {
        $this->config = XuiV3Config::forServer($serverConfig);
        $this->client = new XuiV3ApiClient($this->config);
    }

    public function getConfig(): array
    {
        return $this->config;
    }

    public function getApiClient(): XuiV3ApiClient
    {
        return $this->client;
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    /** @param int|int[] $inboundIdOrIds */
    public function createClient(
        string $email,
        $inboundIdOrIds,
        int $totalBytes,
        int $expiryTimeMs,
        int $limitIp = 1,
        int $tgId = 0,
        string $comment = '',
        ?string $subId = null
    ) {
        $subId = $subId ?: RandomString(16);
        $inboundIds = $this->resolveTargetInboundIds($inboundIdOrIds);
        if (empty($inboundIds)) {
            return (object) ['success' => false, 'msg' => 'No valid inbound selected for this plan', 'obj' => null];
        }

        $payload = [
            'client' => [
                'email' => $email,
                'subId' => $subId,
                'totalGB' => $totalBytes,
                'expiryTime' => $expiryTimeMs,
                'tgId' => $tgId,
                'limitIp' => $limitIp,
                'enable' => true,
                'comment' => $comment,
            ],
            'inboundIds' => $inboundIds,
        ];

        if ($this->waitForClient($email, 2, 150000) !== null) {
            return $this->finalizeClientSetup(
                $email,
                $inboundIds,
                $totalBytes,
                $expiryTimeMs,
                $limitIp,
                $tgId,
                $comment,
                $this->getClientAttachedInboundIds($email),
                false
            );
        }

        $response = $this->client->addClient($payload);
        if (!$response->success) {
            $msg = (string) ($response->msg ?? '');
            $existing = $this->waitForClient($email, 4, 200000);
            if ($existing !== null
                || (function_exists('isPanelDuplicateEmailError') && isPanelDuplicateEmailError($msg))
            ) {
                return $this->finalizeClientSetup(
                    $email,
                    $inboundIds,
                    $totalBytes,
                    $expiryTimeMs,
                    $limitIp,
                    $tgId,
                    $comment,
                    $this->getClientAttachedInboundIds($email),
                    false
                );
            }
            return $this->toLegacyResponse($response);
        }

        if ($this->waitForClient($email, 8, 250000) === null) {
            return (object) [
                'success' => false,
                'msg' => 'Client add reported success but panel record is not visible yet. Please retry approval.',
                'obj' => null,
            ];
        }

        return $this->finalizeClientSetup(
            $email,
            $inboundIds,
            $totalBytes,
            $expiryTimeMs,
            $limitIp,
            $tgId,
            $comment,
            $this->getClientAttachedInboundIds($email),
            true
        );
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed,http_code:int}|null
     */
    private function waitForClient(string $email, int $attempts = 6, int $sleepMicros = 250000): ?object
    {
        $email = trim($email);
        if ($email === '') {
            return null;
        }
        for ($i = 0; $i < $attempts; $i++) {
            $response = $this->client->getClient($email);
            if ($response->success && is_object($response->obj)) {
                return $response;
            }
            if ($i < $attempts - 1) {
                usleep($sleepMicros);
            }
        }
        return null;
    }

    /** @return int[] */
    private function parseInboundIdsFromPayload($obj): array
    {
        if (!is_object($obj) && !is_array($obj)) {
            return [];
        }
        $sources = [$obj];
        if (is_object($obj) && isset($obj->client)) {
            $sources[] = $obj->client;
        }
        foreach ($sources as $source) {
            if (is_object($source)) {
                foreach (['inboundIds', 'inbound_ids', 'InboundIds'] as $key) {
                    if (isset($source->$key)) {
                        $ids = $this->normalizeInboundIdList($source->$key);
                        if (!empty($ids)) {
                            return $ids;
                        }
                    }
                }
            }
        }
        return [];
    }

    /** @return int[] */
    private function normalizeInboundIdList($value): array
    {
        if (is_string($value)) {
            $decoded = json_decode($value, true);
            if (is_array($decoded)) {
                $value = $decoded;
            }
        }
        if (!is_array($value)) {
            return [];
        }
        return array_values(array_unique(array_filter(array_map('intval', $value))));
    }

    /** @return int[] */
    public function getClientAttachedInboundIds(string $email): array
    {
        $response = $this->client->getClient($email);
        if (!$response->success || !is_object($response->obj)) {
            return [];
        }
        return $this->parseInboundIdsFromPayload($response->obj);
    }

    /** @param int[] $expectedInboundIds @return object{success:bool,msg:string,obj:mixed} */
    public function attachMissingInboundsOnce(string $email, array $expectedInboundIds)
    {
        $expected = array_values(array_unique(array_filter(array_map('intval', $expectedInboundIds))));
        if (empty($expected)) {
            return (object) ['success' => true, 'msg' => 'ok', 'obj' => null];
        }

        $attached = $this->getClientAttachedInboundIds($email);
        $missing = array_values(array_diff($expected, $attached));
        if (empty($missing)) {
            return (object) ['success' => true, 'msg' => 'ok', 'obj' => null];
        }

        $result = $this->client->attachClient($email, $missing);
        if ($result->success) {
            return (object) ['success' => true, 'msg' => 'ok', 'obj' => null];
        }

        $msg = (string) ($result->msg ?? '');
        if (function_exists('isPanelAttachAlreadyExistsError') && isPanelAttachAlreadyExistsError($msg)) {
            return (object) ['success' => true, 'msg' => 'ok', 'obj' => null];
        }

        return (object) ['success' => false, 'msg' => $msg !== '' ? $msg : 'attach failed', 'obj' => null];
    }

    public function isClientReadyForDelivery(string $email): bool
    {
        $response = $this->client->getClient($email);
        if (!$response->success || !is_object($response->obj)) {
            return false;
        }

        $client = is_object($response->obj->client ?? null) ? $response->obj->client : $response->obj;
        if (!is_object($client)) {
            return false;
        }

        if (!empty($client->subId)) {
            return true;
        }

        $links = $this->client->getClientLinks($email);
        if ($links->success) {
            return true;
        }

        return !empty($client->enable);
    }

    /** @param int[] $expectedInboundIds @return object{success:bool,msg:string,obj:mixed} */
    public function ensureClientAttachedToInbounds(string $email, array $expectedInboundIds, array $sessionAttached = [])
    {
        unset($sessionAttached);
        return $this->attachMissingInboundsOnce($email, $expectedInboundIds);
    }

    /**
     * @param int[] $alreadyAttached
     * @return object{success:bool,msg:string,obj:mixed}
     */
    private function finalizeClientSetup(
        string $email,
        array $inboundIds,
        int $totalBytes,
        int $expiryTimeMs,
        int $limitIp,
        int $tgId,
        string $comment,
        array $alreadyAttached = [],
        bool $skipUpdate = false
    ) {
        if ($skipUpdate) {
            $response = $this->waitForClient($email, 6, 200000);
            if ($response !== null) {
                $obj = $response->obj;
                $client = is_object($obj) && isset($obj->client) ? $obj->client : $obj;
                if (is_object($client)) {
                    $attachResult = $this->attachMissingInboundsOnce($email, $inboundIds);
                    if (!$attachResult->success && !$this->isClientReadyForDelivery($email)) {
                        return $attachResult;
                    }
                    return (object) ['success' => true, 'msg' => 'ok', 'obj' => $client];
                }
            }
            return (object) [
                'success' => false,
                'msg' => 'Client was not found on panel after create',
                'obj' => null,
            ];
        }

        if (!$skipUpdate) {
            $fields = [
                'totalGB' => $totalBytes,
                'expiryTime' => $expiryTimeMs,
                'limitIp' => $limitIp,
                'enable' => true,
            ];
            if ($comment !== '') {
                $fields['comment'] = $comment;
            }
            if ($tgId > 0) {
                $fields['tgId'] = $tgId;
            }
            $this->updateClientByEmail($email, $fields);
        }

        $attachResult = $this->attachMissingInboundsOnce($email, $inboundIds);
        if (!$attachResult->success && !$this->isClientReadyForDelivery($email)) {
            return $attachResult;
        }

        $response = $this->client->getClient($email);
        if (!$response->success) {
            return $this->toLegacyResponse($response);
        }
        $obj = $response->obj;
        $client = is_object($obj) && isset($obj->client) ? $obj->client : $obj;
        if (!is_object($client)) {
            return (object) ['success' => false, 'msg' => 'Client created but payload empty', 'obj' => null];
        }

        return (object) ['success' => true, 'msg' => 'ok', 'obj' => $client];
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function verifyClient(
        string $email,
        $expectedInboundIds,
        int $expectedTotalBytes,
        int $expectedExpiryMs,
        ?string $expectedSubId = null
    ) {
        $response = $this->client->getClient($email);
        if (!$response->success) {
            return $this->toLegacyResponse($response);
        }

        $obj = $response->obj;
        $client = is_object($obj) && isset($obj->client) ? $obj->client : $obj;
        $inboundIds = [];
        if (is_object($obj) && isset($obj->inboundIds) && is_array($obj->inboundIds)) {
            $inboundIds = (array) $obj->inboundIds;
        } elseif (is_object($client) && isset($client->inboundIds) && is_array($client->inboundIds)) {
            $inboundIds = (array) $client->inboundIds;
        }
        $expected = is_array($expectedInboundIds)
            ? array_values(array_filter(array_map('intval', $expectedInboundIds)))
            : $this->resolveTargetInboundIds((int) $expectedInboundIds);

        if (!is_object($client)) {
            return (object) ['success' => false, 'msg' => 'Client verification failed: empty payload', 'obj' => null];
        }

        if ($expectedSubId !== null && isset($client->subId) && (string) $client->subId !== (string) $expectedSubId) {
            return (object) ['success' => false, 'msg' => 'Client verification failed: subId mismatch', 'obj' => $client];
        }

        $attached = array_map('intval', $inboundIds);
        foreach ($expected as $id) {
            if (!in_array($id, $attached, true)) {
                return (object) ['success' => false, 'msg' => 'Client verification failed: inbound not attached', 'obj' => $client];
            }
        }

        if ($expectedTotalBytes > 0 && isset($client->totalGB) && (int) $client->totalGB !== $expectedTotalBytes) {
            return (object) ['success' => false, 'msg' => 'Client verification failed: totalGB mismatch', 'obj' => $client];
        }

        if ($expectedExpiryMs > 0 && isset($client->expiryTime) && (int) $client->expiryTime !== $expectedExpiryMs) {
            return (object) ['success' => false, 'msg' => 'Client verification failed: expiryTime mismatch', 'obj' => $client];
        }

        if (isset($client->enable) && !$client->enable) {
            return (object) ['success' => false, 'msg' => 'Client verification failed: client disabled', 'obj' => $client];
        }

        return (object) ['success' => true, 'msg' => 'verified', 'obj' => $client];
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function updateClientByEmail(string $email, array $fields)
    {
        $current = $this->client->getClient($email);
        if (!$current->success || !is_object($current->obj)) {
            return $this->toLegacyResponse($current);
        }

        $existing = is_object($current->obj->client ?? null) ? (array) $current->obj->client : (array) $current->obj;
        $payload = array_merge($existing, $fields);
        if (!isset($fields['email'])) {
            $payload['email'] = $email;
        }

        $response = $this->client->updateClient($email, $payload);
        return $this->toLegacyResponse($response, $response->obj);
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function renewOrExtendClient(string $email, int $volumeGb, int $days, string $editType = null)
    {
        $current = $this->client->getClient($email);
        if (!$current->success) {
            return $this->toLegacyResponse($current);
        }

        $client = is_object($current->obj->client ?? null) ? $current->obj->client : $current->obj;
        $totalBytes = (int) ($client->totalGB ?? 0);
        $expiryMs = (int) ($client->expiryTime ?? 0);

        if ($days != 0) {
            $nowMs = (int) floor(microtime(true) * 1000);
            $extendMs = 86400000 * $days;
            if ($editType === 'renew') {
                $expiryMs = $nowMs + $extendMs;
            } else {
                $expiryMs = ($nowMs > $expiryMs) ? $nowMs + $extendMs : $expiryMs + $extendMs;
            }
        }

        if ($volumeGb != 0) {
            $extendBytes = (int) floor($volumeGb * 1073741824);
            if ($editType === 'renew') {
                $totalBytes = $extendBytes;
                $this->client->resetClientTraffic($email);
            } else {
                $traffic = $this->client->getClientTraffic($email);
                $up = 0;
                $down = 0;
                if ($traffic->success && is_object($traffic->obj)) {
                    $up = (int) ($traffic->obj->up ?? 0);
                    $down = (int) ($traffic->obj->down ?? 0);
                }
                $left = $totalBytes - $up - $down;
                $totalBytes = ($left > 0) ? $totalBytes + $extendBytes : $extendBytes;
            }
        }

        $fields = [
            'totalGB' => $totalBytes,
            'expiryTime' => $expiryMs,
            'enable' => true,
        ];

        $response = $this->updateClientByEmail($email, $fields);
        if ($response->success) {
            $this->client->clearClientIps($email);
        }
        return $response;
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function toggleClient(string $email)
    {
        $current = $this->client->getClient($email);
        if (!$current->success) {
            return $this->toLegacyResponse($current);
        }
        $client = is_object($current->obj->client ?? null) ? $current->obj->client : $current->obj;
        $enable = !(bool) ($client->enable ?? true);
        return $this->updateClientByEmail($email, ['enable' => $enable]);
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function deleteClientByEmail(string $email)
    {
        return $this->toLegacyResponse($this->client->deleteClient($email, false));
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function resetTraffic(string $email)
    {
        return $this->toLegacyResponse($this->client->resetClientTraffic($email));
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function clearIps(string $email)
    {
        return $this->toLegacyResponse($this->client->clearClientIps($email));
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function listInbounds()
    {
        $response = $this->client->listInbounds();
        if ($response->success && is_array($response->obj)) {
            $response->obj = self::normalizeInboundListForLegacy($response->obj);
        }
        return $this->toLegacyResponse($response);
    }

    /**
     * @return object|null client object or null
     */
    public function findClientInInbound(int $inboundId, string $uuidOrPassword)
    {
        $list = $this->listInbounds();
        if (!$list->success || !is_array($list->obj)) {
            return null;
        }

        foreach ($list->obj as $row) {
            if ((int) ($row->id ?? 0) !== $inboundId) {
                continue;
            }
            $settings = is_string($row->settings ?? null) ? json_decode($row->settings) : ($row->settings ?? null);
            if (!is_object($settings) || !isset($settings->clients)) {
                continue;
            }
            foreach ($settings->clients as $client) {
                $id = $client->id ?? $client->password ?? null;
                if ((string) $id === (string) $uuidOrPassword) {
                    return $client;
                }
            }
        }
        return null;
    }

    /**
     * Resolve remark/email from inbound + uuid.
     */
    public function resolveEmailFromInbound(int $inboundId, string $uuidOrPassword): ?string
    {
        $client = $this->findClientInInbound($inboundId, $uuidOrPassword);
        return is_object($client) && !empty($client->email) ? (string) $client->email : null;
    }

    public function buildSubscriptionUrl(string $subId): string
    {
        $base = rtrim((string) $this->config['subscription_base_url'], '/');
        $path = (string) $this->config['sub_path'];
        return $base . $path . rawurlencode($subId);
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function getClientSubId(string $email): ?string
    {
        $response = $this->client->getClient($email);
        if (!$response->success) {
            return null;
        }
        $client = is_object($response->obj->client ?? null) ? $response->obj->client : $response->obj;
        return is_object($client) && !empty($client->subId) ? (string) $client->subId : null;
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function setInboundEnable(int $inboundId, bool $enable)
    {
        return $this->toLegacyResponse($this->client->setInboundEnable($inboundId, $enable));
    }

    /**
     * Update inbound from legacy $dataArr built by config.php helpers.
     * @param array<string, mixed> $dataArr
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function updateInboundFromLegacyData(int $inboundId, array $dataArr)
    {
        $payload = $this->normalizeInboundPayload($dataArr);
        return $this->toLegacyResponse($this->client->updateInbound($inboundId, $payload));
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function editInboundRemarkByUuid(string $uuid, string $newRemark)
    {
        $found = $this->findByUuid($uuid);
        if (!$found) {
            return (object) ['success' => false, 'msg' => 'Inbound not found', 'obj' => null];
        }
        $payload = $this->inboundRowToUpdateArray($found['row']);
        $payload['remark'] = $newRemark;
        return $this->updateDedicatedInbound((int) $found['inbound_id'], $payload);
    }

    /**
     * Dedicated inbound UUID rotation (same as renewClientSecret for uuid in inbound).
     * @return object{success:bool,msg:string,obj:mixed,newUuid?:string}
     */
    public function renewInboundUuidByUuid(string $uuid)
    {
        $found = $this->findByUuid($uuid);
        if (!$found) {
            return (object) ['success' => false, 'msg' => 'Inbound not found', 'obj' => null];
        }
        return $this->renewClientSecret((int) $found['inbound_id'], $uuid);
    }

    /**
     * Normalize v3 inbound list objects for legacy bot code (settings as JSON strings).
     * @param mixed $list
     * @return mixed
     */
    public static function normalizeInboundListForLegacy($list)
    {
        if (!is_array($list)) {
            return $list;
        }
        foreach ($list as $row) {
            if (!is_object($row)) {
                continue;
            }
            foreach (['settings', 'streamSettings', 'sniffing'] as $field) {
                if (!isset($row->$field)) {
                    continue;
                }
                if (is_object($row->$field) || is_array($row->$field)) {
                    $row->$field = json_encode($row->$field, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                }
            }
        }
        return $list;
    }

    /**
     * @return array<string, mixed>
     */
    public function inboundRowToUpdateArray($row): array
    {
        $data = [
            'up' => (int) ($row->up ?? 0),
            'down' => (int) ($row->down ?? 0),
            'total' => (int) ($row->total ?? 0),
            'remark' => (string) ($row->remark ?? ''),
            'enable' => ($row->enable ?? true) === true || ($row->enable ?? '') === 'true',
            'expiryTime' => (int) ($row->expiryTime ?? 0),
            'listen' => (string) ($row->listen ?? ''),
            'port' => (int) ($row->port ?? 0),
            'protocol' => (string) ($row->protocol ?? ''),
            'settings' => $row->settings ?? '{}',
            'streamSettings' => $row->streamSettings ?? '{}',
            'sniffing' => $row->sniffing ?? '{}',
        ];
        return $this->normalizeInboundPayload($data);
    }

    /**
     * Find inbound + client by uuid/password (dedicated or shared inbound).
     * @return array{row:object,client:object,email:string,inbound_id:int}|null
     */
    public function findByUuid(string $uuid): ?array
    {
        $list = $this->listInbounds();
        if (!$list->success || !is_array($list->obj)) {
            return null;
        }

        foreach ($list->obj as $row) {
            $settings = is_string($row->settings ?? null) ? json_decode($row->settings) : ($row->settings ?? null);
            if (!is_object($settings) || empty($settings->clients)) {
                continue;
            }
            foreach ($settings->clients as $client) {
                $id = $client->id ?? $client->password ?? null;
                if ((string) $id === (string) $uuid && !empty($client->email)) {
                    return [
                        'row' => $row,
                        'client' => $client,
                        'email' => (string) $client->email,
                        'inbound_id' => (int) $row->id,
                    ];
                }
            }
            $c0 = $settings->clients[0] ?? null;
            if ($c0 && ((string) ($c0->id ?? '') === (string) $uuid || (string) ($c0->password ?? '') === (string) $uuid)) {
                return [
                    'row' => $row,
                    'client' => $c0,
                    'email' => (string) ($c0->email ?? ''),
                    'inbound_id' => (int) $row->id,
                ];
            }
        }
        return null;
    }

    /** @return array<int, object> */
    private function listAllClientRows(): array
    {
        $response = $this->client->listClients();
        if (!$response->success || !is_array($response->obj)) {
            return [];
        }
        return $response->obj;
    }

    /**
     * @return array<string, mixed>|null
     */
    public function findClientByUuid(string $uuid): ?array
    {
        $uuid = trim($uuid);
        if ($uuid === '') {
            return null;
        }
        foreach ($this->listAllClientRows() as $row) {
            $clientUuid = (string) ($row->uuid ?? $row->id ?? '');
            if ($clientUuid !== '' && $clientUuid === $uuid) {
                return $this->normalizeLookupRow($row);
            }
        }
        $found = $this->findByUuid($uuid);
        if ($found && !empty($found['email'])) {
            return $this->resolveClientByEmail($found['email']);
        }
        return null;
    }

    /**
     * @return array<string, mixed>|null
     */
    public function findClientBySubId(string $subId): ?array
    {
        $subId = trim($subId);
        if ($subId === '') {
            return null;
        }
        foreach ($this->listAllClientRows() as $row) {
            if (!empty($row->subId) && (string) $row->subId === $subId) {
                return $this->normalizeLookupRow($row);
            }
        }
        $list = $this->listInbounds();
        if ($list->success && is_array($list->obj)) {
            foreach ($list->obj as $inboundRow) {
                $settings = is_string($inboundRow->settings ?? null)
                    ? json_decode($inboundRow->settings)
                    : ($inboundRow->settings ?? null);
                if (!is_object($settings) || empty($settings->clients)) {
                    continue;
                }
                foreach ($settings->clients as $client) {
                    if (!empty($client->subId) && (string) $client->subId === $subId && !empty($client->email)) {
                        return $this->resolveClientByEmail((string) $client->email);
                    }
                }
            }
        }
        $subLinks = $this->client->getClientSubLinks($subId);
        if (!$subLinks->success) {
            return null;
        }
        $paged = $this->client->get('/panel/api/clients/list/paged', [
            'page' => 1,
            'pageSize' => 50,
            'search' => $subId,
            'filter' => '',
            'protocol' => '',
            'sort' => 'email',
        ]);
        if ($paged->success && is_object($paged->obj) && !empty($paged->obj->items)) {
            foreach ($paged->obj->items as $item) {
                $email = is_object($item) ? (string) ($item->email ?? '') : '';
                if ($email !== '') {
                    $resolved = $this->resolveClientByEmail($email);
                    if ($resolved && ($resolved['sub_id'] ?? '') === $subId) {
                        return $resolved;
                    }
                }
            }
        }
        return null;
    }

    /**
     * @return array<string, mixed>|null
     */
    public function resolveClientByEmail(string $email): ?array
    {
        $email = trim($email);
        if ($email === '') {
            return null;
        }
        $response = $this->client->getClient($email);
        if (!$response->success) {
            return null;
        }
        $client = is_object($response->obj->client ?? null) ? $response->obj->client : $response->obj;
        if (!is_object($client)) {
            return null;
        }
        $traffic = $this->client->getClientTraffic($email);
        $inboundIds = [];
        if (is_object($response->obj) && isset($response->obj->inboundIds)) {
            foreach ((array) $response->obj->inboundIds as $id) {
                $inboundIds[] = (int) $id;
            }
        }
        if (empty($inboundIds) && !empty($client->inboundId)) {
            $inboundIds[] = (int) $client->inboundId;
        }
        return $this->normalizeLookupParts(
            $email,
            $client,
            ($traffic->success && is_object($traffic->obj)) ? $traffic->obj : null,
            $inboundIds
        );
    }

    /**
     * @return array<string, mixed>
     */
    private function normalizeLookupRow(object $row): array
    {
        return $this->normalizeLookupParts(
            (string) ($row->email ?? ''),
            $row,
            $row->traffic ?? null,
            array_values(array_filter(array_map('intval', (array) ($row->inboundIds ?? []))))
        );
    }

    /**
     * @return array<string, mixed>
     */
    private function normalizeLookupParts(string $email, object $client, $traffic, array $inboundIds): array
    {
        $up = is_object($traffic) ? (int) ($traffic->up ?? 0) : 0;
        $down = is_object($traffic) ? (int) ($traffic->down ?? 0) : 0;
        $totalBytes = (int) ($client->totalGB ?? $client->total ?? 0);
        $expiryMs = (int) ($client->expiryTime ?? 0);
        $clientEnabled = ($client->enable ?? true) === true || ($client->enable ?? '') === 'true';
        $trafficEnabled = !is_object($traffic) || (($traffic->enable ?? true) === true || ($traffic->enable ?? '') === 'true');
        return [
            'email' => $email,
            'client' => $client,
            'uuid' => (string) ($client->uuid ?? $client->id ?? $client->password ?? ''),
            'sub_id' => (string) ($client->subId ?? ''),
            'inbound_ids' => $inboundIds,
            'up' => $up,
            'down' => $down,
            'total_bytes' => $totalBytes,
            'expiry_ms' => $expiryMs,
            'enable' => $clientEnabled && $trafficEnabled,
        ];
    }

    /**
     * Dedicated inbound (inbound_id=0 flow): renew/extend by uuid.
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function renewDedicatedByUuid(string $uuid, int $volumeGb, int $days, $editType = null)
    {
        $found = $this->findByUuid($uuid);
        if (!$found || empty($found['email'])) {
            return (object) ['success' => false, 'msg' => 'Client not found', 'obj' => null];
        }
        return $this->renewOrExtendClient($found['email'], $volumeGb, $days, $editType);
    }

    /**
     * POST /panel/api/inbounds/add — dedicated inbound per user.
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function createDedicatedInbound(array $dataArr)
    {
        $payload = $this->normalizeInboundPayload($dataArr);
        if (!empty($payload['settings']['clients'][0])) {
            $payload['settings']['clients'][0]['totalGB'] = (int) ($payload['total'] ?? 0);
            $payload['settings']['clients'][0]['expiryTime'] = (int) ($payload['expiryTime'] ?? 0);
            $payload['settings']['clients'][0]['enable'] = true;
            if (empty($payload['settings']['clients'][0]['subId'])) {
                $payload['settings']['clients'][0]['subId'] = RandomString(16);
            }
        }
        return $this->toLegacyResponse($this->client->addInbound($payload));
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function updateDedicatedInbound(int $inboundId, array $dataArr)
    {
        $payload = $this->normalizeInboundPayload($dataArr);
        return $this->toLegacyResponse($this->client->updateInbound($inboundId, $payload));
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed}
     */
    public function deleteDedicatedInbound(int $inboundId)
    {
        return $this->toLegacyResponse($this->client->deleteInbound($inboundId));
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed,newUuid?:string}
     */
    public function renewClientSecret(int $inboundId, string $uuid)
    {
        $found = $this->findByUuid($uuid);
        if (!$found) {
            return (object) ['success' => false, 'msg' => 'Client not found', 'obj' => null];
        }

        $newUuidResp = $this->client->getNewUuid();
        if (!$newUuidResp->success) {
            return $this->toLegacyResponse($newUuidResp);
        }
        $newUuid = is_object($newUuidResp->obj) ? ($newUuidResp->obj->uuid ?? $newUuidResp->obj->obj ?? null) : $newUuidResp->obj;
        if (empty($newUuid)) {
            $newUuid = is_string($newUuidResp->obj) ? $newUuidResp->obj : generateRandomString(42, $found['row']->protocol ?? 'vless');
        }

        $current = $this->client->getClient($found['email']);
        if (!$current->success) {
            return $this->toLegacyResponse($current);
        }
        $existing = is_object($current->obj->client ?? null) ? (array) $current->obj->client : (array) $current->obj;
        $protocol = $found['row']->protocol ?? 'vless';
        if ($protocol === 'trojan') {
            $existing['password'] = $newUuid;
        } else {
            $existing['id'] = $newUuid;
        }
        $response = $this->updateClientByEmail($found['email'], $existing);
        $response->newUuid = $newUuid;
        return $response;
    }

    /** @return array<string, mixed> */
    private function normalizeInboundPayload(array $dataArr): array
    {
        foreach (['settings', 'streamSettings', 'sniffing'] as $key) {
            if (isset($dataArr[$key]) && is_string($dataArr[$key])) {
                $decoded = json_decode($dataArr[$key], true);
                $dataArr[$key] = is_array($decoded) ? $decoded : $dataArr[$key];
            }
        }
        $dataArr['enable'] = ($dataArr['enable'] ?? true) === 'true' || ($dataArr['enable'] ?? true) === true;
        $dataArr['total'] = (int) ($dataArr['total'] ?? 0);
        $dataArr['expiryTime'] = (int) ($dataArr['expiryTime'] ?? 0);
        $dataArr['port'] = (int) ($dataArr['port'] ?? 0);
        $dataArr['up'] = (int) ($dataArr['up'] ?? 0);
        $dataArr['down'] = (int) ($dataArr['down'] ?? 0);
        return $dataArr;
    }

    /** @return int[] */
    public function listLiveInboundIds(): array
    {
        $response = $this->client->getInboundOptions();
        if (!$response->success || !is_array($response->obj)) {
            return [];
        }
        $live = [];
        foreach ($response->obj as $row) {
            $id = (int) ($row->id ?? 0);
            if ($id > 0) {
                $live[] = $id;
            }
        }
        return $live;
    }

    /** @param int|int[] $inboundIdOrIds @return int[] */
    public function resolveTargetInboundIds($inboundIdOrIds): array
    {
        if (is_array($inboundIdOrIds)) {
            $ids = array_values(array_unique(array_filter(array_map('intval', $inboundIdOrIds))));
        } else {
            $single = (int) $inboundIdOrIds;
            $ids = $single > 0 ? [$single] : [];
        }
        if (empty($ids)) {
            return [];
        }

        $live = $this->listLiveInboundIds();
        if (!empty($live)) {
            return PlanInboundIds::intersectWithLive($ids, $live);
        }
        return $ids;
    }

    /**
     * @param object $apiResponse
     * @param mixed $objOverride
     * @return object{success:bool,msg:string,obj:mixed}
     */
    private function toLegacyResponse($apiResponse, $objOverride = null)
    {
        return (object) [
            'success' => (bool) $apiResponse->success,
            'msg' => (string) ($apiResponse->msg ?? ''),
            'obj' => $objOverride !== null ? $objOverride : ($apiResponse->obj ?? null),
        ];
    }
}
