<?php
declare(strict_types=1);

namespace Favo\Services;

use PDO;
use PDOException;

final class UnlockService
{
    public function __construct(private PDO $db) {}

    /**
     * Evaluate deterministic loyalty rewards.
     *
     * Reward lifecycle is configured in programs.rules_json:
     *
     * ONE_TIME:
     *   {"points":100,"reward_mode":"ONE_TIME"}
     *
     * REPEATABLE:
     *   {"points":100,"reward_mode":"REPEATABLE","cycle":100}
     *
     * SPEND_POINTS:
     *   {"points_cost":100,"reward_mode":"SPEND_POINTS"}
     *
     * SPEND_POINTS is represented as an entitlement here, but point
     * deduction remains a redemption-side financial/loyalty operation.
     * This service therefore never mutates the loyalty balance.
     */
    public function evaluate(
        int $customerId,
        int $merchantId,
        ?int $programId = null,
        ?int $actorId = null
    ): array {
        if ($customerId < 1 || $merchantId < 1) {
            throw new \InvalidArgumentException('customer_id dan merchant_id diperlukan');
        }

        $customer = $this->db->prepare(
            'SELECT id FROM customers WHERE id=? LIMIT 1'
        );
        $customer->execute([$customerId]);
        if (!$customer->fetchColumn()) {
            throw new \RuntimeException('Customer tidak ditemui');
        }

        $merchant = $this->db->prepare(
            'SELECT id FROM merchants WHERE id=? AND status="ACTIVE" LIMIT 1'
        );
        $merchant->execute([$merchantId]);
        if (!$merchant->fetchColumn()) {
            throw new \RuntimeException('Merchant tidak aktif atau tidak ditemui');
        }

        $balance = $this->db->prepare(
            'SELECT points_balance, stamps_balance
             FROM loyalty_accounts
             WHERE customer_id=? AND merchant_id=?
             LIMIT 1'
        );
        $balance->execute([$customerId, $merchantId]);
        $loyalty = $balance->fetch(PDO::FETCH_ASSOC) ?: [
            'points_balance' => 0,
            'stamps_balance' => 0
        ];

        $points = (float)$loyalty['points_balance'];
        $stamps = (int)$loyalty['stamps_balance'];

        $sql = 'SELECT
                    r.id AS reward_id,
                    r.merchant_id,
                    r.program_id,
                    r.name AS reward_name,
                    r.reward_type,
                    r.value,
                    r.reason,
                    r.status AS reward_status,
                    p.name AS program_name,
                    p.type AS program_type,
                    p.objective,
                    p.rules_json
                FROM rewards r
                INNER JOIN programs p
                    ON p.id=r.program_id
                   AND p.merchant_id=r.merchant_id
                WHERE r.merchant_id=?
                  AND r.status="ACTIVE"
                  AND p.status="ACTIVE"
                  AND r.program_id IS NOT NULL
                  AND r.reason="LOYALTY"';

        $params = [$merchantId];

        if ($programId !== null && $programId > 0) {
            $sql .= ' AND p.id=?';
            $params[] = $programId;
        }

        $sql .= ' ORDER BY r.id';

        $q = $this->db->prepare($sql);
        $q->execute($params);
        $rows = $q->fetchAll(PDO::FETCH_ASSOC);

        $created = [];

        foreach ($rows as $row) {
            $rules = json_decode((string)($row['rules_json'] ?? ''), true);
            if (!is_array($rules)) {
                continue;
            }

            $mode = strtoupper(trim((string)($rules['reward_mode'] ?? 'ONE_TIME')));
            if (!in_array($mode, ['ONE_TIME', 'REPEATABLE', 'SPEND_POINTS'], true)) {
                $mode = 'ONE_TIME';
            }

            $threshold = null;
            $metric = null;

            if (isset($rules['points']) && is_numeric($rules['points'])) {
                $threshold = (float)$rules['points'];
                $metric = 'POINTS';
            } elseif (isset($rules['threshold_points']) && is_numeric($rules['threshold_points'])) {
                $threshold = (float)$rules['threshold_points'];
                $metric = 'POINTS';
            } elseif (isset($rules['points_cost']) && is_numeric($rules['points_cost'])) {
                $threshold = (float)$rules['points_cost'];
                $metric = 'POINTS';
            } elseif (isset($rules['stamps']) && is_numeric($rules['stamps'])) {
                $threshold = (float)$rules['stamps'];
                $metric = 'STAMPS';
            } elseif (isset($rules['threshold']) && is_numeric($rules['threshold'])) {
                if (strtoupper((string)$row['program_type']) === 'STICKER') {
                    $threshold = (float)$rules['threshold'];
                    $metric = 'STAMPS';
                } else {
                    $threshold = (float)$rules['threshold'];
                    $metric = 'POINTS';
                }
            }

            if ($threshold === null || $threshold <= 0) {
                continue;
            }

            $current = $metric === 'STAMPS' ? $stamps : $points;

            if ($current < $threshold) {
                continue;
            }

            /*
             * ONE_TIME:
             * One entitlement for the lifetime of the customer/reward.
             */
            if ($mode === 'ONE_TIME') {
                $check = $this->db->prepare(
                    'SELECT id
                     FROM reward_unlocks
                     WHERE customer_id=? AND merchant_id=? AND reward_id=?
                     LIMIT 1'
                );
                $check->execute([
                    $customerId,
                    $merchantId,
                    (int)$row['reward_id']
                ]);

                if ($check->fetchColumn()) {
                    continue;
                }

                $this->insertUnlock(
                    $customerId,
                    $merchantId,
                    $row,
                    1,
                    $mode,
                    $metric,
                    $threshold,
                    $current,
                    $actorId,
                    $created
                );

                continue;
            }

            /*
             * REPEATABLE:
             * Number of earned cycles is floor(balance / cycle_threshold).
             * Example: 330 / 100 = cycles 1, 2, 3.
             *
             * A cycle remains an immutable entitlement even after redemption.
             * Re-evaluation therefore only creates missing cycle numbers.
             */
            $cycleThreshold = $threshold;

            if (isset($rules['cycle']) && is_numeric($rules['cycle'])) {
                $cycleThreshold = (float)$rules['cycle'];
            }

            if ($cycleThreshold <= 0) {
                continue;
            }

            $eligibleCycles = (int)floor($current / $cycleThreshold);

            /*
             * Safety ceiling prevents a malformed rule from generating
             * an unbounded number of rows in one request.
             */
            $eligibleCycles = min($eligibleCycles, 10000);

            $existing = $this->db->prepare(
                'SELECT cycle_number
                 FROM reward_unlocks
                 WHERE customer_id=? AND merchant_id=? AND reward_id=?
                 ORDER BY cycle_number'
            );
            $existing->execute([
                $customerId,
                $merchantId,
                (int)$row['reward_id']
            ]);

            $existingCycles = [];
            while (($cycle = $existing->fetchColumn()) !== false) {
                $existingCycles[(int)$cycle] = true;
            }

            for ($cycle = 1; $cycle <= $eligibleCycles; $cycle++) {
                if (isset($existingCycles[$cycle])) {
                    continue;
                }

                $this->insertUnlock(
                    $customerId,
                    $merchantId,
                    $row,
                    $cycle,
                    $mode,
                    $metric,
                    $cycleThreshold,
                    $current,
                    $actorId,
                    $created
                );
            }

            continue;
        }

        return [
            'points' => $points,
            'stamps' => $stamps,
            'unlocked' => $created,
            'count' => count($created)
        ];
    }

