<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\DB;

/**
 * The authority's catalogue of Owner-pushed advisories — the notices and targeted
 * patch instructions a client receives on its next check-in.
 *
 * Authority-only (stripped from client bundles, see PlatformPackager). A client
 * never holds this class; it only ever receives the SIGNED feed built from these
 * rows (see AdvisoryFeed) and verifies it against the pinned vendor key.
 *
 * Two shapes share one table:
 *   • action = 'notice' → a message that becomes a bell notification on the client.
 *   • action = 'apply'  → a message that ALSO tells the client to install a specific
 *                          platform version (honoured only when it is signed, the
 *                          release verifies, and the client's auto-security setting
 *                          is on — see AdvisoryClient). This is the Rapid-Security-
 *                          Response half.
 *
 * Audience:
 *   • 'all'      → every licensed client.
 *   • 'licenses' → only the instance-ids / licence-keys listed in `targets`
 *                  (per-client targeting; the check-in is authenticated so the
 *                  server already knows which licence is asking).
 */
final class AdvisoryStore
{
    private static bool $ensured = false;

    public const SEVERITIES = ['info', 'success', 'warning', 'critical'];
    public const ACTIONS    = ['notice', 'apply'];
    public const AUDIENCES   = ['all', 'licenses'];

    public static function ensureSchema(): void
    {
        if (self::$ensured) return;
        DB::Init();
        DB::SetConnection();

        DB::Exec("CREATE TABLE IF NOT EXISTS `pp_advisories` (
            `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
            `uid` VARCHAR(40) NOT NULL,
            `severity` VARCHAR(16) NOT NULL DEFAULT 'info',
            `title` VARCHAR(191) NOT NULL DEFAULT '',
            `body` TEXT NULL,
            `link` VARCHAR(255) NOT NULL DEFAULT '',
            `action` VARCHAR(16) NOT NULL DEFAULT 'notice',
            `apply_version` VARCHAR(32) NOT NULL DEFAULT '',
            `audience` VARCHAR(16) NOT NULL DEFAULT 'all',
            `targets` LONGTEXT NULL,
            `edition` VARCHAR(16) NOT NULL DEFAULT '',
            `status` VARCHAR(16) NOT NULL DEFAULT 'live',
            `author` VARCHAR(191) NOT NULL DEFAULT '',
            `created_at` DATETIME NOT NULL,
            `expires_at` DATETIME NULL,
            PRIMARY KEY (`id`),
            UNIQUE KEY `uniq_uid` (`uid`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        self::$ensured = true;
    }

    /** A fresh, collision-resistant advisory id (also the client de-dupe key). */
    public static function newUid(): string
    {
        return 'adv_' . bin2hex(random_bytes(6));
    }

    /**
     * Create or replace an advisory (keyed by uid). Returns the uid.
     *
     * @param array{uid?:string,severity?:string,title?:string,body?:string,link?:string,
     *   action?:string,applyVersion?:string,audience?:string,targets?:array<int,string>,
     *   edition?:string,status?:string,author?:string,expiresAt?:?string} $a
     */
    public static function save(array $a): string
    {
        self::ensureSchema();
        $uid = trim((string) ($a['uid'] ?? '')) ?: self::newUid();

        $severity = in_array($a['severity'] ?? '', self::SEVERITIES, true) ? $a['severity'] : 'info';
        $action   = in_array($a['action'] ?? '', self::ACTIONS, true) ? $a['action'] : 'notice';
        $audience = in_array($a['audience'] ?? '', self::AUDIENCES, true) ? $a['audience'] : 'all';
        $status   = in_array($a['status'] ?? '', ['draft', 'live', 'retired'], true) ? $a['status'] : 'live';
        $edition  = in_array($a['edition'] ?? '', ['client', 'server', ''], true) ? (string) ($a['edition'] ?? '') : '';

        // Targets are normalised to a clean list of non-empty strings.
        $targets = [];
        foreach ((array) ($a['targets'] ?? []) as $t) {
            $t = trim((string) $t);
            if ($t !== '') $targets[] = $t;
        }

        $expires = trim((string) ($a['expiresAt'] ?? ''));
        $expires = $expires !== '' ? $expires : null;

        DB::Query(
            "INSERT INTO `pp_advisories`
                (`uid`,`severity`,`title`,`body`,`link`,`action`,`apply_version`,`audience`,`targets`,`edition`,`status`,`author`,`created_at`,`expires_at`)
             VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?)
             ON DUPLICATE KEY UPDATE
                `severity`=VALUES(`severity`), `title`=VALUES(`title`), `body`=VALUES(`body`),
                `link`=VALUES(`link`), `action`=VALUES(`action`), `apply_version`=VALUES(`apply_version`),
                `audience`=VALUES(`audience`), `targets`=VALUES(`targets`), `edition`=VALUES(`edition`),
                `status`=VALUES(`status`), `expires_at`=VALUES(`expires_at`)",
            [
                $uid, $severity, (string) ($a['title'] ?? ''), (string) ($a['body'] ?? ''),
                (string) ($a['link'] ?? ''), $action, (string) ($a['applyVersion'] ?? ''),
                $audience, json_encode($targets), $edition, $status, (string) ($a['author'] ?? ''),
                $expires,
            ]
        );
        return $uid;
    }

    /** Every advisory, newest first — for the Owner console. */
    public static function all(int $limit = 200): array
    {
        self::ensureSchema();
        return array_map([self::class, 'hydrate'],
            DB::FetchAll("SELECT * FROM `pp_advisories` ORDER BY `id` DESC LIMIT " . max(1, min(1000, $limit))));
    }

    public static function get(string $uid): ?array
    {
        self::ensureSchema();
        $row = DB::FetchAll("SELECT * FROM `pp_advisories` WHERE `uid` = ? LIMIT 1", [$uid]);
        return $row ? self::hydrate($row[0]) : null;
    }

    public static function setStatus(string $uid, string $status): bool
    {
        self::ensureSchema();
        if (!in_array($status, ['draft', 'live', 'retired'], true)) return false;
        DB::Query("UPDATE `pp_advisories` SET `status` = ? WHERE `uid` = ?", [$status, $uid]);
        return true;
    }

    public static function delete(string $uid): bool
    {
        self::ensureSchema();
        DB::Query("DELETE FROM `pp_advisories` WHERE `uid` = ?", [$uid]);
        return true;
    }

    /**
     * The advisories a specific licensed client should see right now: live, not
     * expired, edition-matched, and either broadcast ('all') or explicitly aimed
     * at this licence. This is the whole targeting decision — made server-side on
     * an authenticated check-in, so a client can never see another's targeted push.
     *
     * @param string[] $identity instance-id + licence-key the caller authenticated as
     * @return array<int,array<string,mixed>>
     */
    public static function visibleTo(array $identity, string $edition = 'client'): array
    {
        self::ensureSchema();
        $rows = DB::FetchAll(
            "SELECT * FROM `pp_advisories`
              WHERE `status` = 'live'
                AND (`expires_at` IS NULL OR `expires_at` > NOW())
                AND (`edition` = '' OR `edition` = ?)
              ORDER BY `id` ASC",
            [$edition]
        );
        $id = array_map('strval', $identity);
        $out = [];
        foreach ($rows as $r) {
            $a = self::hydrate($r);
            if ($a['audience'] === 'licenses') {
                $hit = false;
                foreach ($a['targets'] as $t) { if (in_array((string) $t, $id, true)) { $hit = true; break; } }
                if (!$hit) continue;
            }
            unset($a['audience'], $a['targets'], $a['status'], $a['edition']); // client needn't see routing
            $out[] = $a;
        }
        return $out;
    }

    private static function hydrate(array $r): array
    {
        return [
            'uid'          => (string) ($r['uid'] ?? ''),
            'severity'     => (string) ($r['severity'] ?? 'info'),
            'title'        => (string) ($r['title'] ?? ''),
            'body'         => (string) ($r['body'] ?? ''),
            'link'         => (string) ($r['link'] ?? ''),
            'action'       => (string) ($r['action'] ?? 'notice'),
            'applyVersion' => (string) ($r['apply_version'] ?? ''),
            'audience'     => (string) ($r['audience'] ?? 'all'),
            'targets'      => (array) (json_decode((string) ($r['targets'] ?? '[]'), true) ?: []),
            'edition'      => (string) ($r['edition'] ?? ''),
            'status'       => (string) ($r['status'] ?? 'live'),
            'author'       => (string) ($r['author'] ?? ''),
            'createdAt'    => (string) ($r['created_at'] ?? ''),
            'expiresAt'    => $r['expires_at'] !== null ? (string) $r['expires_at'] : null,
        ];
    }
}
