<?php

declare(strict_types=1);

/**
 * Shared cPanel addon-domain helper.
 * No output, no session mutation, no secrets in logs/responses.
 */

function addonLoadEnvFileFallback(string $path): void
{
    if (!is_file($path) || !is_readable($path)) {
        return;
    }

    $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    if ($lines === false) {
        return;
    }

    foreach ($lines as $line) {
        $line = trim($line);
        if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
            continue;
        }

        [$key, $value] = explode('=', $line, 2);
        $key = trim($key);
        $value = trim($value);

        if ($key === '' || preg_match('/^[A-Z0-9_]+$/i', $key) !== 1) {
            continue;
        }

        if (
            (str_starts_with($value, '"') && str_ends_with($value, '"'))
            || (str_starts_with($value, "'") && str_ends_with($value, "'"))
        ) {
            $value = substr($value, 1, -1);
        }

        if (getenv($key) === false) {
            putenv($key . '=' . $value);
            $_ENV[$key] = $value;
            $_SERVER[$key] = $value;
        }
    }
}

function addonEnv(string $key, ?string $default = null): ?string
{
    if (function_exists('app_env')) {
        $value = app_env($key, $default);

        return $value === null ? $default : (string) $value;
    }

    $value = getenv($key);
    if ($value === false) {
        $value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
    }

    if ($value === null) {
        return $default;
    }

    return (string) $value;
}

function addonNormalizeCpanelRedirectDir(string $value): string
{
    $dir = trim(str_replace('\\', '/', $value));
    $dir = trim($dir, "/ \t\n\r\0\x0B");
    if ($dir === '') {
        $dir = 'public_html';
    }

    return str_ends_with(strtolower($dir), '/redirect') ? $dir : $dir . '/redirect';
}

/**
 * @return array{ok: bool, error: string}
 */
function addonBootstrapCpanelDependencies(): array
{
    static $loaded = false;
    static $result = ['ok' => true, 'error' => ''];

    if ($loaded) {
        return $result;
    }

    $loaded = true;
    $projectRoot = dirname(__DIR__);
    $accountRoot = dirname(__DIR__, 2);

    $xmlApiPath = $projectRoot . '/xmlapi.php';
    if (!is_file($xmlApiPath)) {
        error_log('addon-domain-lib: missing xmlapi.php');
        $result = ['ok' => false, 'error' => 'cpanel-client-unavailable'];

        return $result;
    }

    require_once $xmlApiPath;

    foreach ([$projectRoot . '/env.php', $accountRoot . '/env.php'] as $envBootstrapPath) {
        if (is_file($envBootstrapPath)) {
            require_once $envBootstrapPath;
            break;
        }
    }

    foreach ([$projectRoot . '/.env', $accountRoot . '/.env'] as $envPath) {
        if (function_exists('load_env_file')) {
            load_env_file($envPath);
        } else {
            addonLoadEnvFileFallback($envPath);
        }
    }

    if (!class_exists('xmlapi')) {
        error_log('addon-domain-lib: xmlapi class unavailable');
        $result = ['ok' => false, 'error' => 'cpanel-client-unavailable'];

        return $result;
    }

    $result = ['ok' => true, 'error' => ''];

    return $result;
}

function addonNormalizeDomain(string $domain): ?string
{
    $domain = strtolower(trim($domain));
    $domain = preg_replace('/^https?:\/\//i', '', $domain) ?? '';
    $domain = trim($domain, " \t\n\r\0\x0B./");

    if ($domain === '' || strlen($domain) > 253) {
        return null;
    }

    if (preg_match('/[\/\\:@?#\[\]]/', $domain) === 1) {
        return null;
    }

    if (preg_match('/^(localhost|localdomain)$/i', $domain) === 1) {
        return null;
    }

    if (filter_var($domain, FILTER_VALIDATE_IP) !== false) {
        return null;
    }

    if (preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $domain) !== 1) {
        return null;
    }

    return $domain;
}

