<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

/**
 * The contract shared by the licence server and every hosted client.
 *
 * A LEASE is a short-lived, Ed25519-signed statement from the licence server
 * that a particular instance is entitled to run. Clients verify it offline with
 * an embedded public key, so:
 *   • a man-in-the-middle cannot forge or alter one,
 *   • the client needs no round-trip to re-check during its grace window,
 *   • the licence server being unreachable degrades gracefully instead of
 *     taking a customer's site down with it.
 *
 * Wire format (compact, URL-safe, no JWT dependency):
 *
 *     PPL1.<base64url(json payload)>.<base64url(ed25519 signature)>
 *
 * Every field the client enforces lives INSIDE the signed payload — nothing
 * meaningful is carried outside the signature.
 */
final class LicenseProtocol
{
    public const TOKEN_PREFIX = 'PPL1';

    /** How long a lease is valid before the client should renew (seconds). */
    public const LEASE_TTL = 86400;             // 24 h — "rotates daily"

    /** Renew this far before expiry so a hiccup has room to retry. */
    public const RENEW_AHEAD = 21600;           // 6 h

    /**
     * How often an activated client checks in with the mesh even when its lease
     * is nowhere near expiry — to confirm the licence, learn the current backup
     * addresses, and pick up a revocation quickly. Opportunistic: it runs on a
     * console page load, at most once per interval.
     */
    public const HEARTBEAT_INTERVAL = 3600;     // 1 h

    /**
     * RUNTIME grace: how long an ALREADY-ACTIVATED client keeps working after its
     * last SUCCESSFUL contact when neither the main nor any backup can be reached.
     * Long enough to ride out a transient outage / long weekend of the whole mesh,
     * then the console locks with a "contact support" message. Resets on every
     * successful check.
     */
    public const RUNTIME_GRACE = 259200;        // 72 h

    /**
     * FRESH-INSTALL grace — the free TRIAL. How long a never-activated copy may run
     * before it must enter a licence key. After this window a copy with no genuine
     * licence locks completely (status NONE → LicenseGate::locked()). Kept short and
     * deliberate: enough to evaluate the product, not to run a business on. Unrelated
     * to the runtime grace above (which covers an outage of an ALREADY-licensed copy).
     */
    public const GRACE_PERIOD = 259200;         // 3 days — trial

    /** Clock skew tolerated when checking notBefore (seconds). */
    public const CLOCK_SKEW = 300;

    /**
     * Fallback support contact shown when a client locks. The vendor can override
     * it centrally (it rides inside the signed lease as `sup`); this is what a
     * client shows before any lease has carried one.
     */
    public const SUPPORT_CONTACT = 'ppradhan@live.com';

    /** Statuses a lease may carry. */
    public const OK = 'ok';
    public const GRACE = 'grace';
    public const EXPIRED = 'expired';
    public const REVOKED = 'revoked';
    public const NONE = 'none';
    public const INVALID = 'invalid';
    /** Protected program files were modified — enforcement code was tampered with. */
    public const TAMPERED = 'tampered';

    public static function b64u(string $raw): string
    {
        return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
    }

    public static function unb64u(string $s): string
    {
        $pad = strlen($s) % 4;
        if ($pad) $s .= str_repeat('=', 4 - $pad);
        return (string) base64_decode(strtr($s, '-_', '+/'), true);
    }

