<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\Notification;
use PpPhp\Core\PlatformSettings;

/**
 * The client half of the Owner→client push channel.
 *
 * Receives the signed advisory feed the authority sends on a check-in (piggybacked
 * on a licence renewal, or actively pulled between renewals), VERIFIES it against
 * the pinned vendor key — so a mirror or man-in-the-middle can neither forge nor
 * alter it — then, for each advisory it hasn't seen before:
 *
 *   • always drops a notification in the admin's bell (severity-coloured), and
 *   • when the advisory is a CRITICAL patch instruction (action = apply) and this
 *     copy's "auto-apply critical security patches" setting is on, installs the
 *     update through the existing signed pipeline (signature-verified, restore
 *     point first — see AutoUpdater). This is the Rapid-Security-Response half.
 *
 * Everything is idempotent: an advisory is notified once and applied once, tracked
 * by its uid, so a replayed feed changes nothing.
 */
final class AdvisoryClient
{
    private const SEEN_SETTING = 'platform.advisories_seen';
    /** Whether critical security patches auto-apply. Default ON (opt-out). */
    private const AUTO_SETTING = 'platform.update_autosecurity';

    /** Severity → notification type (the bell only knows info/success/warning/error). */
    private const SEV_TYPE = ['info' => 'info', 'success' => 'success', 'warning' => 'warning', 'critical' => 'error'];
    private const SEV_ICON = ['info' => 'megaphone', 'success' => 'check-circle', 'warning' => 'exclamation-triangle', 'critical' => 'shield-exclamation'];

    private ?string $root;

    public function __construct(?string $basePath = null)
    {
        $this->root = $basePath ?? (defined('BASE_PATH') ? (string) constant('BASE_PATH') : null);
    }

    // ── Whether forced security auto-apply is on (opt-out; default ON) ──────────

    public static function autoSecurityEnabled(): bool
    {
        $v = PlatformSettings::get(self::AUTO_SETTING)['value'] ?? null;
        if (!is_array($v) || !array_key_exists('enabled', $v)) return true;   // default ON
        return (bool) $v['enabled'];
    }

    public static function setAutoSecurity(bool $on): void
    {
        PlatformSettings::set(self::AUTO_SETTING, ['value' => ['enabled' => $on]]);
    }

    // ── Active pull (used by the deferred check-in tick) ───────────────────────

    /** Ask the authority for any new advisories and act on them. */
    public function pull(): array
    {
        if (!class_exists(\PpPhp\Core\Licensing\LicenseClient::class)) {
            return ['ok' => false, 'reason' => 'no licence client'];
        }
        try {
            $token = (new \PpPhp\Core\Licensing\LicenseClient($this->root))->fetchAdvisories();
        } catch (\Throwable $e) {
            return ['ok' => false, 'reason' => 'fetch failed'];
        }
        if ($token === '') return ['ok' => true, 'processed' => 0, 'notified' => 0, 'applied' => null];
        return $this->ingest($token);
    }

    // ── Verify + act on a signed feed ──────────────────────────────────────────

    /**
     * @return array{ok:bool,processed?:int,notified?:int,applied?:?string,reason?:string}
     */
    public function ingest(string $token): array
    {
        if ($token === '') return ['ok' => true, 'processed' => 0, 'notified' => 0, 'applied' => null];

        $claims = $this->verify($token);
        if ($claims === null) return ['ok' => false, 'reason' => 'signature'];

        $advisories = is_array($claims['advisories'] ?? null) ? $claims['advisories'] : [];
        $seen    = $this->seen();
        $applied = $this->appliedList();
        $notified = 0;
        $didApply = null;

        foreach ($advisories as $a) {
            if (!is_array($a)) continue;
            $uid = (string) ($a['uid'] ?? '');
            if ($uid === '') continue;

            // Notify once per advisory.
            if (!in_array($uid, $seen, true)) {
                $this->notify($a);
                $seen[] = $uid;
                $notified++;
            }

            // Apply a critical patch once — gated, signed, restore-point first.
            if ($didApply === null
                && ($a['action'] ?? '') === 'apply'
                && ($a['severity'] ?? '') === 'critical'
                && !in_array($uid, $applied, true)
                && $this->mayAutoApply()) {
                $res = $this->applyCritical($a);
                $applied[] = $uid;
                if (!empty($res['applied'])) $didApply = (string) ($res['version'] ?? '?');
            }
        }

        $this->saveSeen($seen, $applied);
        return ['ok' => true, 'processed' => count($advisories), 'notified' => $notified, 'applied' => $didApply];
    }

