<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

/**
 * Tamper detection for the files that enforce licensing.
 *
 * The weakness of every licence check is that it is a boolean somebody can flip.
 * This raises the cost of doing that: at build time the vendor records the
 * SHA-256 of each enforcement-critical file into a manifest and SIGNS it with the
 * licence signing key. At runtime the client re-hashes those files and compares.
 *
 * Because the manifest is signed with a key the customer does not hold, they
 * cannot re-issue one to match edited code. Their options are to edit the files
 * (detected), delete the manifest (fails closed — see verify()), or also edit the
 * verifier itself, which is why this file is in its own manifest and why the
 * check is called from several unrelated places rather than one.
 *
 * Honest scope: this is tamper EVIDENCE and cost, not tamper proof. PHP ships as
 * readable source; a determined expert with filesystem access can still unpick
 * it. What this guarantees is that doing so is deliberate, multi-step, and
 * self-reporting — never an accident and never a one-line edit. The genuinely
 * unbypassable control is ProtectedCode, where the licence is a decryption key
 * rather than a boolean.
 */
final class CodeIntegrity
{
    /** Manifest lives inside a code root so platform updates replace it with the code. */
    public const MANIFEST = 'core/licence-integrity.json';

    /**
     * Files whose bytes decide whether this copy is licensed. Kept deliberately
     * short: every entry is hashed on a locked-path request, and a long list buys
     * little once the enforcement chain itself is covered.
     *
     * Paths are repo-relative. Missing files are simply skipped when the manifest
     * is built (a client bundle legitimately omits the server-side classes).
     */
    public const PROTECTED = [
        'core/Licensing/LicenseGate.php',
        'core/Licensing/LicenseClient.php',
        'core/Licensing/LicenseProtocol.php',
        'core/Licensing/LicenseTrust.php',
        'core/Licensing/CodeIntegrity.php',
        'core/Licensing/ProtectedCode.php',
        'core/Licensing/Fingerprint.php',
        'public/index.php',
    ];

    /** Verdicts. */
    public const OK        = 'ok';        // every protected file matches
    public const MODIFIED  = 'modified';  // at least one file was edited
    public const MISSING   = 'missing';   // manifest absent/unsigned on a stamped build
    public const UNSIGNED  = 'unsigned';  // development source — nothing to enforce

    private static ?array $cache = null;

    private static function base(?string $basePath = null): string
    {
        $basePath ??= defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        return rtrim($basePath, '/');
    }

    /**
     * Build the manifest body (unsigned). Called by the packager at build time.
     *
     * @return array{v:int,built:int,files:array<string,string>}
     */
    public static function collect(?string $basePath = null): array
    {
        $base = self::base($basePath);
        $files = [];
        foreach (self::PROTECTED as $rel) {
            $abs = $base . '/' . $rel;
            if (is_file($abs)) {
                $files[$rel] = (string) hash_file('sha256', $abs);
            }
        }
        return ['v' => 1, 'built' => time(), 'files' => $files];
    }

    /**
     * Sign a manifest with the licence signing key and write it. Vendor-side —
     * needs the secret key, so only the build machine can produce one.
     */
    public static function write(string $secretKey, ?string $basePath = null, ?array $body = null): bool
    {
        $body ??= self::collect($basePath);
        $json = (string) json_encode($body, JSON_UNESCAPED_SLASHES);
        $sig  = sodium_crypto_sign_detached($json, $secretKey);
        $doc  = ['body' => $body, 'sig' => LicenseProtocol::b64u($sig)];
        $path = self::base($basePath) . '/' . self::MANIFEST;
        return @file_put_contents($path, (string) json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;
    }

    /**
     * Check this installation against its signed manifest.
     *
     * Fails CLOSED on a stamped build: deleting the manifest, truncating it or
     * breaking its signature all read as tampering, because a genuine vendor
     * bundle always ships a valid one. On unstamped source there is nothing to
     * verify and this is a no-op.
     *
     * @return array{status:string, changed:array<int,string>, reason:string}
     */
    public static function verify(?string $basePath = null): array
    {
        if (self::$cache !== null) return self::$cache;

        $trusted = LicenseTrust::key();
        if ($trusted === '') {
            return self::$cache = ['status' => self::UNSIGNED, 'changed' => [], 'reason' => ''];
        }

        $base = self::base($basePath);
        $raw  = @file_get_contents($base . '/' . self::MANIFEST);
        $doc  = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;

        if (!is_array($doc) || !isset($doc['body'], $doc['sig']) || !is_array($doc['body'])) {
            // Fail closed ONLY for a distributed bundle. Every released bundle
            // ships release.json (the packager writes it fresh); a working source
            // tree does not. Without this the vendor's own dev box — which is
            // stamped but has no manifest — would lock itself the moment this
            // check shipped, and so would any install mid-update.
            if (!is_file($base . '/release.json')) {
                return self::$cache = ['status' => self::UNSIGNED, 'changed' => [], 'reason' => ''];
            }
            return self::$cache = ['status' => self::MISSING, 'changed' => [],
                'reason' => 'The integrity manifest is missing or unreadable.'];
        }

        // The signature is Ed25519 — verified via the PHP `sodium` extension. On a
        // host without it, referencing SODIUM_CRYPTO_SIGN_BYTES / calling the verify
        // function would fatal; fail closed with a clear reason instead (callers show
        // an actionable "enable sodium" message rather than a blank 500).
        if (!function_exists('sodium_crypto_sign_verify_detached') || !defined('SODIUM_CRYPTO_SIGN_BYTES')) {
            return self::$cache = ['status' => self::MISSING, 'changed' => [],
                'reason' => 'The PHP sodium extension is required to verify the build signature.'];
        }

        $json = (string) json_encode($doc['body'], JSON_UNESCAPED_SLASHES);
        $sig  = LicenseProtocol::unb64u((string) $doc['sig']);
        if (strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) {
            return self::$cache = ['status' => self::MISSING, 'changed' => [],
                'reason' => 'The integrity manifest is not properly signed.'];
        }
        try {
            if (!sodium_crypto_sign_verify_detached($sig, $json, $trusted)) {
                return self::$cache = ['status' => self::MISSING, 'changed' => [],
                    'reason' => 'The integrity manifest was not signed by the vendor.'];
            }
        } catch (\Throwable $e) {
            return self::$cache = ['status' => self::MISSING, 'changed' => [], 'reason' => 'The integrity manifest could not be checked.'];
        }

        $changed = [];
        foreach ((array) ($doc['body']['files'] ?? []) as $rel => $want) {
            $abs = $base . '/' . $rel;
            if (!is_file($abs)) { $changed[] = $rel; continue; }
            $got = (string) hash_file('sha256', $abs);
            if (!hash_equals((string) $want, $got)) $changed[] = $rel;
        }

        if ($changed !== []) {
            return self::$cache = ['status' => self::MODIFIED, 'changed' => $changed,
                'reason' => 'Protected program files have been modified.'];
        }
        return self::$cache = ['status' => self::OK, 'changed' => [], 'reason' => ''];
    }

    /** True when this copy's enforcement files have been tampered with. */
    public static function tampered(?string $basePath = null): bool
    {
        $v = self::verify($basePath);
        return $v['status'] === self::MODIFIED || $v['status'] === self::MISSING;
    }

    /** Reset the memo (tests only). */
    public static function flush(): void
    {
        self::$cache = null;
    }
}
