<?php

declare(strict_types=1);

namespace PpPhp\Core\Databases\Drivers;

/**
 * cPanel UAPI driver.
 *
 *   GET/POST https://<host>:2083/execute/<Module>/<function>?<params>
 *   Authorization: cpanel <account>:<API token>
 *
 * Three calls provision a database: Mysql::create_database,
 * Mysql::create_user, Mysql::set_privileges_on_database.
 *
 * The token is created in cPanel → Security → Manage API Tokens. It is scoped to
 * one account, which is why the account name is stored alongside it — and which
 * is also where the forced `<account>_` prefix comes from.
 */
final class CpanelDriver implements ProvisionDriver
{
    public function __construct(
        private string $host,
        private string $account,
        private string $token,
        private int $port = 2083,
        private string $dbHost = 'localhost',
        private int $dbPort = 3306,
    ) {}

    /** Which candidate URL actually answered, for honest diagnostics. */
    private string $lastEndpoint = '';
    private bool $lastViaFallback = false;

    public function id(): string { return 'cpanel'; }

    public function connectionTarget(): array
    {
        // cPanel databases live on the panel's own MySQL, which the hosted app
        // reaches as localhost. Overridable for hosts that use a separate
        // database server.
        return ['host' => $this->dbHost !== '' ? $this->dbHost : 'localhost', 'port' => $this->dbPort ?: 3306];
    }

    /** Every cPanel database and user is silently prefixed with the account name. */
    public function prefix(): string
    {
        return $this->account !== '' ? $this->account . '_' : '';
    }

    public function test(): array
    {
        // Listing databases is the cheapest call that proves the token works AND
        // that the Mysql module is available to this account.
        $r = $this->call('Mysql', 'list_databases');
        if (!$r['ok']) {
            return ['success' => false, 'message' => $r['message'],
                    'canCreateDb' => false, 'canCreateUser' => false,
                    'prefix' => $this->prefix(), 'maxNameLength' => 64];
        }
        $count = is_array($r['data']) ? count($r['data']) : 0;
        return [
            'success' => true,
            'message' => sprintf('Connected to cPanel as “%s”. %d existing database(s). New names will be prefixed “%s”.',
                $this->account, $count, $this->prefix()),
            'canCreateDb' => true, 'canCreateUser' => true,
            'prefix' => $this->prefix(), 'maxNameLength' => 64,
            'detail' => ['databases' => $count],
        ];
    }

    public function createDatabase(string $desiredDb, string $desiredUser, string $password): array
    {
        // cPanel wants the FULL prefixed name, and rejects one that does not
        // start with the account prefix.
        $db   = $this->qualify($desiredDb);
        $user = $this->qualify($desiredUser);

        $r = $this->call('Mysql', 'create_database', ['name' => $db]);
        if (!$r['ok']) return ['success' => false, 'message' => 'Could not create the database: ' . $r['message']];

        $r = $this->call('Mysql', 'create_user', ['name' => $user, 'password' => $password]);
        if (!$r['ok']) {
            // Roll the database back so a half-made pair is not left behind.
            $this->call('Mysql', 'delete_database', ['name' => $db]);
            return ['success' => false, 'message' => 'Database created but the login could not be: ' . $r['message']];
        }

        $r = $this->call('Mysql', 'set_privileges_on_database',
            ['user' => $user, 'database' => $db, 'privileges' => 'ALL PRIVILEGES']);
        if (!$r['ok']) {
            $this->call('Mysql', 'delete_user', ['name' => $user]);
            $this->call('Mysql', 'delete_database', ['name' => $db]);
            return ['success' => false, 'message' => 'Could not grant the login access: ' . $r['message']];
        }

        return ['success' => true, 'message' => 'Database created via cPanel.',
                'dbName' => $db, 'dbUser' => $user, 'dedicated' => true];
    }