    /** Verify the token against the pinned key + confirm it was built for us. */
    private function verify(string $token): ?array
    {
        try {
            $pub = '';
            if (class_exists(\PpPhp\Core\Licensing\LicenseClient::class)) {
                $pub = (new \PpPhp\Core\Licensing\LicenseClient($this->root))->pinnedPublicKey();
            }
            if ($pub === '') return null;

            $claims = UpdateProtocol::verify($token, $pub, UpdateProtocol::ADVISORY_PREFIX);
            if ($claims === null) return null;

            // If the feed names its intended recipient, it must be us — a feed
            // built for another licence must never be acted on here.
            $iid = (string) ($claims['iid'] ?? '');
            if ($iid !== '' && class_exists(\PpPhp\Core\Licensing\LicenseClient::class)) {
                $mine = (new \PpPhp\Core\Licensing\LicenseClient($this->root))->instanceId();
                if ($mine !== '' && !hash_equals($iid, $mine)) return null;
            }
            return $claims;
        } catch (\Throwable $e) {
            return null;
        }
    }

    // ── Notify ─────────────────────────────────────────────────────────────────

    private function notify(array $a): void
    {
        if (!class_exists(Notification::class)) return;
        $sev  = (string) ($a['severity'] ?? 'info');
        $type = self::SEV_TYPE[$sev] ?? 'info';
        $icon = (string) ($a['link'] ?? '') !== '' && !empty($a['icon'])
            ? (string) $a['icon'] : (self::SEV_ICON[$sev] ?? 'megaphone');
        $data = [
            'title'   => (string) ($a['title'] ?? 'Notice from your provider'),
            'message' => (string) ($a['body'] ?? ''),
            'url'     => (string) ($a['link'] ?? ''),
            'type'    => $type,
            'icon'    => $icon,
        ];
        foreach (['superadmin', 'owner'] as $role) {
            try { Notification::AddForRole($role, $data); } catch (\Throwable $e) { /* one role is enough */ }
        }
    }

    // ── Apply a critical patch (signed pipeline, restore point) ────────────────

    private function mayAutoApply(): bool
    {
        if (!self::autoSecurityEnabled()) return false;
        // Never apply to a copy whose licence is lapsed/revoked/tampered.
        if (class_exists(\PpPhp\Core\Licensing\LicenseGate::class)
            && \PpPhp\Core\Licensing\LicenseGate::locked()) {
            return false;
        }
        return class_exists(AutoUpdater::class);
    }

    private function applyCritical(array $a): array
    {
        try {
            // Force past the general auto-update toggle — this is a security push —
            // but AutoUpdater still verifies the signature and takes a restore point.
            $res = (new AutoUpdater($this->root))->run(['force' => true]);
            $applied = !empty($res['applied']);
            if (class_exists(Notification::class)) {
                $msg = $applied
                    ? 'A critical security patch was installed automatically (' . (string) ($res['from'] ?? '?')
                        . ' → ' . (string) ($res['version'] ?? '?') . '). A restore point was saved.'
                    : 'A critical security patch is available but could not be installed automatically: '
                        . (string) ($res['message'] ?? 'unknown') . ' Please install it from Platform Updates.';
                foreach (['superadmin', 'owner'] as $role) {
                    try {
                        Notification::AddForRole($role, [
                            'title'   => $applied ? 'Security patch installed' : 'Security patch needs your attention',
                            'message' => $msg,
                            'url'     => '/platform/updates',
                            'type'    => $applied ? 'success' : 'warning',
                            'icon'    => 'shield-check',
                        ]);
                    } catch (\Throwable $e) { /* best effort */ }
                }
            }
            return ['applied' => $applied, 'version' => (string) ($res['version'] ?? '')];
        } catch (\Throwable $e) {
            return ['applied' => false];
        }
    }

    // ── Idempotency state ──────────────────────────────────────────────────────

    /** @return string[] */
    private function seen(): array
    {
        $v = PlatformSettings::get(self::SEEN_SETTING)['value'] ?? [];
        return is_array($v) && isset($v['seen']) && is_array($v['seen']) ? array_map('strval', $v['seen']) : [];
    }

    /** @return string[] */
    private function appliedList(): array
    {
        $v = PlatformSettings::get(self::SEEN_SETTING)['value'] ?? [];
        return is_array($v) && isset($v['applied']) && is_array($v['applied']) ? array_map('strval', $v['applied']) : [];
    }

    private function saveSeen(array $seen, array $applied): void
    {
        // Bound the lists so they can't grow forever (newest kept).
        $seen    = array_slice(array_values(array_unique($seen)), -500);
        $applied = array_slice(array_values(array_unique($applied)), -500);
        try { PlatformSettings::set(self::SEEN_SETTING, ['value' => ['seen' => $seen, 'applied' => $applied]]); }
        catch (\Throwable $e) { /* best effort */ }
    }
}
