<?php

declare(strict_types=1);

namespace PpPhp\Modules\Licensing\Controllers;

use PpPhp\Core\DB;
use PpPhp\Core\Licensing\LicenseConfig;
use PpPhp\Core\Licensing\LicenseException;
use PpPhp\Core\Licensing\LicenseServer;

/**
 * The public licensing API — the only part of PPPHP that answers without a login.
 *
 * Remote installations call these three endpoints and nothing else. Everything
 * that matters in the reply is Ed25519-signed by the server's key, so the
 * transport is not what is being trusted: a caller that strips TLS, replays a
 * body or fakes a "success" can still not produce a lease this or any client
 * will accept.
 *
 * What IS enforced here: seat counting, revocation, expiry, and a rate limit so
 * the endpoints cannot be used to guess licence keys.
 */
class LicenseApiController
{
    /** Attempts per IP per window, and the window in minutes. */
    private const RATE_LIMIT = 30;
    private const RATE_WINDOW = 10;

    private function json(array $payload, int $status = 200): void
    {
        http_response_code($status);
        header('Content-Type: application/json');
        header('Cache-Control: no-store');
        // Installations call this cross-origin from their own server, not a
        // browser, so no CORS headers are needed — and not sending them keeps a
        // page on another site from probing this API with a user's cookies.
        echo json_encode($payload, JSON_UNESCAPED_SLASHES);
        exit;
    }

    private function input(): array
    {
        $raw = (string) file_get_contents('php://input');
        $data = json_decode($raw, true);
        if (is_array($data)) return $data;
        return $_POST;
    }

