<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

use PpPhp\Core\DB;
use PpPhp\Core\PlatformSettings;

/**
 * Plans / packages the Owner sells — Standard, Pro, Premium, or any the Owner
 * defines. Each plan carries a set of CAPABILITIES (server-side features a licence
 * on that plan is entitled to use, e.g. the platform's own AI / Email / SMS).
 *
 * Authority-only: the Owner defines plans here on the licence server. A client
 * never holds this class; it learns only its OWN plan + granted capabilities,
 * carried inside its signed lease (see LicenseServer::issueLease `cap`), so the
 * entitlement can't be forged and a change of plan reaches the install within a
 * day. Enforcement of each capability is wired up per-feature over time; this is
 * the registry those features read.
 *
 * Storage: plans are real rows in `pp_license_plans` (not a settings blob), so
 * they are proper records that replicate to a backup / new server with the rest
 * of the licence data (see LicenseSync::dataSnapshot). The table self-heals into
 * existence on first use (there is no migration runner — same idiom as every
 * other licensing table); on first creation it imports any plans that lived in
 * the legacy settings key, else seeds the shipped defaults.
 */
final class PlanRegistry
{
    /** Legacy settings key plans lived under before they became a table (migrated in). */
    public const SETTING = 'platform.license_plans';

    private static bool $ensured = false;

    /** Curated tier icons the owner picks from — premium-feeling, all in the bundled set. */
    public const ICON_CHOICES = [
        'rocket-takeoff-fill', 'lightning-charge-fill', 'gem', 'award-fill', 'trophy-fill',
        'stars', 'patch-check-fill', 'diamond-fill', 'shield-fill-check', 'hexagon-fill',
        'fire', 'bag-fill', 'boxes',
    ];

    /** Curated tier accent colours (a gradient badge is derived from each). */
    public const COLOR_CHOICES = [
        '#64748b', '#4f46e5', '#0ea5e9', '#14b8a6', '#10b981',
        '#f59e0b', '#f43f5e', '#a855f7',
    ];

    /**
     * The catalogue of capabilities a plan can grant. Single source of truth is
     * {@see Capability::CATALOG} (which ships to clients so labels match); this
     * alias keeps the Owner-facing plan console reading it by its familiar name.
     *
     * @var array<string,array{label:string,desc:string,icon:string,group:string}>
     */
    public const CAPABILITIES = Capability::CATALOG;

    /**
     * The plans shipped by default. The Owner can rename, re-tier, add or remove
     * them. Premium unlocks the server services; Pro is a middle tier; Standard is
     * the base. Access can be tuned later — these are just sensible starting lines.
     *
     * @return array<int,array<string,mixed>>
     */
    private static function defaults(): array
    {
        $none = array_fill_keys(array_keys(self::CAPABILITIES), false);
        return [
            ['slug' => 'standard', 'name' => 'Standard', 'order' => 1, 'icon' => 'rocket-takeoff-fill', 'color' => '#0ea5e9',
             'description' => 'The base plan. The app and its own features, nothing server-side.',
             'capabilities' => $none, 'max_db_mb' => 5],
            ['slug' => 'pro', 'name' => 'Pro', 'order' => 2, 'icon' => 'lightning-charge-fill', 'color' => '#4f46e5',
             'description' => 'A step up — priority support and server email.',
             'capabilities' => ['server_email' => true, 'priority_support' => true] + $none, 'max_db_mb' => 15],
            ['slug' => 'premium', 'name' => 'Premium', 'order' => 3, 'icon' => 'gem', 'color' => '#f59e0b',
             'description' => 'Everything, including the server AI, Email and SMS.',
             'capabilities' => array_fill_keys(array_keys(self::CAPABILITIES), true), 'max_db_mb' => 25],
        ];
    }