    public function dropDatabase(string $dbName, string $dbUser = ''): array
    {
        $errors = [];
        $r = $this->call('Mysql', 'delete_database', ['name' => $dbName]);
        if (!$r['ok']) $errors[] = $r['message'];
        if ($dbUser !== '') {
            $r = $this->call('Mysql', 'delete_user', ['name' => $dbUser]);
            // A missing user is not a failure worth reporting — the goal is that
            // it is gone, and it is.
            if (!$r['ok'] && stripos($r['message'], 'does not exist') === false) $errors[] = $r['message'];
        }
        return $errors === []
            ? ['success' => true, 'message' => 'Removed via cPanel.']
            : ['success' => false, 'message' => implode(' · ', $errors)];
    }

    /**
     * Base URLs to try, in order: exactly what the owner configured, then the
     * `cpanel.<domain>` proxy on 443 for hosts that block outbound 2083.
     *
     * @return array<int,string>
     */
    private function candidates(): array
    {
        $list = [sprintf('https://%s:%d', $this->host, $this->port)];

        // Only meaningful for a real domain. Loopback and bare IPs have no proxy
        // subdomain, and one already starting with `cpanel.` is what we just
        // tried — adding a doomed candidate only buys another connect timeout.
        if (!self::isLoopbackOrIp($this->host) && !str_starts_with($this->host, 'cpanel.')) {
            $alt = 'https://cpanel.' . $this->host;          // implicit :443
            if (!in_array($alt, $list, true)) $list[] = $alt;
        }
        return $list;
    }

    /** Apply the account prefix unless the caller already did. */
    private function qualify(string $name): string
    {
        $p = $this->prefix();
        $name = preg_replace('/[^A-Za-z0-9_]/', '', $name) ?? $name;
        if ($p !== '' && str_starts_with($name, $p)) return substr($name, 0, 64);
        return substr($p . $name, 0, 64);
    }

    /**
     * One UAPI call.
     *
     * @return array{ok:bool, message:string, data:mixed}
     */
    private function call(string $module, string $function, array $params = []): array
    {
        $query = $params !== [] ? '?' . http_build_query($params) : '';
        $path  = sprintf('/execute/%s/%s%s', $module, $function, $query);
        $auth  = ['Authorization: cpanel ' . $this->account . ':' . $this->token];

        // Many hosts allow outbound 80/443 ONLY, so a server that can talk to the
        // internet perfectly well still cannot reach cPanel on 2083. cPanel also
        // publishes its interface on the `cpanel.<domain>` proxy subdomain over
        // standard 443, which is the same API — so if the configured address is
        // unreachable, that is tried before giving up. Only a TRANSPORT failure
        // falls through; a real answer (even "denied") is returned as-is.
        $raw = null;
        $answered = '';
        $viaFallback = false;
        foreach ($this->candidates() as $i => $base) {
            $raw = $this->http($base . $path, $auth);
            if ($raw['error'] === '') { $answered = $base; $viaFallback = $i > 0; break; }
        }
        if ($raw === null || $raw['error'] !== '') {
            return ['ok' => false, 'data' => null,
                    'message' => ($raw['error'] ?? 'Could not reach cPanel.')
                        . ' Tried ' . implode(' and ', $this->candidates()) . '.'];
        }
        $this->lastEndpoint = $answered;
        $this->lastViaFallback = $viaFallback;

        $j = json_decode((string) $raw['body'], true);
        if (!is_array($j)) {
            $body = trim(strip_tags((string) $raw['body']));

            // cPanel answers a bad or unauthorised API token with HTTP 403 and a
            // bare "Access denied" body — NOT a 401 and NOT JSON. Reading that as
            // a networking problem sends people hunting for firewall rules that
            // are not the issue, so it is called out explicitly.
            if ($raw['status'] === 403 || $raw['status'] === 401 || stripos($body, 'access denied') !== false) {
                $where = $this->lastEndpoint !== '' ? ' at ' . $this->lastEndpoint : '';
                // Which endpoint answered changes the diagnosis completely, so it
                // is never hidden. The `cpanel.<domain>` proxy is reachable through
                // a 443-only firewall but does not necessarily accept API-token
                // auth — a 403 from THERE is not evidence the token is wrong.
                if ($this->lastViaFallback) {
                    return ['ok' => false, 'data' => null, 'message' =>
                        'Port ' . $this->port . ' is blocked from this server, so cPanel was contacted via the proxy '
                        . $this->lastEndpoint . ' — and it answered "' . ($body !== '' ? $body : 'Access denied') . '" (HTTP ' . $raw['status'] . '). '
                        . 'That proxy often refuses API tokens even when they are valid, so this is most likely the '
                        . 'blocked port rather than your credentials. Ask your host to allow OUTBOUND TCP '
                        . $this->port . ' from this server; if the same token works from another machine, that confirms it.'];
                }
                return ['ok' => false, 'data' => null, 'message' =>
                    'cPanel refused the API token' . $where . ' (HTTP ' . $raw['status'] . ': ' . ($body !== '' ? $body : 'Access denied') . '). '
                    . 'The port is reachable, so this is the credentials — check that: '
                    . '(1) the username is your cPanel ACCOUNT name, which is often not the domain and may be truncated; '
                    . '(2) the token was copied whole, with no stray spaces; '
                    . '(3) the token is unrestricted, or has permission to manage MySQL.'];
            }
            if ($raw['status'] === 404) {
                return ['ok' => false, 'data' => null, 'message' =>
                    'No cPanel API at ' . $this->host . ':' . $this->port . ' (HTTP 404). Check the host and port.'];
            }
            // A login page instead of JSON means we reached cPanel but were not
            // authenticated into the API at all.
            if (stripos((string) $raw['body'], '<html') !== false) {
                return ['ok' => false, 'data' => null, 'message' =>
                    'cPanel returned its web interface rather than an API response — the API token was not accepted.'];
            }
            return ['ok' => false, 'data' => null,
                    'message' => 'cPanel did not return a valid API response (HTTP ' . $raw['status'] . ').'
                        . ($body !== '' ? ' It said: ' . mb_substr($body, 0, 120) : '')];
        }

        // UAPI: {"status":1,"errors":null,"data":…} — errors is an array of strings.
        $okFlag = (int) ($j['status'] ?? 0) === 1;
        $errs = $j['errors'] ?? null;
        if (!$okFlag || (is_array($errs) && $errs !== [])) {
            $msg = is_array($errs) ? implode('; ', array_map('strval', $errs)) : 'The request was refused by cPanel.';
            return ['ok' => false, 'message' => $msg, 'data' => null];
        }
        return ['ok' => true, 'message' => '', 'data' => $j['data'] ?? null];
    }