function addonSafeLogText(string $text): string
{
    $text = preg_replace('/(token|password|pass|authorization|auth|secret)[^,}]{0,160}/i', '$1-redacted', $text) ?? $text;
    $text = preg_replace('/[^a-z0-9._:@=+\-\/\s]+/i', ' ', $text) ?? $text;
    $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);

    return substr($text, 0, 260);
}

/**
 * @return array{host:string, port:int}
 */
function addonCpanelEndpointFromEnv(): array
{
    $rawHost = trim((string) addonEnv('CPANEL_HOST', 'localhost'));
    $rawPort = trim((string) addonEnv('CPANEL_PORT', '2083'));

    $rawHost = preg_replace('#^https?://#i', '', $rawHost) ?? $rawHost;
    $rawHost = explode('/', $rawHost, 2)[0];
    $parsedPort = 0;

    if (str_contains($rawHost, ':')) {
        [$hostPart, $portPart] = explode(':', $rawHost, 2);
        $rawHost = $hostPart;
        $parsedPort = (int) $portPart;
    }

    $host = function_exists('app_normalize_cpanel_host')
        ? app_normalize_cpanel_host($rawHost)
        : trim($rawHost);
    if ($host === '') {
        $host = 'localhost';
    }

    $port = (int) $rawPort;
    if ($port < 1 || $port > 65535) {
        $port = $parsedPort > 0 ? $parsedPort : 2083;
    }

    if (function_exists('app_detect_cpanel_host')) {
        $host = app_detect_cpanel_host($host, $port);
    }

    return ['host' => $host, 'port' => $port];
}

function addonMakeCpanelClient(): xmlapi
{
    $endpoint = addonCpanelEndpointFromEnv();

    $client = new xmlapi($endpoint['host']);
    $client->set_port($endpoint['port']);
    $client->set_output('json');
    $client->set_debug(0);

    if (method_exists($client, 'set_protocol')) {
        $client->set_protocol('https');
    }

    return $client;
}

/**
 * @return array<string, mixed>|null
 */
function addonNormalizeCpanelResult(mixed $result): ?array
{
    if ($result === null || $result === false) {
        return null;
    }

    if (is_string($result)) {
        $decoded = json_decode($result, true);

        return is_array($decoded) ? $decoded : ['raw' => $result];
    }

    if (is_array($result)) {
        return $result;
    }

    if (is_object($result)) {
        $encoded = json_encode($result);
        if ($encoded === false) {
            return ['object' => get_class($result)];
        }

        $decoded = json_decode($encoded, true);

        return is_array($decoded) ? $decoded : ['object' => get_class($result)];
    }

    return ['raw' => (string) $result];
}

function addonStringContainsAny(string $text, array $needles): bool
{
    $text = strtolower($text);
    foreach ($needles as $needle) {
        if (str_contains($text, strtolower((string) $needle))) {
            return true;
        }
    }

    return false;
}

function addonCpanelResultText(array $data): string
{
    $encoded = json_encode($data);
    if ($encoded === false) {
        return '';
    }

    return strtolower($encoded);
}

/**
 * Normalize cpanelresult.data into a list of rows (API2 may return a single
 * associative row or a list of rows).
 *
 * @return array<int, array<string, mixed>>
 */
function addonCpanelResultRows(array $data): array
{
    $rows = $data['cpanelresult']['data'] ?? null;
    if (!is_array($rows)) {
        return [];
    }

    // single associative row → wrap as list
    if (array_key_exists('result', $rows) || array_key_exists('reason', $rows)) {
        return [$rows];
    }

    return array_values(array_filter($rows, 'is_array'));
}

