<?php

namespace KeyLair;

/**
 * Which platforms exist (Steam, Xbox, PlayStation 5, ...) is admin-editable
 * via admin/platforms.php rather than hardcoded, so a new console can be
 * added without a code change. Cached per-request like Config, since it's
 * read on almost every page.
 */
class Platform
{
    private static ?array $rows = null;

    /** @return array<int, array{slug: string, label: string, icon_path: ?string, key_format_pattern: ?string}> in display order. */
    public static function all(): array
    {
        if (self::$rows === null) {
            $pdo = Database::connection();
            self::$rows = $pdo->query('SELECT slug, label, icon_path, key_format_pattern FROM platforms ORDER BY sort_order, label')->fetchAll();
        }
        return self::$rows;
    }

    /** @return string[] just the slugs, in display order. */
    public static function slugs(): array
    {
        return array_column(self::all(), 'slug');
    }

    /** @return array<string, string> slug => label */
    public static function labels(): array
    {
        return array_column(self::all(), 'label', 'slug');
    }

    public static function exists(string $slug): bool
    {
        return in_array($slug, self::slugs(), true);
    }

    /** Falls back to the raw slug if it no longer matches a configured platform. */
    public static function label(string $slug): string
    {
        return self::labels()[$slug] ?? $slug;
    }