    /**
     * Sign a payload into a lease token. Server-side only — needs the secret key.
     *
     * @param array  $payload  claims (see LicenseServer::issueLease)
     * @param string $secretKey 64-byte Ed25519 secret key
     */
    public static function sign(array $payload, string $secretKey): string
    {
        $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);
        $body = self::b64u($json);
        $sig  = sodium_crypto_sign_detached(self::TOKEN_PREFIX . '.' . $body, $secretKey);
        return self::TOKEN_PREFIX . '.' . $body . '.' . self::b64u($sig);
    }

    /**
     * Verify a token's signature and return its claims, or null if the token is
     * malformed or not signed by the holder of $publicKey.
     *
     * Signature is checked BEFORE the payload is trusted for anything.
     */
    public static function verify(string $token, string $publicKey): ?array
    {
        $parts = explode('.', trim($token));
        if (count($parts) !== 3 || $parts[0] !== self::TOKEN_PREFIX) return null;
        [$prefix, $body, $sig64] = $parts;

        $sig = self::unb64u($sig64);
        if (strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) return null;
        if (strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) return null;

        try {
            if (!sodium_crypto_sign_verify_detached($sig, $prefix . '.' . $body, $publicKey)) {
                return null;
            }
        } catch (\Throwable $e) {
            return null;
        }
        $claims = json_decode(self::unb64u($body), true);
        return is_array($claims) ? $claims : null;
    }

    /**
     * Evaluate verified claims against the clock and this instance.
     *
     * `$now` is passed in so the caller can supply a monotonic "never goes
     * backwards" time — winding the server clock back must not extend grace.
     *
     * @return array{status:string, reason:string, expiresIn:int, graceLeft:int}
     */
    public static function evaluate(array $claims, string $instanceId, int $now, int $lastContact, ?int $runtimeGrace = null): array
    {
        // Owner-tunable outage grace (rides the lease as `rg`); the const is the floor.
        $grace = ($runtimeGrace !== null && $runtimeGrace > 0) ? $runtimeGrace : self::RUNTIME_GRACE;
        $exp   = (int) ($claims['exp'] ?? 0);
        $nbf   = (int) ($claims['nbf'] ?? 0);
        $inst  = (string) ($claims['iid'] ?? '');
        $state = (string) ($claims['st'] ?? self::OK);

        if ($inst !== '' && $instanceId !== '' && !hash_equals($inst, $instanceId)) {
            return self::verdict(self::INVALID, 'This lease was issued to a different installation.', 0, 0);
        }
        if ($nbf > 0 && $now + self::CLOCK_SKEW < $nbf) {
            return self::verdict(self::INVALID, 'Lease is not valid yet — check the server clock.', 0, 0);
        }
        if ($state === self::REVOKED) {
            return self::verdict(self::REVOKED, 'This installation was deactivated by the licence holder.', 0, 0);
        }

        if ($exp > $now) {
            return self::verdict(self::OK, 'Licensed.', $exp - $now, $grace);
        }

        // The licence's own end date is final. Grace exists to cover an
        // unreachable server, not to extend a subscription that has genuinely
        // run out — so once the term is over, the current lease plays out and
        // that is the end of it.
        $licenseEnds = (int) ($claims['lexp'] ?? 0);
        if ($licenseEnds > 0 && $now > $licenseEnds) {
            return self::verdict(self::EXPIRED, 'This licence reached its end date on '
                . date('j M Y', $licenseEnds) . '.', 0, 0);
        }

        // Lease expired → we are running on grace, measured from the last
        // SUCCESSFUL server contact (not from expiry, so repeated failures
        // cannot silently extend the window).
        $graceLeft = ($lastContact + $grace) - $now;
        if ($graceLeft > 0) {
            return self::verdict(self::GRACE, 'Licence server unreachable — running on grace.', 0, $graceLeft);
        }
        return self::verdict(self::EXPIRED, 'Licence could not be renewed within the grace period.', 0, 0);
    }

    private static function verdict(string $status, string $reason, int $expiresIn, int $graceLeft): array
    {
        return ['status' => $status, 'reason' => $reason,
                'expiresIn' => max(0, $expiresIn), 'graceLeft' => max(0, $graceLeft)];
    }

    /** Human summary for the admin UI. */
    public static function describe(array $verdict): string
    {
        return match ($verdict['status']) {
            self::OK      => 'Licensed — renews in ' . self::human($verdict['expiresIn']),
            self::GRACE   => 'Grace period — ' . self::human($verdict['graceLeft']) . ' remaining',
            self::EXPIRED => 'Licence expired',
            self::REVOKED => 'Installation deactivated',
            self::NONE    => 'Not activated',
            default       => 'Licence invalid',
        };
    }

    public static function human(int $seconds): string
    {
        if ($seconds >= 172800) return intdiv($seconds, 86400) . ' days';
        if ($seconds >= 7200)   return intdiv($seconds, 3600) . ' hours';
        if ($seconds >= 120)    return intdiv($seconds, 60) . ' minutes';
        return max(0, $seconds) . ' seconds';
    }
}