function addonCpanelResultIsOk(mixed $result, bool $allowAlreadyExists): bool
{
    $data = addonNormalizeCpanelResult($result);
    if ($data === null) {
        return false;
    }

    $cpanel = $data['cpanelresult'] ?? null;
    if (is_array($cpanel)) {
        // API-level error (bad params, auth, module disabled, …)
        $apiError = trim((string) ($cpanel['error'] ?? ''));
        if ($apiError !== '') {
            return $allowAlreadyExists
                && addonStringContainsAny($apiError, ['already', 'exists', 'configured']);
        }

        // event.result === 0 → the API call itself failed
        $eventResult = $cpanel['event']['result'] ?? null;
        if ($eventResult !== null && (int) $eventResult !== 1) {
            return false;
        }

        // per-operation result is the source of truth for API2
        foreach (addonCpanelResultRows($data) as $row) {
            if (!array_key_exists('result', $row)) {
                continue;
            }

            if ((int) $row['result'] === 1) {
                return true;
            }

            $reason = strtolower((string) ($row['reason'] ?? ''));
            if ($allowAlreadyExists && addonStringContainsAny($reason, ['already', 'exists', 'configured'])) {
                return true;
            }

            return false;
        }

        // call executed and no row-level failure was reported
        if ($eventResult !== null) {
            return (int) $eventResult === 1;
        }
    }

    // UAPI shape fallbacks
    $uapiStatus = $data['result']['status'] ?? null;
    if ($uapiStatus !== null) {
        return (int) $uapiStatus === 1;
    }

    $status = $data['status'] ?? null;
    if ($status !== null) {
        return (int) $status === 1;
    }

    // textual last resort
    $text = addonCpanelResultText($data);
    if ($allowAlreadyExists && addonStringContainsAny($text, ['already exists', 'already configured', 'is already configured'])) {
        return true;
    }

    if (addonStringContainsAny($text, ['owned by another user', 'not owned', 'permission denied', 'not allowed', 'invalid domain'])) {
        return false;
    }

    if (addonStringContainsAny($text, ['"error"', '"errors"']) && addonStringContainsAny($text, ['failed', 'denied', 'invalid'])) {
        return false;
    }

    return true;
}

/**
 * Extract a human-readable failure reason from a cPanel API2 result, safe to
 * surface to the operator (no secrets).
 */
function addonCpanelResultReason(mixed $result): string
{
    $data = addonNormalizeCpanelResult($result);
    if ($data === null) {
        return 'no-response';
    }

    $cpanel = $data['cpanelresult'] ?? null;
    if (is_array($cpanel)) {
        $apiError = trim((string) ($cpanel['error'] ?? ''));
        if ($apiError !== '') {
            return addonSafeLogText($apiError);
        }

        foreach (addonCpanelResultRows($data) as $row) {
            $reason = trim((string) ($row['reason'] ?? ''));
            if ($reason !== '') {
                return addonSafeLogText($reason);
            }
        }
    }

    return '';
}

function addonCpanelResultLogSummary(mixed $result): string
{
    $data = addonNormalizeCpanelResult($result);
    if ($data === null) {
        return 'null-result';
    }

    return addonSafeLogText(addonCpanelResultText($data));
}

/**
 * Read a list-style API2 result (e.g. Park::listparkeddomains,
 * SubDomain::listsubdomains) into a flat list of lowercased domain strings.
 *
 * @return array<int, string>
 */
function addonCpanelListDomainNames(xmlapi $client, string $user, string $module, string $func): array
{
    $result = $client->api2_query($user, $module, $func, []);
    $data   = addonNormalizeCpanelResult($result);
    if ($data === null) {
        return [];
    }

    $names = [];
    foreach (addonCpanelResultRows($data) as $row) {
        $name = strtolower(trim((string) ($row['domain'] ?? '')));
        if ($name !== '') {
            $names[] = $name;
        }
    }

    return $names;
}

function addonCpanelDomainIsParked(xmlapi $client, string $user, string $domain): bool
{
    return in_array(
        strtolower($domain),
        addonCpanelListDomainNames($client, $user, 'Park', 'listparkeddomains'),
        true,
    );
}

/**
 * Poll the parked-domain list a few times, because Park::park often lands a
 * couple of seconds after the HTTP client has already timed out. Kept short to
 * stay within the web request's execution budget.
 */
function addonCpanelWaitParked(xmlapi $client, string $user, string $domain, int $attempts = 3, int $sleepSeconds = 2): bool
{
    for ($i = 0; $i < $attempts; $i++) {
        if (addonCpanelDomainIsParked($client, $user, $domain)) {
            return true;
        }
        if ($i < $attempts - 1) {
            sleep($sleepSeconds);
        }
    }

    return false;
}

