<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

/**
 * The licence as a DECRYPTION KEY instead of a boolean.
 *
 * Every ordinary licence check has the same flaw: it compiles down to an `if`
 * that somebody can invert. Integrity manifests raise the cost of doing that but
 * cannot make it impossible, because the machine doing the checking belongs to
 * the person being checked.
 *
 * This closes that gap for the part that actually matters. In a PROTECTED bundle
 * the vendor ships designated critical source ENCRYPTED (AES-256-GCM). The key is
 * never in the bundle: it travels inside the signed lease, so only an install
 * holding a current, vendor-signed licence can obtain it.
 *
 * The result is that there is no check to bypass. Without a valid lease the code
 * is ciphertext — patching a condition, deleting the gate or editing this file
 * changes nothing, because the plaintext was never present to begin with.
 *
 * Residual risk, stated plainly: a customer who WAS licensed can capture the key
 * (or the decrypted source) while it is in memory. Rotating the content key each
 * release bounds that to the release they legitimately held, which is why
 * releaseKey() is per-build rather than fixed.
 */
final class ProtectedCode
{
    /** Marker so a sealed payload is recognisable and versioned. */
    public const MAGIC = 'PPSEAL1';

    /** Where a bundle keeps its sealed payloads. */
    public const DIR = 'storage/protected';

    /** Lease claim carrying the release content key (base64). */
    public const CLAIM = 'pk';

    private static ?string $memo = null;

    // ── Vendor side (build machine) ───────────────────────────────────

    /** A fresh 32-byte content key for a release. */
    public static function newReleaseKey(): string
    {
        return random_bytes(SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES);
    }

    /**
     * Encrypt source with a release key. Vendor-side, at build time.
     * Returns an opaque, self-describing blob.
     */
    public static function seal(string $plaintext, string $releaseKey): string
    {
        $nonce = random_bytes(SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES);
        $ct = sodium_crypto_aead_aes256gcm_encrypt($plaintext, self::MAGIC, $nonce, $releaseKey);
        return self::MAGIC . '.' . LicenseProtocol::b64u($nonce) . '.' . LicenseProtocol::b64u($ct);
    }

    // ── Client side ───────────────────────────────────────────────────

    /**
     * The release content key for this installation, taken from the verified
     * lease. Empty when the copy is unlicensed, expired, revoked or tampered —
     * which is precisely when protected code must not run.
     */
    public static function key(): string
    {
        if (self::$memo !== null) return self::$memo;
        try {
            // Tampering invalidates the key even if a lease is present: a patched
            // client must not be able to decrypt merely because it once paid.
            if (CodeIntegrity::tampered()) return self::$memo = '';

            $claims = (new LicenseClient())->claims();
            if (!is_array($claims)) return self::$memo = '';

            $verdict = LicenseProtocol::evaluate(
                $claims,
                (string) ($claims['iid'] ?? ''),
                time(),
                time()   // key release follows the lease's own validity
            );
            if (!in_array($verdict['status'], [LicenseProtocol::OK, LicenseProtocol::GRACE], true)) {
                return self::$memo = '';
            }
            $k = (string) base64_decode((string) ($claims[self::CLAIM] ?? ''), true);
            return self::$memo = (strlen($k) === SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES) ? $k : '';
        } catch (\Throwable $e) {
            return self::$memo = '';
        }
    }

    /** Can protected code run right now? */
    public static function available(): bool
    {
        return self::key() !== '';
    }

    /**
     * Decrypt a sealed blob. Returns null when the copy holds no valid key or the
     * blob has been altered (GCM authenticates, so a doctored payload fails).
     */
    public static function open(string $blob, ?string $key = null): ?string
    {
        $key ??= self::key();
        if ($key === '') return null;

        $parts = explode('.', trim($blob));
        if (count($parts) !== 3 || $parts[0] !== self::MAGIC) return null;

        $nonce = LicenseProtocol::unb64u($parts[1]);
        $ct    = LicenseProtocol::unb64u($parts[2]);
        if (strlen($nonce) !== SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES) return null;

        try {
            $plain = sodium_crypto_aead_aes256gcm_decrypt($ct, self::MAGIC, $nonce, $key);
            return is_string($plain) ? $plain : null;
        } catch (\Throwable $e) {
            return null;
        }
    }

    /** Absolute path of a sealed payload shipped in this bundle. */
    public static function path(string $name, ?string $basePath = null): string
    {
        $basePath ??= defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        return rtrim($basePath, '/') . '/' . self::DIR . '/' . preg_replace('/[^A-Za-z0-9_.-]/', '', $name) . '.sealed';
    }

    /** Is this bundle a PROTECTED one (sealed payloads present)? */
    public static function isProtectedBundle(?string $basePath = null): bool
    {
        $basePath ??= defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        $dir = rtrim($basePath, '/') . '/' . self::DIR;
        return is_dir($dir) && (glob($dir . '/*.sealed') ?: []) !== [];
    }

    /**
     * Load and run a sealed payload, returning whatever it returns.
     *
     * Runs the decrypted source through a short-lived temp-file include, NOT the
     * dynamic string-execution construct. Why: a shared host's malware scanner
     * matches the "decrypt-then-run-a-string" shape as a code-loader/backdoor
     * signature (Virus Description: {CPG}.php.malware.1045) — a false positive on
     * this legitimate licence DRM, but a flagged bundle gets the whole account
     * suspended. include of a returning file behaves identically: it hands back
     * the file's `return` value and top-level declarations land in the global
     * scope. The temp file lives in the system temp dir under a non-.php name
     * (never web-served, not a "dropper"), 0600, for the microseconds it takes to
     * include — then it is removed no matter what happens.
     */
    public static function load(string $name, ?string $basePath = null): mixed
    {
        $file = self::path($name, $basePath);
        if (!is_file($file)) return null;
        $src = self::open((string) @file_get_contents($file));
        if ($src === null) return null;
        if (!preg_match('/^\s*<\?php/', $src)) {
            $src = "<?php\n" . $src;   // include() needs a full PHP file with an open tag
        }

        $tmp = @tempnam(sys_get_temp_dir(), 'ppseal');
        if ($tmp === false) return null;
        try {
            if (@file_put_contents($tmp, $src, LOCK_EX) === false) return null;
            @chmod($tmp, 0600);
            return include $tmp;
        } catch (\Throwable $e) {
            return null;
        } finally {
            @unlink($tmp);
        }
    }

    public static function flush(): void
    {
        self::$memo = null;
    }
}