    /** Normalise a raw plan row into the canonical shape. */
    private static function hydrate(array $p): array
    {
        $caps = [];
        $raw = is_array($p['capabilities'] ?? null) ? $p['capabilities'] : [];
        foreach (array_keys(self::CAPABILITIES) as $k) {
            $caps[$k] = !empty($raw[$k]);
        }
        $slug = self::slugify((string) ($p['slug'] ?? $p['name'] ?? ''));
        // A slug-aware default so plans stored before the icon set changed (and any
        // new plan) get a sensible tier badge without a manual reset.
        $byIcon  = ['standard' => 'rocket-takeoff-fill', 'pro' => 'lightning-charge-fill', 'premium' => 'gem'];
        $byColor = ['standard' => '#0ea5e9', 'pro' => '#4f46e5', 'premium' => '#f59e0b'];
        $icon = (string) ($p['icon'] ?? '');
        if (!in_array($icon, self::ICON_CHOICES, true)) $icon = $byIcon[$slug] ?? 'rocket-takeoff-fill';
        $color = strtolower(trim((string) ($p['color'] ?? '')));
        if (!preg_match('/^#[0-9a-f]{6}$/', $color)) $color = $byColor[$slug] ?? '#4f46e5';
        return [
            'slug'         => $slug,
            'name'         => trim((string) ($p['name'] ?? ucfirst($slug))) ?: ucfirst($slug),
            'description'  => trim((string) ($p['description'] ?? '')),
            'order'        => (int) ($p['order'] ?? 99),
            'icon'         => $icon,
            'color'        => $color,
            'capabilities' => $caps,
            // Per-plan build allowances, one per window (hour/day/week/month).
            // null = inherit the global default; 0 = unlimited; N = cap. Numeric,
            // deliberately SEPARATE from the boolean capability map above.
            'build_limits' => self::normalizeLimits($p['build_limits'] ?? null),
            // Per-plan MAX SIZE of one Virtual Database, in MB. Hard-enforced by
            // SQLite's max_page_count on every connection. Semantics: a stored 0 =
            // explicit "unlimited"; UNSET (null) falls back to a slug-aware default
            // so the shipped tiers get 5/15/25 even on rows created before this
            // column existed (mirrors the icon/color defaults above). A custom plan
            // with no value set stays null = unlimited.
            'max_db_mb' => (($v = $p['max_db_mb'] ?? null) === null || $v === '')
                ? (['standard' => 5, 'pro' => 15, 'premium' => 25][$slug] ?? null)
                : max(0, (int) $v),
        ];
    }

    /**
     * Normalise a build-limits map to {hour,day,week,month} where each value is an
     * int (0 = unlimited, N = cap) or null (inherit the next level). Accepts a JSON
     * string or an array; unknown/blank entries become null.
     *
     * @return array{hour:?int,day:?int,week:?int,month:?int}
     */
    public static function normalizeLimits(mixed $raw): array
    {
        if (is_string($raw)) { $raw = $raw === '' ? [] : (json_decode($raw, true) ?: []); }
        if (!is_array($raw)) $raw = [];
        $out = [];
        foreach (['hour', 'day', 'week', 'month'] as $w) {
            $v = $raw[$w] ?? null;
            $out[$w] = ($v === null || $v === '') ? null : max(0, (int) $v);
        }
        return $out;
    }

    /**
     * The build allowances a plan defines, per window (null = inherit global).
     * Used by BuildMeter to resolve a caller's effective limits at build time.
     *
     * @return array{hour:?int,day:?int,week:?int,month:?int}
     */
    public static function buildLimitsFor(string $slug): array
    {
        $p = self::get($slug);
        return is_array($p['build_limits'] ?? null) ? $p['build_limits'] : self::normalizeLimits(null);
    }

    /**
     * The max size (in MB) of one Virtual Database on a plan. null = unlimited.
     * Owner-defined per plan (Standard 5 / Pro 15 / Premium 25 by default).
     */
    public static function maxDbMbFor(string $slug): ?int
    {
        $p = self::get($slug);
        $v = $p['max_db_mb'] ?? null;
        return ($v === null || $v === '') ? null : max(0, (int) $v);
    }