function addonCpanelWildcardExists(xmlapi $client, string $user, string $domain): bool
{
    return in_array(
        '*.' . strtolower($domain),
        addonCpanelListDomainNames($client, $user, 'SubDomain', 'listsubdomains'),
        true,
    );
}

/**
 * Park the domain and create the wildcard (*.domain) subdomain pointing at the
 * redirect dir. Verification-based and idempotent: success is reported only when
 * the wildcard subdomain actually exists.
 *
 * Park is a slow operation (it rebuilds Apache vhosts + DNS) and frequently
 * exceeds the HTTP client timeout while still completing server-side. We
 * therefore never abort on a park "no-response": we re-read the parked-domain
 * list to learn whether it actually landed, then proceed to the wildcard step.
 *
 * @return array{ok:bool,error:string}
 */
function addonCpanelParkAndWildcard(xmlapi $client, string $user, string $domain, string $dir): array
{
    // Already provisioned — nothing to do (idempotent re-run / retry).
    if (addonCpanelWildcardExists($client, $user, $domain)) {
        return ['ok' => true, 'error' => ''];
    }

    // --- Park the domain (tolerate a client-side timeout) ---------------------
    if (!addonCpanelDomainIsParked($client, $user, $domain)) {
        $client->set_timeout(15);
        $parkResult = $client->api2_query($user, 'Park', 'park', ['domain' => $domain]);
        // Capture the transport error NOW: addonCpanelWaitParked() below issues
        // more queries that reset xmlapi::$lastError, masking the real cause.
        $parkTransport = $client->get_last_error();
        $client->set_timeout(20);

        // The HTTP call may time out while park finishes server-side, so the
        // parked-domain list — not the response — is the source of truth. Give
        // it a few seconds to land before concluding it failed.
        if (!addonCpanelResultIsOk($parkResult, true) && !addonCpanelWaitParked($client, $user, $domain)) {
            $reason = addonCpanelReasonWithTransport($parkResult, $parkTransport);
            error_log('addon-domain-lib: park failed domain=' . $domain . ' reason=' . $reason . ' result=' . addonCpanelResultLogSummary($parkResult));

            return ['ok' => false, 'error' => 'cpanel-park-failed: ' . $reason];
        }
    }

    // --- Create the wildcard subdomain (*.domain → redirect dir) --------------
    $subParams = [
        'domain' => '*',
        'rootdomain' => $domain,
        'dir' => $dir,
    ];

    $subdomainResult = $client->api2_query($user, 'SubDomain', 'addsubdomain', $subParams);
    $subTransport = $client->get_last_error();

    // Wildcard may collide with a stale entry — remove then re-add and retry.
    if (!addonCpanelResultIsOk($subdomainResult, true) && !addonCpanelWildcardExists($client, $user, $domain)) {
        $client->api2_query($user, 'SubDomain', 'delsubdomain', ['domain' => '*.' . $domain]);
        $subdomainResult = $client->api2_query($user, 'SubDomain', 'addsubdomain', $subParams);
        $subTransport = $client->get_last_error();
    }

    // Source of truth: does the wildcard actually exist now?
    if (!addonCpanelWildcardExists($client, $user, $domain)) {
        $reason = addonCpanelReasonWithTransport($subdomainResult, $subTransport);
        error_log('addon-domain-lib: wildcard subdomain failed domain=' . $domain . ' reason=' . $reason . ' result=' . addonCpanelResultLogSummary($subdomainResult));

        return ['ok' => false, 'error' => 'cpanel-wildcard-failed: ' . $reason];
    }

    return ['ok' => true, 'error' => ''];
}

/**
 * Best-effort human-readable failure reason. Falls back to the transport-level
 * curl error (timeout / connection refused / HTTP 401) when cPanel returned
 * nothing, so logs no longer collapse every distinct failure into "no-response".
 */
function addonCpanelFailureReason(mixed $result, xmlapi $client): string
{
    $transport = $client->get_last_error();

    return addonCpanelReasonWithTransport($result, $transport);
}