    /**
     * Insert a cycle safely. The unique key is the final concurrency guard.
     */
    private function insertUnlock(
        int $customerId,
        int $merchantId,
        array $row,
        int $cycle,
        string $mode,
        string $metric,
        float $threshold,
        float $current,
        ?int $actorId,
        array &$created
    ): void {
        try {
            $ins = $this->db->prepare(
                'INSERT INTO reward_unlocks
                    (customer_id, merchant_id, reward_id, program_id, cycle_number,
                     status, metric, threshold_value, achieved_value,
                     unlocked_at, created_at, updated_at)
                 VALUES
                    (?,?,?,?,?,"UNLOCKED",?,?,?,NOW(),NOW(),NOW())'
            );

            $ins->execute([
                $customerId,
                $merchantId,
                (int)$row['reward_id'],
                (int)$row['program_id'],
                $cycle,
                $metric,
                $threshold,
                $current
            ]);

            if ($ins->rowCount() !== 1) {
                return;
            }

            $unlockId = (int)$this->db->lastInsertId();

            $item = [
                'id' => $unlockId,
                'reward_id' => (int)$row['reward_id'],
                'program_id' => (int)$row['program_id'],
                'cycle_number' => $cycle,
                'reward_name' => $row['reward_name'],
                'metric' => $metric,
                'threshold' => $threshold,
                'achieved' => $current,
                'reward_mode' => $mode,
                'status' => 'UNLOCKED'
            ];

            $created[] = $item;

            $audit = $this->db->prepare(
                'INSERT INTO audit_logs
                    (account_id, action, entity, entity_id, metadata_json,
                     ip_address, user_agent, created_at)
                 VALUES (?,?,?,?,?,?,?,NOW())'
            );

            $audit->execute([
                $actorId ?: null,
                'REWARD_UNLOCKED',
                'reward_unlocks',
                $unlockId,
                json_encode([
                    'customer_id' => $customerId,
                    'merchant_id' => $merchantId,
                    'reward_id' => (int)$row['reward_id'],
                    'program_id' => (int)$row['program_id'],
                    'cycle_number' => $cycle,
                    'reward_mode' => $mode,
                    'metric' => $metric,
                    'threshold' => $threshold,
                    'achieved' => $current
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
                $_SERVER['REMOTE_ADDR'] ?? null,
                $_SERVER['HTTP_USER_AGENT'] ?? null
            ]);
        } catch (PDOException $e) {
            /*
             * Concurrent evaluation may race for the same cycle.
             * The unique key makes one winner. A duplicate-key race is
             * treated as idempotent; other DB errors are re-thrown.
             */
            if ((string)$e->getCode() === '23000') {
                return;
            }
            throw $e;
        }
    }

    public function listUnlocked(
        int $customerId,
        int $merchantId,
        ?int $programId = null
    ): array {
        $sql = 'SELECT
                    u.id,
                    u.customer_id,
                    u.merchant_id,
                    u.reward_id,
                    u.program_id,
                    u.cycle_number,
                    u.status,
                    u.metric,
                    u.threshold_value,
                    u.achieved_value,
                    u.unlocked_at,
                    u.redeemed_at,
                    u.created_at,
                    u.updated_at,
                    r.name AS reward_name,
                    r.reward_type,
                    r.value AS reward_value,
                    r.reason,
                    p.name AS program_name,
                    p.type AS program_type,
                    p.rules_json
                FROM reward_unlocks u
                INNER JOIN rewards r ON r.id=u.reward_id
                INNER JOIN programs p ON p.id=u.program_id
                WHERE u.customer_id=? AND u.merchant_id=?';

        $params = [$customerId, $merchantId];

        if ($programId !== null && $programId > 0) {
            $sql .= ' AND u.program_id=?';
            $params[] = $programId;
        }

        $sql .= ' ORDER BY u.unlocked_at DESC, u.id DESC';

        $q = $this->db->prepare($sql);
        $q->execute($params);

        $rows = $q->fetchAll(PDO::FETCH_ASSOC);

        foreach ($rows as &$row) {
            $rules = json_decode((string)($row['rules_json'] ?? ''), true);
            $row['reward_mode'] = is_array($rules)
                ? strtoupper((string)($rules['reward_mode'] ?? 'ONE_TIME'))
                : 'ONE_TIME';
            unset($row['rules_json']);
        }

        return $rows;
    }
}