    /** @return array{body:string,status:int,error:string} */
    private function http(string $url, array $headers): array
    {
        if (!function_exists('curl_init')) {
            return ['body' => '', 'status' => 0, 'error' => 'PHP cURL is required to talk to cPanel.'];
        }
        // A panel certificate is issued for the server's HOSTNAME, so it can never
        // match a raw IP or `localhost`. Relax verification for exactly those
        // cases rather than dead-ending:
        //   • loopback — the traffic never leaves the machine, so there is no
        //     man in the middle to protect against, and the cert cannot match by
        //     construction. This is the normal setup for a console provisioning
        //     on its OWN panel;
        //   • IP literal — same certificate problem, and the token still
        //     authenticates us.
        // A real hostname keeps full verification.
        $byIp = self::isLoopbackOrIp($this->host);

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_USERAGENT      => 'PPPHP-Provisioner/1',
            CURLOPT_SSL_VERIFYPEER => !$byIp,
            CURLOPT_SSL_VERIFYHOST => $byIp ? 0 : 2,
        ]);
        $body = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err = curl_error($ch);
        unset($ch);

        if ($body === false || $status === 0) {
            return ['body' => '', 'status' => $status,
                    'error' => 'Could not reach cPanel at ' . $this->host . ':' . $this->port
                             . ($err !== '' ? ' (' . $err . ')' : '.')
                             . ' Check the host, the port, and that the panel is reachable from this server.'];
        }
        return ['body' => (string) $body, 'status' => $status, 'error' => ''];
    }

    /** Loopback or a bare IP — the two cases a panel certificate can never match. */
    private static function isLoopbackOrIp(string $host): bool
    {
        $h = strtolower(trim($host));
        if (in_array($h, ['localhost', '127.0.0.1', '::1', '[::1]'], true)) return true;
        return (bool) filter_var($h, FILTER_VALIDATE_IP);
    }
}
