<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

/**
 * What actually happens when a licence lapses.
 *
 * The rule, deliberately: **the design console locks; generated apps keep
 * running.** A customer's live site — their orders, their staff, their public
 * forms — is not our leverage. It was built with PPPHP but it is theirs, and it
 * contains no licensing code at all (nothing under core/Licensing is copied into
 * a build), so it cannot be switched off from here even by accident.
 *
 * What stops is the tool: designing, building and deploying. That is the part
 * being licensed, it is recoverable the moment a valid licence is in place, and
 * losing it for a day costs a customer nothing they cannot get back.
 *
 * Enforcement only applies to a STAMPED build (LicenseTrust) — the vendor's
 * released bundles. Working from unstamped source, everything is unrestricted.
 */
final class LicenseGate
{
    /** Paths always reachable, even locked: sign in/out, licensing itself, assets. */
    private const ALWAYS_ALLOWED = [
        '/login', '/logout', '/register', '/forgot-password', '/reset-password',
        '/licensing', '/api/license', '/module-asset', '/assets', '/health',
    ];

    private static ?array $cached = null;

    /** Is licensing enforced in this copy at all? */
    public static function enforced(): bool
    {
        // A backup/secondary licence server is the vendor's own failover clone of
        // the main — redundancy only. It holds no client licence of its own and
        // must never be gated, locked, or nagged about an expiry date. Enforcement
        // is for the distributed CLIENT copies, not for the mesh that serves them.
        // (This check wins even over an explicit PP_LICENSE_ENFORCE stamp, because
        // a backup is literally a copy of the enforced main build.)
        try {
            if (ServerRole::isBackup()) return false;
        } catch (\Throwable $e) {
            // Role unknown this early (e.g. no DB yet) — fall through to the normal
            // rule; a genuine backup ships config/server-role.json so this is rare.
        }

        // A licence AUTHORITY — any install that holds a signing key — is the
        // SOURCE of licences: it issues them, it does not consume one. Nagging or
        // locking it against a licence it would sign for itself is circular and
        // confusing (it made the main server show "activate — 13 days left", as if
        // it were a customer). Customer copies ship with LicenseKeys stripped, so
        // they are never treated as an authority and remain fully enforced.
        if (self::isAuthority()) return false;

        if (defined('PP_LICENSE_ENFORCE')) return (bool) constant('PP_LICENSE_ENFORCE');
        return LicenseTrust::isStamped();
    }