    public static function slugify(string $s): string
    {
        $s = strtolower(trim($s));
        $s = (string) preg_replace('/[^a-z0-9]+/', '-', $s);
        return trim($s, '-');
    }

    /**
     * Create the plans table if missing (self-healing — no migration runner), then
     * on first creation import legacy settings-stored plans or seed the defaults.
     */
    public static function ensureSchema(): void
    {
        if (self::$ensured) return;
        DB::Init();
        DB::SetConnection();
        DB::Exec("CREATE TABLE IF NOT EXISTS `pp_license_plans` (
            `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
            `slug` VARCHAR(64) NOT NULL,
            `name` VARCHAR(64) NOT NULL DEFAULT '',
            `description` VARCHAR(255) NOT NULL DEFAULT '',
            `sort_order` INT NOT NULL DEFAULT 99,
            `icon` VARCHAR(40) NOT NULL DEFAULT 'rocket-takeoff-fill',
            `color` VARCHAR(16) NOT NULL DEFAULT '#4f46e5',
            `capabilities` LONGTEXT NULL,
            `build_limits` LONGTEXT NULL,
            `max_db_mb` INT NULL,
            `created_at` DATETIME NOT NULL,
            `updated_at` DATETIME NOT NULL,
            PRIMARY KEY (`id`),
            UNIQUE KEY `uniq_slug` (`slug`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
        // Self-heal columns onto a table created before they existed (no migration
        // runner; ADD COLUMN IF NOT EXISTS isn't portable to MySQL, so try/ignore
        // the duplicate-column error).
        try { DB::Exec("ALTER TABLE `pp_license_plans` ADD COLUMN `build_limits` LONGTEXT NULL"); }
        catch (\Throwable $e) { /* column already present */ }
        try { DB::Exec("ALTER TABLE `pp_license_plans` ADD COLUMN `max_db_mb` INT NULL"); }
        catch (\Throwable $e) { /* column already present */ }
        self::$ensured = true;
        self::bootstrap();
    }

    /** First-run only: pull plans from the legacy settings blob, else seed defaults. */
    private static function bootstrap(): void
    {
        try {
            $count = (int) (DB::FetchAll("SELECT COUNT(*) c FROM `pp_license_plans`")[0]['c'] ?? 0);
            if ($count > 0) return;
            $legacy = PlatformSettings::get(self::SETTING)['value'] ?? null;
            $rows = (is_array($legacy) && $legacy !== []) ? $legacy : self::defaults();
            foreach ($rows as $r) {
                if (is_array($r)) self::insertRow(self::hydrate($r));
            }
        } catch (\Throwable $e) { /* best effort — defaults still returned by all() */ }
    }

    /** Upsert one already-hydrated plan row. */
    private static function insertRow(array $p): void
    {
        DB::Query(
            "INSERT INTO `pp_license_plans`
                (`slug`,`name`,`description`,`sort_order`,`icon`,`color`,`capabilities`,`build_limits`,`max_db_mb`,`created_at`,`updated_at`)
             VALUES (?,?,?,?,?,?,?,?,?,NOW(),NOW())
             ON DUPLICATE KEY UPDATE `name`=VALUES(`name`), `description`=VALUES(`description`),
                `sort_order`=VALUES(`sort_order`), `icon`=VALUES(`icon`), `color`=VALUES(`color`),
                `capabilities`=VALUES(`capabilities`), `build_limits`=VALUES(`build_limits`),
                `max_db_mb`=VALUES(`max_db_mb`), `updated_at`=NOW()",
            [$p['slug'], $p['name'], $p['description'], $p['order'], $p['icon'], $p['color'],
             json_encode($p['capabilities']), json_encode($p['build_limits'] ?? self::normalizeLimits(null)),
             $p['max_db_mb'] ?? null]
        );
    }

    private static function rowToPlan(array $r): array
    {
        return self::hydrate([
            'slug'         => $r['slug'] ?? '',
            'name'         => $r['name'] ?? '',
            'description'  => $r['description'] ?? '',
            'order'        => $r['sort_order'] ?? 99,
            'icon'         => $r['icon'] ?? '',
            'color'        => $r['color'] ?? '',
            'capabilities' => json_decode((string) ($r['capabilities'] ?? '{}'), true) ?: [],
            'build_limits' => $r['build_limits'] ?? null,
            'max_db_mb'    => $r['max_db_mb'] ?? null,
        ]);
    }

    /**
     * Every plan, ordered. Never returns an empty list, so the Issue form always
     * has something to pick.
     *
     * @return array<int,array<string,mixed>>
     */
    public static function all(): array
    {
        self::ensureSchema();
        try {
            $rows = DB::FetchAll("SELECT * FROM `pp_license_plans` ORDER BY `sort_order` ASC, `name` ASC");
        } catch (\Throwable $e) {
            $rows = [];
        }
        if (!$rows) return array_map([self::class, 'hydrate'], self::defaults());
        return array_map([self::class, 'rowToPlan'], $rows);
    }

    /** @return array<int,string> plan slugs, ordered */
    public static function slugs(): array
    {
        return array_map(static fn ($p) => $p['slug'], self::all());
    }

    public static function get(string $slug): ?array
    {
        $slug = self::slugify($slug);
        foreach (self::all() as $p) if ($p['slug'] === $slug) return $p;
        return null;
    }

    public static function exists(string $slug): bool
    {
        return self::get($slug) !== null;
    }

    /** The display name for a plan slug (falls back to a prettified slug). */
    public static function nameOf(string $slug): string
    {
        return self::get($slug)['name'] ?? ucfirst(self::slugify($slug));
    }

    /**
     * The capability map granted by a plan — every catalogue key present, defaulting
     * false for an unknown plan. This is what gets baked into a lease and what
     * feature code checks.
     *
     * @return array<string,bool>
     */
    public static function capabilitiesFor(string $slug): array
    {
        $p = self::get($slug);
        if ($p) return $p['capabilities'];
        return array_fill_keys(array_keys(self::CAPABILITIES), false);
    }

    /** The keys granted (true) by a plan — the compact form carried in the lease. */
    public static function grantedKeys(string $slug): array
    {
        return array_values(array_keys(array_filter(self::capabilitiesFor($slug))));
    }

    public static function planGrants(string $slug, string $capability): bool
    {
        return !empty(self::capabilitiesFor($slug)[$capability]);
    }

    /**
     * Create or update a plan (keyed by slug). Returns the saved slug.
     * @param array<string,mixed> $plan
     */
    public static function save(array $plan): string
    {
        self::ensureSchema();
        $p = self::hydrate($plan);
        if ($p['slug'] === '') return '';
        self::insertRow($p);
        return $p['slug'];
    }

    /** Remove a plan. The last remaining plan can't be deleted. */
    public static function delete(string $slug): bool
    {
        self::ensureSchema();
        $slug = self::slugify($slug);
        $count = (int) (DB::FetchAll("SELECT COUNT(*) c FROM `pp_license_plans`")[0]['c'] ?? 0);
        if ($count <= 1) return false;
        if (!DB::FetchAll("SELECT `id` FROM `pp_license_plans` WHERE `slug` = ?", [$slug])) return false;
        DB::Query("DELETE FROM `pp_license_plans` WHERE `slug` = ?", [$slug]);
        return true;
    }

    /** Reset to the shipped Standard/Pro/Premium defaults. */
    public static function resetDefaults(): void
    {
        self::ensureSchema();
        DB::Exec("DELETE FROM `pp_license_plans`");
        foreach (self::defaults() as $d) self::insertRow(self::hydrate($d));
    }
}
