<?php

namespace KeyLair;

use PDO;
use Throwable;

/**
 * Everything that touches "give this visitor a key" lives here, inside a
 * single DB transaction with row-level locking, so two people can never be
 * handed the same key and nobody can exceed their claim limit — even under
 * concurrent requests.
 */
class ClaimService
{
    public static function findLinkByToken(string $token): ?array
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare(
            "SELECT * FROM claim_links
             WHERE token = :token AND status = 'active'
               AND (expires_at IS NULL OR expires_at > NOW())
             LIMIT 1"
        );
        $stmt->execute(['token' => $token]);
        $link = $stmt->fetch();

        return $link ?: null;
    }

    /**
     * All products assigned to a claim link, with per-product claim progress
     * and per-platform stock (so sold-out platforms can be shown, disabled,
     * rather than hidden).
     */
    public static function productsForLink(int $claimLinkId): array
    {
        $pdo = Database::connection();

        $stmt = $pdo->prepare(
            'SELECT p.*, clp.max_claims, clp.claims_used
             FROM claim_link_products clp
             JOIN products p ON p.id = clp.product_id
             WHERE clp.claim_link_id = :link_id AND p.status = "active"
             ORDER BY p.name ASC'
        );
        $stmt->execute(['link_id' => $claimLinkId]);
        $products = $stmt->fetchAll();

        foreach ($products as &$product) {
            $product['platform_stock'] = self::platformStock((int) $product['id']);
        }

        return $products;
    }

    public static function findProductBySlugForLink(int $claimLinkId, string $slug): ?array
    {
        foreach (self::productsForLink($claimLinkId) as $product) {
            if ($product['slug'] === $slug) {
                return $product;
            }
        }
        return null;
    }

    /**
     * Available key count per platform for a product, used to show
     * sold-out state. Only counts the active pool -- keys an admin hasn't
     * released yet don't count as stock and can't be claimed. Limited to
     * the product's supported platforms (every configured platform if the
     * product is unrestricted -- see ProductPlatform), and further capped
     * by each platform's claim_limit if one is set, independent of actual
     * key stock.
     */
    public static function platformStock(int $productId): array
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare(
            "SELECT platform, COUNT(*) AS available
             FROM `keys`
             WHERE product_id = :product_id AND status = 'available' AND pool = 'active'
             GROUP BY platform"
        );
        $stmt->execute(['product_id' => $productId]);

        $keyCounts = [];
        foreach ($stmt->fetchAll() as $row) {
            $keyCounts[$row['platform']] = (int) $row['available'];
        }

        $limits = ProductPlatform::forProduct($productId);

        $stock = [];
        foreach (ProductPlatform::supportedSlugs($productId) as $platform) {
            $available = $keyCounts[$platform] ?? 0;
            $limit = $limits[$platform] ?? null;
            if ($limit !== null) {
                $remaining = max(0, $limit - ProductPlatform::claimedCount($productId, $platform));
                $available = min($available, $remaining);
            }
            $stock[$platform] = $available;
        }

        return $stock;
    }

    /** Screenshots + YouTube links for a product, in admin-defined order. */
    public static function mediaForProduct(int $productId): array
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare(
            'SELECT type, value, sort_order FROM product_media
             WHERE product_id = :product_id
             ORDER BY sort_order ASC, id ASC'
        );
        $stmt->execute(['product_id' => $productId]);
        return $stmt->fetchAll();
    }

    /**
     * Builds the ordered media-showcase list for a product's claim page:
     * video(s) first, then screenshots. When there's no video, the cover
     * image takes that first slot instead (ahead of the screenshots) rather
     * than being skipped -- a product with only screenshots would otherwise
     * lead with whichever screenshot happens to be first, when the cover is
     * the image an admin actually chose to represent the product. Falls
     * back to the cover alone if there's neither a video nor screenshots,
     * and returns empty only when there's truly nothing to show at all.
     * Shared by public/product.php and admin/product_preview.php so the
     * two can never drift out of sync with each other.
     *
     * @param array<int, array{type: string, value: string}> $media
     * @return array<int, array{type: string, src: string, label: string}>
     */
    public static function buildMediaShowcase(array $media, ?string $coverImagePath): array
    {
        $items = [];
        $hasVideo = false;
        foreach ($media as $m) {
            if ($m['type'] === 'youtube') {
                $embed = Helpers::youtubeEmbedUrl($m['value']);
                if ($embed) {
                    $items[] = ['type' => 'video', 'src' => $embed, 'label' => 'Trailer'];
                    $hasVideo = true;
                }
            }
        }
        if (!$hasVideo && !empty($coverImagePath)) {
            $items[] = ['type' => 'shot', 'src' => '/uploads/products/' . $coverImagePath, 'label' => 'Cover'];
        }
        foreach ($media as $m) {
            if ($m['type'] === 'screenshot') {
                $items[] = ['type' => 'shot', 'src' => '/uploads/products/' . $m['value'], 'label' => 'Screenshot'];
            }
        }
        return $items;
    }

    /** Keys already redeemed by this link for this product (shown again without re-consuming). */
    public static function claimedKeysForLinkProduct(int $claimLinkId, int $productId): array
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare(
            "SELECT r.platform, k.key_value, r.redeemed_at
             FROM redemptions r
             JOIN `keys` k ON k.id = r.key_id
             WHERE r.claim_link_id = :link_id AND r.product_id = :product_id
             ORDER BY r.redeemed_at ASC"
        );
        $stmt->execute(['link_id' => $claimLinkId, 'product_id' => $productId]);
        return $stmt->fetchAll();
    }

    /**
     * The critical path: atomically checks the claim limit, locks and
     * allocates one available key, records the redemption, and increments
     * the used-claims counter — all inside one transaction.
     *
     * @return array{ok: bool, reason?: string, key_value?: string}
     */
    public static function claim(int $claimLinkId, int $productId, string $platform): array
    {
        if (!Platform::exists($platform)) {
            return ['ok' => false, 'reason' => 'invalid_platform'];
        }
        if (ProductPlatform::isRestricted($productId) && !in_array($platform, ProductPlatform::supportedSlugs($productId), true)) {
            return ['ok' => false, 'reason' => 'invalid_platform'];
        }

        $pdo = Database::connection();
        $pdo->beginTransaction();

        try {
            // Lock the per-product claim counter row so concurrent requests
            // from the same link can't both slip past the limit check.
            $limitStmt = $pdo->prepare(
                'SELECT max_claims, claims_used FROM claim_link_products
                 WHERE claim_link_id = :link_id AND product_id = :product_id
                 FOR UPDATE'
            );
            $limitStmt->execute(['link_id' => $claimLinkId, 'product_id' => $productId]);
            $limitRow = $limitStmt->fetch();

            if (!$limitRow) {
                $pdo->rollBack();
                return ['ok' => false, 'reason' => 'product_not_assigned'];
            }

            if ((int) $limitRow['claims_used'] >= (int) $limitRow['max_claims']) {
                $pdo->rollBack();
                return ['ok' => false, 'reason' => 'limit_reached'];
            }

            // If this product+platform has its own claimable quota (independent
            // of actual key stock), lock that config row too, so concurrent
            // claims against the same quota can't both slip past this check --
            // same reasoning as the claim_link_products lock above.
            $quotaStmt = $pdo->prepare(
                'SELECT claim_limit FROM product_platforms
                 WHERE product_id = :product_id AND platform = :platform
                 FOR UPDATE'
            );
            $quotaStmt->execute(['product_id' => $productId, 'platform' => $platform]);
            $quotaLimit = $quotaStmt->fetchColumn();

            if ($quotaLimit !== false && $quotaLimit !== null) {
                $usedStmt = $pdo->prepare(
                    "SELECT COUNT(*) FROM `keys` WHERE product_id = :product_id AND platform = :platform AND status = 'redeemed'"
                );
                $usedStmt->execute(['product_id' => $productId, 'platform' => $platform]);
                if ((int) $usedStmt->fetchColumn() >= (int) $quotaLimit) {
                    $pdo->rollBack();
                    return ['ok' => false, 'reason' => 'sold_out'];
                }
            }

            // Lock and grab one available key for this product + platform.
            $keyStmt = $pdo->prepare(
                "SELECT id, key_value FROM `keys`
                 WHERE product_id = :product_id AND platform = :platform AND status = 'available' AND pool = 'active'
                 LIMIT 1 FOR UPDATE"
            );
            $keyStmt->execute(['product_id' => $productId, 'platform' => $platform]);
            $key = $keyStmt->fetch();

            if (!$key) {
                $pdo->rollBack();
                return ['ok' => false, 'reason' => 'sold_out'];
            }

            $update = $pdo->prepare(
                "UPDATE `keys` SET status = 'redeemed', redeemed_at = NOW()
                 WHERE id = :id AND status = 'available'"
            );
            $update->execute(['id' => $key['id']]);

            if ($update->rowCount() !== 1) {
                // Someone else grabbed it between the lock and here — should not
                // happen under FOR UPDATE, but bail safely if it ever does.
                $pdo->rollBack();
                return ['ok' => false, 'reason' => 'sold_out'];
            }

            $insertRedemption = $pdo->prepare(
                'INSERT INTO redemptions (claim_link_id, product_id, key_id, platform, ip_hash, user_agent, redeemed_at)
                 VALUES (:link_id, :product_id, :key_id, :platform, :ip_hash, :user_agent, NOW())'
            );
            $insertRedemption->execute([
                'link_id' => $claimLinkId,
                'product_id' => $productId,
                'key_id' => $key['id'],
                'platform' => $platform,
                'ip_hash' => Helpers::hashIp(Helpers::clientIp()),
                'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 500),
            ]);

            $incrementCounter = $pdo->prepare(
                'UPDATE claim_link_products SET claims_used = claims_used + 1
                 WHERE claim_link_id = :link_id AND product_id = :product_id'
            );
            $incrementCounter->execute(['link_id' => $claimLinkId, 'product_id' => $productId]);

            $pdo->commit();

            return ['ok' => true, 'key_value' => $key['key_value']];
        } catch (Throwable $e) {
            $pdo->rollBack();
            error_log('ClaimService::claim failed: ' . $e->getMessage());
            return ['ok' => false, 'reason' => 'error'];
        }
    }
}