/**
 * Build a failure reason from the API result, falling back to a transport-level
 * error (curl timeout / connection refused / HTTP 401) captured at call time.
 * Callers must capture get_last_error() immediately after the failing query —
 * later queries reset it, which is how distinct failures collapsed to
 * "no-response".
 */
function addonCpanelReasonWithTransport(mixed $result, string $transportError): string
{
    $reason = addonCpanelResultReason($result);
    if ($reason === '' || $reason === 'no-response') {
        $reason = $transportError !== '' ? $transportError : 'no-response';
    }

    return $reason;
}

/**
 * @return array{ok:bool,error:string}
 */
function addonPerformCpanelAddonDomain(string $domain): array
{
    $bootstrap = addonBootstrapCpanelDependencies();
    if (!$bootstrap['ok']) {
        return $bootstrap;
    }

    $user = trim((string) addonEnv('CPANEL_USER', ''));
    if ($user === '') {
        error_log('addon-domain-lib: missing CPANEL_USER');

        return ['ok' => false, 'error' => 'missing-cpanel-user'];
    }

    $dirEnv = trim((string) addonEnv('CPANEL_SUBDOMAIN_DIR', ''));
    $dir = addonNormalizeCpanelRedirectDir($dirEnv !== '' ? $dirEnv : 'public_html');

    $token = trim((string) addonEnv('CPANEL_API_TOKEN', ''));
    if ($token !== '') {
        $client = addonMakeCpanelClient();
        if (method_exists($client, 'token_auth')) {
            $client->token_auth($user, $token);
            $result = addonCpanelParkAndWildcard($client, $user, $domain, $dir);
            if ($result['ok']) {
                return $result;
            }
            error_log('addon-domain-lib: token auth attempt failed error=' . $result['error']);

            return $result;
        } else {
            error_log('addon-domain-lib: token_auth method unavailable');
        }
    }

    $pass = (string) addonEnv('CPANEL_PASSWORD', '');
    if ($pass === '') {
        return ['ok' => false, 'error' => $token !== '' ? 'cpanel-token-auth-failed' : 'missing-cpanel-auth'];
    }

    $client = addonMakeCpanelClient();
    $client->password_auth($user, $pass);

    return addonCpanelParkAndWildcard($client, $user, $domain, $dir);
}

/**
 * Authenticated cPanel client using whichever credential is configured
 * (API token preferred, password fallback). Returns null if neither exists.
 */
function addonMakeAuthenticatedCpanelClient(string $user): ?xmlapi
{
    $token = trim((string) addonEnv('CPANEL_API_TOKEN', ''));
    $client = addonMakeCpanelClient();

    if ($token !== '' && method_exists($client, 'token_auth')) {
        $client->token_auth($user, $token);

        return $client;
    }

    $pass = (string) addonEnv('CPANEL_PASSWORD', '');
    if ($pass === '') {
        return null;
    }

    $client->password_auth($user, $pass);

    return $client;
}

/**
 * Remove an addon domain from cPanel: delete its wildcard subdomain and unpark
 * it. Best-effort and idempotent — a "does not exist" outcome is treated as
 * success so callers can still drop the local record.
 *
 * @return array{ok:bool,error:string}
 */
function addonPerformCpanelDeleteDomain(string $domain): array
{
    $bootstrap = addonBootstrapCpanelDependencies();
    if (!$bootstrap['ok']) {
        return $bootstrap;
    }

    $user = trim((string) addonEnv('CPANEL_USER', ''));
    if ($user === '') {
        error_log('addon-domain-lib: missing CPANEL_USER (delete)');

        return ['ok' => false, 'error' => 'missing-cpanel-user'];
    }

    $client = addonMakeAuthenticatedCpanelClient($user);
    if ($client === null) {
        return ['ok' => false, 'error' => 'missing-cpanel-auth'];
    }

    // Wildcard subdomain first, then the parked/addon domain. Both best-effort:
    // a missing entry simply means it was already gone.
    $client->api2_query($user, 'SubDomain', 'delsubdomain', ['domain' => '*.' . $domain]);
    $client->api2_query($user, 'Park', 'unpark', ['domain' => $domain]);

    return ['ok' => true, 'error' => ''];
}