    /** Best-effort caller IP. Used for the audit trail and rate limiting only. */
    private function ip(): string
    {
        foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $k) {
            $v = trim((string) ($_SERVER[$k] ?? ''));
            if ($v === '') continue;
            $v = trim(explode(',', $v)[0]);
            if (filter_var($v, FILTER_VALIDATE_IP)) return $v;
        }
        return '';
    }

    /**
     * Refuse an IP that is hammering the endpoints.
     *
     * Deliberately counts FAILURES, not calls: a busy vendor with thousands of
     * legitimate installs renewing daily must never trip this, while someone
     * walking the key space hits it within seconds.
     */
    private function throttle(string $ip): void
    {
        if ($ip === '') return;
        LicenseServer::ensureSchema();
        // Owner-tunable in Core Configuration; the consts are the fallback default.
        $window = class_exists(\PpPhp\Core\Licensing\LicenseConfig::class)
            ? \PpPhp\Core\Licensing\LicenseConfig::rateWindow() : self::RATE_WINDOW;
        $limit  = class_exists(\PpPhp\Core\Licensing\LicenseConfig::class)
            ? \PpPhp\Core\Licensing\LicenseConfig::rateLimit() : self::RATE_LIMIT;
        try {
            $n = DB::FetchOne(
                "SELECT COUNT(*) FROM `pp_license_events`
                  WHERE `ip` = ? AND `kind` IN ('activate_failed','renew_failed','blocked','seat_limit')
                    AND `created_at` > DATE_SUB(NOW(), INTERVAL " . (int) $window . " MINUTE)",
                [$ip]
            );
        } catch (\Throwable $e) {
            return;    // never let the limiter itself break licensing
        }
        if ((int) $n >= $limit) {
            header('Retry-After: ' . ($window * 60));
            $this->json([
                'success' => false,
                'message' => 'Too many failed attempts from this address. Try again in a few minutes.',
            ], 429);
        }
    }

    private function run(callable $fn): void
    {
        $ip = $this->ip();
        $this->throttle($ip);
        try {
            $result = $fn(new LicenseServer(), $this->input(), $ip);
            $this->json(['success' => true] + $result);
        } catch (LicenseException $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], $e->status());
        } catch (\Throwable $e) {
            error_log('[license-api] ' . $e->getMessage());
            // Never echo an internal error to an unauthenticated caller.
            $this->json(['success' => false, 'message' => 'The licence server hit an unexpected problem. Please try again.'], 500);
        }
    }

    /** POST /api/license/activate — claim a seat, receive credentials + first lease. */
    public function activate(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $key = trim((string) ($in['licenseKey'] ?? ''));
            if ($key === '') throw new LicenseException('No licence key was supplied.', 400);

            // Activation is the ONE request that carries the licence key itself.
            // Leases are signed, so plaintext could not forge one — but a key in
            // the clear is a key anyone on the path can reuse, so this refuses
            // rather than quietly accepting it.
            if (LicenseConfig::requireHttps() && !LicenseConfig::requestIsSecure()) {
                throw new LicenseException(
                    'Activation must use https:// — your licence key would otherwise be sent in the clear. '
                    . 'Use the https address from your activation code.',
                    403
                );
            }
            return $server->activate($key, $in, $ip);
        });
    }

    /** POST /api/license/renew — daily rotation: hand back a fresh 24-hour lease. */
    public function renew(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $id     = trim((string) ($in['instanceId'] ?? ''));
            $secret = (string) ($in['secret'] ?? '');
            if ($id === '' || $secret === '') throw new LicenseException('Missing installation credentials.', 400);
            return $server->renew($id, $secret, $in, $ip);
        });
    }

    /**
     * POST /api/license/advisories — "any new notices/patches for me?".
     *
     * A licensed client's lightweight pull between full renewals. Authenticated
     * with the same id + secret; returns only a SIGNED advisory feed and never
     * touches entitlement, so it is safe to call on every ordinary check-in.
     */
    public function advisories(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $id     = trim((string) ($in['instanceId'] ?? ''));
            $secret = (string) ($in['secret'] ?? '');
            if ($id === '' || $secret === '') throw new LicenseException('Missing installation credentials.', 400);
            return $server->advisoriesFeed($id, $secret, $ip);
        });
    }

    /**
     * POST /api/license/event — a client reporting a security event about itself
     * (currently: its own files no longer match the signed release).
     *
     * Authenticated with the same credentials as a renewal and never alters the
     * caller's entitlement: the client is already enforcing locally, so this is
     * purely so the vendor sees it, with IP, domain and admin email attached.
     */
    public function event(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $id     = trim((string) ($in['instanceId'] ?? ''));
            $secret = (string) ($in['secret'] ?? '');
            if ($id === '' || $secret === '') throw new LicenseException('Missing installation credentials.', 400);
            return $server->securityEvent(
                $id, $secret,
                (string) ($in['kind'] ?? 'unknown'),
                is_array($in['detail'] ?? null) ? $in['detail'] : [],
                is_array($in['telemetry'] ?? null) ? $in['telemetry'] : [],
                $ip
            );
        });
    }

    /** POST /api/license/deactivate — give the seat back. */
    public function deactivate(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $id     = trim((string) ($in['instanceId'] ?? ''));
            $secret = (string) ($in['secret'] ?? '');
            if ($id === '' || $secret === '') throw new LicenseException('Missing installation credentials.', 400);
            return $server->deactivate($id, $secret, $ip);
        });
    }

    /**
     * POST /api/license/pair — a backup server redeems a one-time pairing code
     * and receives the signing key, config and a first data snapshot. This reply
     * carries the signing key, so it must go over HTTPS.
     */
    public function pair(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            if (\PpPhp\Core\Licensing\LicenseConfig::requireHttps() && !\PpPhp\Core\Licensing\LicenseConfig::requestIsSecure()) {
                throw new LicenseException('Pairing must use https:// — it carries the signing key. Use the main server\'s https address.', 403);
            }
            $code = trim((string) ($in['code'] ?? ''));
            if ($code === '') throw new LicenseException('No pairing code was supplied.', 400);
            return (new \PpPhp\Core\Licensing\LicenseSync())->redeemPairing($code, $in, $ip);
        });
    }

    /** POST /api/license/sync — a paired backup pulls the current snapshot. */
    public function sync(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $secret = (string) ($in['syncSecret'] ?? '');
            if ($secret === '') throw new LicenseException('Missing sync credentials.', 400);
            return (new \PpPhp\Core\Licensing\LicenseSync())->sync($secret, $ip);
        });
    }

    /**
     * POST /api/license/db-clone-manifest — a paired backup asks what user data
     * this main currently holds, so it can decide what is worth fetching.
     */
    public function dbCloneManifest(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $secret = (string) ($in['syncSecret'] ?? '');
            if ($secret === '') throw new LicenseException('Missing sync credentials.', 400);
            return (new \PpPhp\Core\Licensing\LicenseSync())->dbCloneManifest($secret, $ip);
        });
    }

    /**
     * POST /api/license/db-clone-pull — a paired backup fetches one database's
     * dump. Read-only on this side: the main never learns where it is restored.
     */
    public function dbClonePull(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $secret = (string) ($in['syncSecret'] ?? '');
            if ($secret === '') throw new LicenseException('Missing sync credentials.', 400);
            $tables = isset($in['tables']) && is_array($in['tables'])
                ? array_values(array_map('strval', $in['tables'])) : null;
            $drop = isset($in['drop']) && is_array($in['drop'])
                ? array_values(array_map('strval', $in['drop'])) : [];
            return (new \PpPhp\Core\Licensing\LicenseSync())
                ->dbCloneDump($secret, $ip, (string) ($in['db'] ?? ''), $tables, $drop);
        });
    }

    /**
     * POST /api/license/set-manifest — a paired backup lists the files in one
     * replication set (virtual DBs, project trees) with their hashes, to decide
     * what has changed. Sync-secret authenticated; served only by the main.
     */
    public function setManifest(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $secret = (string) ($in['syncSecret'] ?? '');
            if ($secret === '') throw new LicenseException('Missing sync credentials.', 400);
            return (new \PpPhp\Core\Licensing\LicenseSync())
                ->fileSetManifest($secret, $ip, (string) ($in['set'] ?? ''));
        });
    }

    /**
     * POST /api/license/set-pull — a paired backup fetches one file (or byte
     * range) from a replication set, gzipped + base64'd. Path is vetted so it can
     * never escape the set root.
     */
    public function setPull(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $secret = (string) ($in['syncSecret'] ?? '');
            if ($secret === '') throw new LicenseException('Missing sync credentials.', 400);
            return (new \PpPhp\Core\Licensing\LicenseSync())->fileSetChunk(
                $secret, $ip,
                (string) ($in['set'] ?? ''), (string) ($in['path'] ?? ''),
                (int) ($in['offset'] ?? 0), (int) ($in['len'] ?? 0)
            );
        });
    }

    /**
     * POST /api/license/owner-reset — a licensed install with no local mail asks
     * the main to email an owner password-reset link to the vendor's recovery
     * address. Licence-gated; the link is only ever sent to the fixed vendor
     * address, never one the caller supplies.
     */
    public function ownerReset(): void
    {
        $this->run(function (LicenseServer $server, array $in, string $ip) {
            $id       = trim((string) ($in['instanceId'] ?? ''));
            $secret   = (string) ($in['secret'] ?? '');
            $resetUrl = (string) ($in['resetUrl'] ?? '');
            if ($id === '' || $secret === '') throw new LicenseException('Missing installation credentials.', 400);
            return \PpPhp\Core\Licensing\OwnerRecovery::relay($id, $secret, $resetUrl, $ip);
        });
    }
}
