<?php

require_once __DIR__ . '/XuiV3Config.php';

/**
 * HTTP client for 3x-ui /panel/api/* (OpenAPI 3.x).
 * Supports 3x-ui v3.0+ CSRF login and Bearer API tokens.
 */
class XuiV3ApiClient
{
    /** @var array<string, mixed> */
    private $config;

    /** @var string|null */
    private $sessionCookie;

    /** @var string */
    private $logPrefix = 'XuiV3';

    /** @var bool */
    private $useBearer = false;

    /** @var string */
    private $lastAuthError = '';

    /**
     * @param array<string, mixed> $config from XuiV3Config::forServer()
     */
    public function __construct(array $config)
    {
        $config['base_url'] = XuiV3Config::normalizePanelUrl((string) ($config['base_url'] ?? ''));
        $config['api_token'] = trim((string) ($config['api_token'] ?? ''));
        $this->config = $config;
        $this->useBearer = $this->resolveUseBearer();
    }

    public function getLastAuthError(): string
    {
        return $this->lastAuthError;
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed,http_code:int}
     */
    public function get(string $path, array $query = [], bool $retry = true)
    {
        return $this->request('GET', $path, null, $query, $retry);
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed,http_code:int}
     */
    public function post(string $path, $body = null, array $query = [], bool $retryTransient = false)
    {
        return $this->request('POST', $path, $body, $query, false, $retryTransient);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getServerStatus()
    {
        return $this->get('/panel/api/server/status');
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getInboundOptions()
    {
        return $this->get('/panel/api/inbounds/options');
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function addClient($body)
    {
        return $this->post('/panel/api/clients/add', $body, [], true);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function attachClient(string $email, array $inboundIds)
    {
        return $this->post(
            '/panel/api/clients/' . rawurlencode($email) . '/attach',
            ['inboundIds' => array_values(array_map('intval', $inboundIds))],
            [],
            true
        );
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function updateClient(string $email, $body)
    {
        return $this->post('/panel/api/clients/update/' . rawurlencode($email), $body, [], true);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function deleteClient(string $email, bool $keepTraffic = false)
    {
        return $this->post('/panel/api/clients/del/' . rawurlencode($email), null, [
            'keepTraffic' => $keepTraffic ? '1' : '0',
        ]);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function resetClientTraffic(string $email)
    {
        return $this->post('/panel/api/clients/resetTraffic/' . rawurlencode($email));
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getClientTraffic(string $email)
    {
        return $this->get('/panel/api/clients/traffic/' . rawurlencode($email));
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getClient(string $email)
    {
        $email = trim($email);
        if ($email === '') {
            return (object) [
                'success' => false,
                'msg' => 'empty email',
                'obj' => null,
                'http_code' => 0,
            ];
        }
        return $this->get('/panel/api/clients/get/' . rawurlencode($email));
    }

    private function isBenignClientMiss(string $msg, string $path): bool
    {
        if (strpos($path, '/panel/api/clients/get/') !== 0) {
            return false;
        }
        $lower = strtolower($msg);
        return $lower === ''
            || strpos($lower, 'record not found') !== false
            || strpos($lower, 'not found') !== false;
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getClientLinks(string $email)
    {
        return $this->get('/panel/api/clients/links/' . rawurlencode($email));
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getClientSubLinks(string $subId)
    {
        return $this->get('/panel/api/clients/subLinks/' . rawurlencode($subId));
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function listClients()
    {
        return $this->get('/panel/api/clients/list');
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function listInbounds()
    {
        return $this->get('/panel/api/inbounds/list');
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getInbound(int $id)
    {
        return $this->get('/panel/api/inbounds/get/' . $id);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function addInbound($body)
    {
        return $this->post('/panel/api/inbounds/add', $body);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function updateInbound(int $id, $body)
    {
        return $this->post('/panel/api/inbounds/update/' . $id, $body);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function deleteInbound(int $id)
    {
        return $this->post('/panel/api/inbounds/del/' . $id);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function setInboundEnable(int $id, bool $enable)
    {
        return $this->post('/panel/api/inbounds/setEnable/' . $id, ['enable' => $enable]);
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function resetInboundTraffic(int $id)
    {
        return $this->post('/panel/api/inbounds/' . $id . '/resetTraffic');
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function clearClientIps(string $email)
    {
        return $this->post('/panel/api/clients/clearIps/' . rawurlencode($email));
    }

    /** @return object{success:bool,msg:string,obj:mixed,http_code:int} */
    public function getNewUuid()
    {
        return $this->get('/panel/api/server/getNewUUID');
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed,http_code:int}
     */
    private function request(string $method, string $path, $body = null, array $query = [], bool $retry = false, bool $retryTransient = false)
    {
        $maxAttempts = 1;
        if ($retryTransient) {
            $maxAttempts = 7;
        } elseif ($retry) {
            $maxAttempts = 2;
        }
        $last = null;

        for ($i = 0; $i < $maxAttempts; $i++) {
            $last = $this->doRequest($method, $path, $body, $query);
            if ($last->success) {
                return $last;
            }
            if ($i === $maxAttempts - 1) {
                return $last;
            }
            $msg = (string) ($last->msg ?? '');
            if ($retryTransient && function_exists('isPanelTransientError') && isPanelTransientError($msg)) {
                usleep(function_exists('panelRetryDelayUs') ? panelRetryDelayUs($i) : 500000);
                continue;
            }
            if ($retry) {
                usleep(250000);
                continue;
            }
            return $last;
        }

        return $last;
    }

    /**
     * @return object{success:bool,msg:string,obj:mixed,http_code:int}
     */
    private function doRequest(string $method, string $path, $body = null, array $query = [])
    {
        try {
            $headers = $this->buildHeaders($method === 'POST' && $body !== null);
        } catch (RuntimeException $e) {
            $this->lastAuthError = $e->getMessage();
            return (object) [
                'success' => false,
                'msg' => $e->getMessage(),
                'obj' => null,
                'http_code' => 0,
            ];
        }

        $url = $this->buildUrl($path, $query);
        $payload = null;
        if ($method === 'POST' && $body !== null) {
            $payload = is_string($body) ? $body : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        $raw = $this->rawHttp($method, $url, $payload, $headers, true);
        $httpCode = $raw['http_code'];
        $rawBody = $raw['body'];

        if ($rawBody === false || $rawBody === null) {
            $msg = 'Connection error: ' . ($raw['curl_error'] ?: 'unknown');
            $this->log('error', $method . ' ' . $this->sanitizePath($path) . ' curl: ' . $raw['curl_error']);
            return (object) [
                'success' => false,
                'msg' => $msg,
                'obj' => null,
                'http_code' => $httpCode,
            ];
        }

        $decoded = json_decode($rawBody);
        if (!is_object($decoded)) {
            $hint = $this->responseHint($httpCode, $rawBody);
            $this->log('error', $method . ' ' . $this->sanitizePath($path) . ' invalid JSON http=' . $httpCode);
            return (object) [
                'success' => false,
                'msg' => 'Invalid JSON from panel (HTTP ' . $httpCode . ')' . ($hint ? ' — ' . $hint : ''),
                'obj' => null,
                'http_code' => $httpCode,
            ];
        }

        $success = isset($decoded->success) ? (bool) $decoded->success : ($httpCode >= 200 && $httpCode < 300);
        if (!$success && !$this->isBenignClientMiss((string) ($decoded->msg ?? ''), $path)) {
            $this->log('warn', $method . ' ' . $this->sanitizePath($path) . ' app-error: ' . ($decoded->msg ?? 'unknown'));
        }

        return (object) [
            'success' => $success,
            'msg' => (string) ($decoded->msg ?? ''),
            'obj' => $decoded->obj ?? null,
            'http_code' => $httpCode,
        ];
    }

    private function buildUrl(string $path, array $query = []): string
    {
        $base = rtrim((string) $this->config['base_url'], '/');
        if ($path[0] !== '/') {
            $path = '/' . $path;
        }
        $url = $base . $path;
        if (!empty($query)) {
            $url .= '?' . http_build_query($query);
        }
        return $url;
    }

    /** @return string[] */
    private function buildHeaders(bool $withJsonContentType = false): array
    {
        $headers = [
            'Accept: application/json, text/plain, */*',
            'User-Agent: WizWiz-XuiV3/2.1',
        ];
        if ($withJsonContentType) {
            $headers[] = 'Content-Type: application/json';
        }

        if ($this->useBearer) {
            if (empty($this->config['api_token'])) {
                throw new RuntimeException('API Token لازم است — از Settings → Security → API Tokens بسازید');
            }
            $headers[] = 'Authorization: Bearer ' . $this->config['api_token'];
            return $headers;
        }

        if ($this->sessionCookie === null) {
            $this->sessionCookie = $this->loginWithCookie();
        }
        if (empty($this->sessionCookie)) {
            $msg = $this->lastAuthError ?: 'ورود به پنل ناموفق — یوزر/پس یا Token را بررسی کنید';
            throw new RuntimeException($msg);
        }
        $headers[] = 'Cookie: ' . $this->sessionCookie;

        return $headers;
    }

    private function resolveUseBearer(): bool
    {
        $mode = strtolower((string) ($this->config['auth_mode'] ?? 'auto'));
        $hasToken = !empty($this->config['api_token']);

        if ($mode === 'cookie') {
            return false;
        }
        if (in_array($mode, ['bearer', 'token'], true)) {
            return true;
        }
        return $hasToken;
    }

    private function loginWithCookie(): string
    {
        $base = rtrim((string) $this->config['base_url'], '/');
        if ($base === '') {
            $this->lastAuthError = 'آدرس پنل خالی است';
            return '';
        }

        $bootstrap = $this->bootstrapSession($base);
        $headers = [
            'Content-Type: application/json',
            'Accept: application/json',
        ];
        if ($bootstrap['csrf'] !== '') {
            $headers[] = 'X-CSRF-Token: ' . $bootstrap['csrf'];
        }
        if ($bootstrap['cookie'] !== '') {
            $headers[] = 'Cookie: ' . $bootstrap['cookie'];
        }

        $payload = json_encode([
            'username' => (string) $this->config['username'],
            'password' => (string) $this->config['password'],
            'twoFactorCode' => '',
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

        $login = $this->rawHttp('POST', $base . '/login', $payload, $headers, false);
        $json = json_decode((string) $login['body']);

        if (!is_object($json) || empty($json->success)) {
            $http = (int) $login['http_code'];
            $panelMsg = is_object($json) ? (string) ($json->msg ?? '') : '';
            if ($http === 403) {
                $this->lastAuthError = '403 Forbidden — پنل v3 به CSRF نیاز دارد یا IP بلاک شده';
            } elseif ($panelMsg !== '') {
                $this->lastAuthError = $panelMsg;
            } else {
                $this->lastAuthError = 'Login failed (HTTP ' . $http . ')';
            }
            $this->log('error', 'login rejected http=' . $http . ' msg=' . $panelMsg);
            return '';
        }

        $cookie = $this->mergeCookieStrings($bootstrap['cookie'], $this->formatCookieHeader($login['cookies']));
        if ($cookie === '') {
            $this->lastAuthError = 'Login OK but no session cookie received';
            return '';
        }

        return $cookie;
    }

    /**
     * 3x-ui v3.0+ requires CSRF token before POST /login.
     * @return array{csrf:string,cookie:string}
     */
    private function bootstrapSession(string $base): array
    {
        $home = $this->rawHttp('GET', $base . '/', null, ['Accept: text/html,application/json'], true);
        $csrf = $this->extractCsrfFromBody((string) $home['body']);
        $cookie = $this->formatCookieHeader($home['cookies']);

        if ($csrf === '' && $cookie !== '') {
            $tokenResp = $this->rawHttp(
                'GET',
                $base . '/csrf-token',
                null,
                ['Accept: application/json', 'Cookie: ' . $cookie],
                true
            );
            $json = json_decode((string) $tokenResp['body']);
            if (is_object($json) && !empty($json->obj)) {
                $csrf = (string) $json->obj;
            }
            $cookie = $this->mergeCookieStrings($cookie, $this->formatCookieHeader($tokenResp['cookies']));
        }

        return ['csrf' => $csrf, 'cookie' => $cookie];
    }

    private function extractCsrfFromBody(string $body): string
    {
        if (preg_match('/name=["\']csrf-token["\']\s+content=["\']([^"\']+)/i', $body, $m)) {
            return $m[1];
        }
        if (preg_match('/content=["\']([^"\']+)["\']\s+name=["\']csrf-token["\']/i', $body, $m)) {
            return $m[1];
        }
        return '';
    }

    /**
     * @param string[] $extraHeaders
     * @return array{body:string|false,curl_error:string,http_code:int,cookies:array<string,string>}
     */
    private function rawHttp(string $method, string $url, ?string $body, array $extraHeaders, bool $followRedirects): array
    {
        $headers = $extraHeaders;
        $curl = curl_init();
        $opts = [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_CONNECTTIMEOUT => (int) ($this->config['connect_timeout_seconds'] ?? $this->config['timeout_seconds']),
            CURLOPT_TIMEOUT => (int) $this->config['timeout_seconds'],
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_FOLLOWLOCATION => $followRedirects,
            CURLOPT_HTTPHEADER => $headers,
        ];

        if ($this->useBearer && !empty($this->config['api_token'])) {
            $opts[CURLOPT_UNRESTRICTED_AUTH] = true;
        }

        if ($method === 'POST' && $body !== null) {
            $opts[CURLOPT_POSTFIELDS] = $body;
        }

        curl_setopt_array($curl, $opts);
        $response = curl_exec($curl);
        $httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $curlError = curl_error($curl);
        curl_close($curl);

        if ($response === false) {
            return [
                'body' => false,
                'curl_error' => $curlError,
                'http_code' => $httpCode,
                'cookies' => [],
            ];
        }

        $headerSize = strpos($response, "\r\n\r\n");
        if ($headerSize === false) {
            return [
                'body' => $response,
                'curl_error' => '',
                'http_code' => $httpCode,
                'cookies' => [],
            ];
        }

        $header = substr($response, 0, $headerSize);
        $respBody = substr($response, $headerSize + 4);

        return [
            'body' => $respBody,
            'curl_error' => '',
            'http_code' => $httpCode,
            'cookies' => $this->parseCookiesFromHeaders($header),
        ];
    }

    /** @return array<string, string> */
    private function parseCookiesFromHeaders(string $header): array
    {
        preg_match_all('/^Set-Cookie:\s*([^;\r\n]+)/mi', $header, $matches);
        $cookies = [];
        foreach ($matches[1] as $item) {
            $parts = explode('=', $item, 2);
            if (count($parts) === 2) {
                $cookies[trim($parts[0])] = trim($parts[1]);
            }
        }
        return $cookies;
    }

    /** @param array<string, string> $cookies */
    private function formatCookieHeader(array $cookies): string
    {
        if (empty($cookies)) {
            return '';
        }
        $parts = [];
        foreach ($cookies as $name => $value) {
            $parts[] = $name . '=' . $value;
        }
        return implode('; ', $parts);
    }

    private function mergeCookieStrings(string $a, string $b): string
    {
        $merged = [];
        foreach ([$a, $b] as $cookieStr) {
            if ($cookieStr === '') {
                continue;
            }
            foreach (explode(';', $cookieStr) as $pair) {
                $pair = trim($pair);
                if ($pair === '' || strpos($pair, '=') === false) {
                    continue;
                }
                [$name, $value] = explode('=', $pair, 2);
                $merged[trim($name)] = trim($value);
            }
        }
        return $this->formatCookieHeader($merged);
    }

    private function responseHint(int $httpCode, string $body): string
    {
        if ($httpCode === 404) {
            return 'آدرس پنل را بدون /panel وارد کنید (مثلاً https://host:2053/path)';
        }
        if ($httpCode === 401 || $httpCode === 403) {
            return 'Token/یوزر اشتباه یا API Token غیرفعال است';
        }
        if (stripos($body, '<html') !== false) {
            return 'پاسخ HTML — احتمالاً webBasePath یا آدرس پنل اشتباه است';
        }
        return '';
    }

    private function sanitizePath(string $path): string
    {
        return preg_replace(
            '/\/clients\/(get|del|update|traffic|resetTraffic|links|clearIps|subLinks)\/[^\/\?]+/',
            '/clients/$1/{id}',
            $path
        );
    }

    private function log(string $level, string $message): void
    {
        error_log('[' . $this->logPrefix . '][' . $level . '] ' . $message);
    }
}