    /** Number of keys currently stored for this platform, across all products. */
    public static function keyCount(string $slug): int
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare('SELECT COUNT(*) FROM `keys` WHERE platform = :slug');
        $stmt->execute(['slug' => $slug]);
        return (int) $stmt->fetchColumn();
    }

    /**
     * Researched real-world key formats (see admin/platforms.php's
     * reference text for the sourcing/character-exclusion reasoning) for
     * the three families consistent enough to auto-fill with any
     * confidence -- Switch and Switch 2 share one format, as do PS4 and
     * PS5 (same PlayStation Store voucher system either way). Steam is
     * deliberately excluded: publishers vary too widely for any single
     * pattern to be safe as a default, so it stays unset even when created
     * from the "Steam" template.
     */
    private static function suggestedKeyFormatPattern(string $label): ?string
    {
        return match (true) {
            $label === 'Xbox/Microsoft Store' => '[BCDFGHJKMNPQRTVWXYZ2346789]{5}(-[BCDFGHJKMNPQRTVWXYZ2346789]{5}){4}',
            str_starts_with($label, 'Nintendo Switch') => '[A-HJ-NP-Y0-9]{16}',
            str_starts_with($label, 'PlayStation') => '[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}',
            default => null,
        };
    }

    /**
     * @throws \PDOException if the derived slug already exists.
     * A platform created from one of the recognized templates
     * (admin/platforms.php's "New platform" picker) gets its key format
     * pre-filled with a researched pattern for that console -- still just
     * a starting point, editable (or clearable) afterward the same as any
     * manually-typed pattern. A custom/free-typed label gets none, exactly
     * like before this existed.
     */
    public static function create(string $label): void
    {
        $slug = substr(Helpers::slugify($label), 0, 32);
        $pdo = Database::connection();
        $nextOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), -1) + 1 FROM platforms')->fetchColumn();

        $stmt = $pdo->prepare(
            'INSERT INTO platforms (slug, label, key_format_pattern, sort_order, created_at) VALUES (:slug, :label, :pattern, :order, NOW())'
        );
        $stmt->execute([
            'slug' => $slug,
            'label' => $label,
            'pattern' => self::suggestedKeyFormatPattern($label),
            'order' => $nextOrder,
        ]);
        self::$rows = null;
    }

    /** Deletes the platform. Any keys (and their redemption history) stored under it are deleted too — ON DELETE CASCADE. */
    public static function delete(string $slug): void
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare('DELETE FROM platforms WHERE slug = :slug');
        $stmt->execute(['slug' => $slug]);
        self::$rows = null;
    }

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

    public static function setIcon(string $slug, string $webPath): void
    {
        $pdo = Database::connection();
        $pdo->prepare('UPDATE platforms SET icon_path = :path WHERE slug = :slug')
            ->execute(['path' => $webPath, 'slug' => $slug]);
        self::$rows = null;
    }

    public static function clearIcon(string $slug): void
    {
        $pdo = Database::connection();
        $pdo->prepare('UPDATE platforms SET icon_path = NULL WHERE slug = :slug')
            ->execute(['slug' => $slug]);
        self::$rows = null;
    }

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

    /**
     * @return bool whether $pattern is empty (clearing it) or compiles as a
     *   valid PCRE fragment -- checked here so a broken regex can never get
     *   saved and only surface as a mysterious failure later, during import.
     */
    public static function isValidKeyFormatPattern(string $pattern): bool
    {
        // "#" rather than the more common "/" as the PCRE delimiter, since
        // an admin's pattern is far more likely to need a literal "/" (an
        // odd but not impossible key format) than a literal "#".
        return $pattern === '' || @preg_match('#' . $pattern . '#u', '') !== false;
    }

    /** @param string $pattern A PCRE fragment (no delimiters) -- admin-supplied, validated by isValidKeyFormatPattern() at the call site before this is ever reached. Empty string clears it (stored as NULL). */
    public static function setKeyFormatPattern(string $slug, string $pattern): void
    {
        $pdo = Database::connection();
        $pdo->prepare('UPDATE platforms SET key_format_pattern = :pattern WHERE slug = :slug')
            ->execute(['pattern' => $pattern === '' ? null : $pattern, 'slug' => $slug]);
        self::$rows = null;
    }

    /**
     * Structural check for an imported key value against this platform's
     * configured format (see admin/platforms.php) -- e.g. rejecting
     * something that clearly isn't shaped like a real Steam key. No pattern
     * configured (the default for every platform) means anything non-blank
     * is accepted, exactly like before this existed.
     */
    public static function matchesKeyFormat(string $slug, string $value): bool
    {
        $pattern = self::keyFormatPattern($slug);
        if ($pattern === null || $pattern === '') {
            return true;
        }
        $result = @preg_match('#^(?:' . $pattern . ')$#u', $value);
        return $result === 1;
    }

    /**
     * Small icon for the claim page's platform tiles. An admin-uploaded
     * icon (set via admin/platforms.php) always wins. Without one, falls
     * back to a generic placeholder shaped somewhat like the real platform
     * for recognizability -- deliberately not an official logo (trademark
     * risk for a product resold to other businesses); matched by slug
     * prefix so it also covers every per-region template variant
     * (nintendo-switch-us, playstation-5-siea, ...), not just exact names.
     */
    public static function icon(string $slug): string
    {
        $path = self::iconPath($slug);
        if ($path) {
            return '<img src="' . Helpers::e($path) . '" alt="" style="width:100%;height:100%;object-fit:contain;">';
        }

        $placeholders = [
            'steam' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="13" r="7.5"/><circle cx="8.5" cy="9" r="2.6"/><circle cx="8.5" cy="9" r="0.6" fill="currentColor" stroke="none"/><path d="M8.5 9L14 14.5"/><circle cx="14" cy="14.5" r="1.6"/><circle cx="14" cy="14.5" r="0.5" fill="currentColor" stroke="none"/></svg>',
            'xbox' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3"><circle cx="12" cy="12" r="9.5"/><path d="M7 7l10 10" stroke-width="2.1" stroke-linecap="round"/><path d="M17 7L7 17" stroke-width="2.1" stroke-linecap="round"/></svg>',
            'playstation' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 8h12a3 3 0 013 3c0 1.5-.5 6-2 8a2 2 0 01-3.2.4L13 16.5a1.5 1.5 0 00-2 0L8.2 19.4A2 2 0 015 19c-1.5-2-2-6.5-2-8a3 3 0 013-3z"/><circle cx="9" cy="12" r="0.8" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="0.8" fill="currentColor" stroke="none"/></svg>',
            'switch' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="2.5" y="5" width="6" height="14" rx="2.5"/><rect x="15.5" y="5" width="6" height="14" rx="2.5"/><rect x="8.5" y="6.5" width="7" height="11" rx="1"/><circle cx="5.5" cy="9" r="0.9" fill="currentColor" stroke="none"/><circle cx="18.5" cy="9" r="0.9" fill="currentColor" stroke="none"/><circle cx="18.5" cy="15" r="0.9" fill="currentColor" stroke="none"/></svg>',
            'store' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 9l1-5h14l1 5"/><rect x="4" y="9" width="16" height="11" rx="1"/><path d="M9 13v4M15 13v4"/></svg>',
            'generic' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 9h.01M9 15c1-1 5-1 6 0"/></svg>',
        ];

        $key = match (true) {
            str_starts_with($slug, 'steam') => 'steam',
            str_starts_with($slug, 'xbox') => 'xbox',
            str_starts_with($slug, 'playstation') => 'playstation',
            str_starts_with($slug, 'nintendo-switch') => 'switch',
            in_array($slug, ['gog', 'epic-games-store', 'itch'], true) => 'store',
            default => 'generic',
        };

        return $placeholders[$key];
    }

    /**
     * A newer console generation should outrank its predecessor and sit
     * directly next to it (Nintendo Switch 2 immediately followed by
     * Switch, PlayStation 5 immediately followed by PS4) -- but that's the
     * *only* reordering wanted. Everything else (Xbox, PC/Steam, ...) stays
     * exactly where it naturally falls (see groupForClaimPage()), so this
     * is applied as a couple of "pull this pair together" passes afterward
     * rather than a single global family-priority list, which would also
     * shove Xbox/PC beneath every console -- tried that first, wrong. An
     * earlier version only swapped the newer and older's two positions,
     * which promoted the newer one but left the older wherever the newer
     * used to be -- e.g. Switch 2 landing next to Xbox while the original
     * Switch stayed stranded after PlayStation. This instead moves both
     * together to the earlier of their two original positions.
     */
    private const NEWER_GENERATION_PAIRS = [
        ['Nintendo Switch 2', 'Nintendo Switch'],
        ['PlayStation 5', 'PlayStation 4'],
    ];

    /**
     * Groups a claim page's platform tiles by family (PC, Xbox, Nintendo
     * Switch, Nintendo Switch 2, PlayStation 4, PlayStation 5) so a product
     * supporting many regional variants of the same console doesn't turn
     * into a wall of near-identical tiles -- admin/platforms.php's own "New
     * platform" template picker already uses these same six families as
     * its <optgroup> labels, so this mirrors a grouping the admin already
     * thinks in, rather than inventing a new one. A family collapses into
     * one entry only when at least two of its members are actually present
     * in $slugs; a lone member (or anything outside these six recognized
     * families, e.g. a custom platform) renders as its own standalone tile
     * instead of a pointless one-item group to expand. Preserves the
     * platforms' own configured order (see Platform::all()) otherwise,
     * except promoting a newer console generation ahead of its predecessor
     * when both are present (see NEWER_GENERATION_PAIRS).
     *
     * @param string[] $slugs
     * @return array<int, array{type: 'single', slug: string}|array{type: 'group', label: string, slugs: string[]}>
     */
    public static function groupForClaimPage(array $slugs): array
    {
        $members = [];
        foreach ($slugs as $slug) {
            $family = self::familyOf($slug);
            if ($family !== null) {
                $members[$family][] = $slug;
            }
        }

        $entries = [];
        $consumed = [];
        foreach ($slugs as $slug) {
            if (in_array($slug, $consumed, true)) {
                continue;
            }
            $family = self::familyOf($slug);
            if ($family !== null && count($members[$family]) >= 2) {
                $entries[] = ['type' => 'group', 'label' => $family, 'slugs' => $members[$family]];
                $consumed = array_merge($consumed, $members[$family]);
            } else {
                $entries[] = ['type' => 'single', 'slug' => $slug];
                $consumed[] = $slug;
            }
        }

        foreach (self::NEWER_GENERATION_PAIRS as [$newer, $older]) {
            $newerIndex = $olderIndex = null;
            foreach ($entries as $i => $entry) {
                $entryFamily = $entry['type'] === 'group' ? $entry['label'] : self::familyOf($entry['slug']);
                if ($entryFamily === $newer) {
                    $newerIndex = $i;
                } elseif ($entryFamily === $older) {
                    $olderIndex = $i;
                }
            }
            if ($newerIndex === null || $olderIndex === null || $newerIndex === $olderIndex) {
                continue;
            }
            $newerEntry = $entries[$newerIndex];
            $olderEntry = $entries[$olderIndex];
            $anchor = min($newerIndex, $olderIndex);
            // Remove the higher index first so removing it doesn't shift
            // the lower one out from under itself.
            array_splice($entries, max($newerIndex, $olderIndex), 1);
            array_splice($entries, min($newerIndex, $olderIndex), 1);
            array_splice($entries, $anchor, 0, [$newerEntry, $olderEntry]);
        }

        return $entries;
    }

    private static function familyOf(string $slug): ?string
    {
        return match (true) {
            in_array($slug, ['steam', 'gog', 'epic-games-store', 'itch'], true) => 'PC',
            str_starts_with($slug, 'xbox') => 'Xbox',
            str_starts_with($slug, 'nintendo-switch-2') => 'Nintendo Switch 2',
            str_starts_with($slug, 'nintendo-switch') => 'Nintendo Switch',
            str_starts_with($slug, 'playstation-4') => 'PlayStation 4',
            str_starts_with($slug, 'playstation-5') => 'PlayStation 5',
            default => null,
        };
    }

    /**
     * The notch + icon + name/status markup shared by every platform tile
     * (standalone or nested inside a grouped family) on the claim page and
     * its preview -- extracted so the two pages (which wrap this in a
     * <button> vs a <div>, since only the real page ever submits a claim)
     * can't drift out of sync on what a tile actually looks like inside.
     */
    public static function tileInner(string $slug, bool $available): string
    {
        return '<span class="notch"></span>'
            . '<span class="tile-icon">' . self::icon($slug) . '</span>'
            . '<span class="tile-info">'
            . '<span class="tile-name">' . Helpers::e(self::label($slug)) . '</span>'
            . '<span class="tile-status">' . ($available ? 'Available' : 'Sold out') . '</span>'
            . '</span>';
    }

    /**
     * How many of a grouped family's regions actually have stock right
     * now -- also used to decide whether the whole group should render
     * dimmed like an individual sold-out tile (see groupStatusLabel()).
     *
     * @param string[] $slugs
     * @param array<string, int> $stock slug => available key count
     */
    public static function groupAvailableCount(array $slugs, array $stock): int
    {
        $count = 0;
        foreach ($slugs as $slug) {
            if (($stock[$slug] ?? 0) > 0) {
                $count++;
            }
        }
        return $count;
    }

    /**
     * Aggregate availability text for a grouped family's <summary> (e.g.
     * "2 of 5 available"), so an admin/visitor can tell whether it's worth
     * expanding without having to open it first.
     *
     * @param string[] $slugs
     * @param array<string, int> $stock slug => available key count
     */
    public static function groupStatusLabel(array $slugs, array $stock): string
    {
        $total = count($slugs);
        $available = self::groupAvailableCount($slugs, $stock);
        if ($available === 0) {
            return 'Sold out';
        }
        if ($available === $total) {
            return 'Available';
        }
        return "{$available} of {$total} available";
    }
}