    /**
     * Does this install hold a signing key — i.e. is it a licence authority
     * (the vendor's main server or a backup of it), rather than a customer copy?
     *
     * The client edition ships with LicenseKeys stripped, so this is always false
     * for a customer install. Guarded with class_exists for exactly that reason.
     */
    public static function isAuthority(): bool
    {
        try {
            return class_exists(\PpPhp\Core\Licensing\LicenseKeys::class)
                && (new \PpPhp\Core\Licensing\LicenseKeys())->exists();
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Is this a CLIENT edition — a distributed customer copy — rather than the
     * vendor's own server/authority or a full source checkout?
     *
     * The packager strips the licence-AUTHORITY code (LicenseServer) from client
     * bundles, so its absence is the reliable "I am a customer copy" signal. Used
     * to hide vendor/platform surfaces (the Establish-Owner shortcut, the internal
     * Help guides, the manual update paths) that only make sense on the vendor side.
     */
    public static function isClientEdition(): bool
    {
        return !class_exists(\PpPhp\Core\Licensing\LicenseServer::class);
    }

    /** Current licence status (computed at most once per request). */
    public static function status(): array
    {
        if (self::$cached === null) {
            try {
                self::$cached = (new LicenseClient())->status();
            } catch (\Throwable $e) {
                // A broken licence file must not white-screen the console; treat
                // it as "needs attention", which the gate handles gracefully.
                self::$cached = ['status' => LicenseProtocol::INVALID, 'activated' => false,
                                 'reason' => 'The licence could not be read.', 'expiresIn' => 0, 'graceLeft' => 0];
            }

            // Tampering outranks everything the lease says. A copy whose
            // enforcement files have been edited is not "licensed with a
            // problem", it is a copy whose answers can no longer be believed —
            // so this is evaluated last and overwrites the verdict.
            try {
                if (CodeIntegrity::tampered()) {
                    $v = CodeIntegrity::verify();
                    self::$cached['status']  = LicenseProtocol::TAMPERED;
                    self::$cached['reason']  = $v['reason'] ?: 'Protected program files have been modified.';
                    self::$cached['changed'] = $v['changed'];
                    self::$cached['expiresIn'] = 0;
                    self::$cached['graceLeft'] = 0;
                }
            } catch (\Throwable $e) {
                // never white-screen on the integrity path itself
            }
        }
        return self::$cached;
    }

    /** True when the console should be locked. */
    public static function locked(): bool
    {
        if (!self::enforced()) return false;
        return in_array(self::status()['status'] ?? '', [
            LicenseProtocol::EXPIRED, LicenseProtocol::REVOKED,
            LicenseProtocol::NONE, LicenseProtocol::INVALID,
            LicenseProtocol::TAMPERED,
        ], true);
    }

    /** True when this copy is locked specifically because files were edited. */
    public static function tampered(): bool
    {
        return self::enforced() && (self::status()['status'] ?? '') === LicenseProtocol::TAMPERED;
    }

    /** True when running, but the user should be told something needs doing. */
    public static function warning(): bool
    {
        return self::enforced() && (self::status()['status'] ?? '') === LicenseProtocol::GRACE;
    }

    /** Support contact to show when this copy is locked or in grace. */
    public static function supportContact(): string
    {
        $c = trim((string) (self::status()['support'] ?? ''));
        return $c !== '' ? $c : LicenseProtocol::SUPPORT_CONTACT;
    }

    /**
     * Renew opportunistically. Called on ordinary page loads, so it is
     * rate-limited inside the client and silent on failure — a page load must
     * never wait on, or fail because of, the licence server.
     */
    public static function tick(): void
    {
        if (!self::enforced()) return;
        try {
            $client = new LicenseClient();
            if ($client->renewalDue()) {
                $client->renew();
                self::$cached = null;
            }
        } catch (\Throwable $e) {
            // silent by design
        }
    }

    /**
     * The gate itself: call once per console request, before dispatch.
     * Returns true if it handled the request (and already sent output).
     */
    public static function guard(string $requestUri, string $baseUrl = ''): bool
    {
        if (!self::enforced()) return false;

        // Tampering is absolute: there is no allow-list, no styled page borrowing
        // the app's assets, and no activation form — activating cannot repair
        // edited files, so offering it would only invite another attempt. Every
        // route serves the same self-contained notice until the files are
        // restored, which the vendor can confirm remotely from the report below.
        if (self::tampered()) {
            // An interrupted platform update (process killed mid-write: OOM, host
            // kill, power loss — none catchable in apply()) leaves the code and the
            // signed integrity manifest out of sync, which is indistinguishable from
            // tampering. Before locking the console with only FTP recovery, if the
            // updater left an apply-in-progress marker, roll back to the previous
            // consistent, vendor-signed version and re-check. Recovery can only
            // revert to a snapshot the updater itself took, so it is not a bypass;
            // genuine tampering (no marker) still hard-locks.
            if (self::tryRecoverInterruptedApply() && !self::tampered()) {
                // recovered — fall through to the normal licensing checks below.
            } else {
                self::report('tamper', self::status()['changed'] ?? []);
                self::hardLock();
                return true;
            }
        }

        foreach (self::ALWAYS_ALLOWED as $allow) {
            if ($requestUri === $allow || str_starts_with($requestUri, $allow . '/')) {
                self::tick();
                return false;
            }
        }

        self::tick();
        if (!self::locked()) return false;

        // API callers get JSON; people get the page explaining what to do.
        $wantsJson = str_starts_with($requestUri, '/api/')
            || str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')
            || strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';

        if ($wantsJson) {
            $support = self::supportContact();
            http_response_code(402);
            header('Content-Type: application/json');
            echo json_encode([
                'success' => false,
                'license' => self::status()['status'],
                'support' => $support,
                'message' => (self::status()['reason'] ?? 'This copy of PPPHP is not licensed.')
                    . ' Please contact ' . $support . '.',
            ]);
            return true;
        }

        header('Location: ' . rtrim($baseUrl, '/') . '/licensing?locked=1');
        return true;
    }

    /**
     * The uncompromising lock: a complete, self-contained response that depends on
     * no stylesheet, script or route of the application, so nothing about it can
     * be disabled by editing the app around it. Sent with no-store headers and a
     * 402 so intermediaries do not cache a locked page either.
     */
    private static function hardLock(): void
    {
        $support = self::supportContact();
        $status  = self::status();
        $changed = (array) ($status['changed'] ?? []);

        if (!headers_sent()) {
            http_response_code(402);
            header('Content-Type: text/html; charset=utf-8');
            header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
            header('Pragma: no-cache');
            header('X-Robots-Tag: noindex, nofollow');
        }

        $wantsJson = str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')
            || strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
        if ($wantsJson) {
            header('Content-Type: application/json');
            echo json_encode(['success' => false, 'license' => LicenseProtocol::TAMPERED,
                'support' => $support,
                'message' => 'This installation has been modified and is locked. Contact ' . $support . '.']);
            return;
        }

        $e = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
        $list = '';
        foreach (array_slice($changed, 0, 12) as $f) {
            $list .= '<li><code>' . $e((string) $f) . '</code></li>';
        }
        $files = $list !== '' ? '<p class="l">Files that no longer match the signed release:</p><ul>' . $list . '</ul>' : '';
        $ref = substr(hash('sha256', (string) ($_SERVER['HTTP_HOST'] ?? '') . '|' . implode(',', $changed)), 0, 12);

        echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
           . '<meta name="viewport" content="width=device-width,initial-scale=1">'
           . '<meta name="robots" content="noindex,nofollow"><title>Installation locked</title><style>'
           . 'html,body{margin:0;height:100%;background:#0b0f17;color:#e6edf6;'
           . 'font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}'
           . '.w{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px}'
           . '.c{max-width:620px;width:100%;background:#121826;border:1px solid #26314a;'
           . 'border-radius:16px;padding:28px 30px;box-shadow:0 24px 70px rgba(0,0,0,.5)}'
           . '.i{width:52px;height:52px;border-radius:14px;background:#7f1d1d;color:#fecaca;'
           . 'display:flex;align-items:center;justify-content:center;font-size:26px;margin-bottom:16px}'
           . 'h1{font-size:20px;margin:0 0 10px;letter-spacing:-.01em}'
           . 'p{margin:0 0 12px;color:#aebad0}.l{margin-top:18px;color:#8b98b0;font-size:13px}'
           . 'ul{margin:6px 0 0;padding-left:20px;color:#8b98b0;font-size:13px}'
           . 'code{background:#0b0f17;border:1px solid #26314a;border-radius:5px;padding:1px 6px;font-size:12.5px}'
           . 'a{color:#93c5fd}.r{margin-top:20px;padding-top:14px;border-top:1px solid #26314a;'
           . 'font-size:12px;color:#6b7688}</style></head><body><div class="w"><div class="c">'
           . '<div class="i">&#9888;</div>'
           . '<h1>This installation is locked</h1>'
           . '<p>PPPHP has detected that its program files have been modified, so it cannot verify that '
           . 'this copy is genuine. Access has been suspended.</p>'
           . '<p>To restore access, reinstall the original release or apply the latest update, then contact '
           . '<a href="mailto:' . $e($support) . '"><strong>' . $e($support) . '</strong></a> quoting the '
           . 'reference below.</p>'
           . $files
           . '<p class="l">Applications you have already built are unaffected and continue to run normally.</p>'
           . '<div class="r">Reference ' . $e($ref) . '</div>'
           . '</div></div></body></html>';
    }

    /**
     * Tell the vendor, once an hour at most, that this copy looks tampered with.
     * Best-effort and completely silent: it must never delay or break the lock
     * page, and a client that blocks the call is still locked either way.
     */
    /**
     * Attempt to self-heal an interrupted platform update. Returns true only when
     * a pending apply marker existed and its rollback succeeded, after which the
     * integrity + licence status caches are flushed so the caller re-checks against
     * the rolled-back (consistent, signed) files. No marker → returns false and the
     * caller hard-locks, so real tampering is unaffected.
     */
    private static function tryRecoverInterruptedApply(): bool
    {
        if (!class_exists(\PpPhp\Core\Update\PlatformUpdater::class)) return false;
        try {
            $updater = new \PpPhp\Core\Update\PlatformUpdater();
            if ($updater->applyInProgress() === null) return false;
            $res = $updater->recoverInterruptedApply();
            if (empty($res['recovered'])) return false;
            CodeIntegrity::flush();   // re-read the manifest against restored files
            self::$cached = null;     // re-run status()/tampered() fresh
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    private static function report(string $kind, array $detail = []): void
    {
        try {
            $client = new LicenseClient();
            $client->reportSecurityEvent($kind, $detail);
        } catch (\Throwable $e) {
            // silent by design
        }
    }
}
