<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Payment;
use App\Services\Payments\PaymentGateway;
use App\Services\Payments\PaymentService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Throwable;

/**
 * Asks Revolut directly about payments we never heard back on.
 *
 * Webhooks get lost. The endpoint gets misconfigured, a deploy lands
 * mid-delivery, a firewall rule changes, the signing secret is rotated and
 * not updated. Every one of those is survivable — unless the only way a
 * booking ever gets confirmed is a webhook arriving.
 *
 * Without this, the failure mode is the worst one available: a customer pays,
 * watches their slot expire, and the studio has their money and no record of
 * why. This command is what makes that impossible rather than unlikely.
 */
class ReconcilePayments extends Command
{
    protected $signature = 'payments:reconcile {--limit=50}';

    protected $description = 'Check unresolved card payments against Revolut';

    public function handle(PaymentGateway $gateway, PaymentService $payments): int
    {
        $now = CarbonImmutable::now('UTC');

        // Leave a short grace period. Checking an order the customer is still
        // typing their card into just burns an API call to be told "pending".
        $cutoff = $now->subMinutes((int) config('revolut.reconcile_after_minutes'));

        $stale = Payment::query()
            // Settling a payment confirms whatever it was for, so the booking
            // is needed for every row. Loading it here rather than per
            // iteration keeps this to two queries instead of one per payment.
            ->with('payable')
            ->where('method', PaymentMethod::RevolutLink->value)
            ->where('status', PaymentStatus::Pending->value)
            ->whereNotNull('external_ref')
            ->where('created_at', '<=', $cutoff)
            ->orderBy('created_at')
            ->limit((int) $this->option('limit'))
            ->get();

        if ($stale->isEmpty()) {
            $this->info('Nothing to reconcile.');

            return self::SUCCESS;
        }

        $settled = 0;
        $failed = 0;
        $stillPending = 0;
        $errors = 0;

        foreach ($stale as $payment) {
            try {
                $status = $gateway->fetchStatus($payment->external_ref);

                if ($status->isPaid) {
                    // markPaid is idempotent, so a webhook that arrives at
                    // the same moment cannot double-confirm anything.
                    $payments->markPaid($payment, $status->raw, $now);
                    $settled++;

                    $this->warn("Recovered a payment no webhook told us about: {$payment->external_ref}");

                    continue;
                }

                if ($status->isFinished) {
                    $payments->markFailed($payment, $status->providerState ?? 'unresolved', $now);
                    $failed++;

                    continue;
                }

                $stillPending++;
            } catch (Throwable $e) {
                // One unreachable order must not stop the sweep — the next
                // one might be the customer who is out of pocket.
                $errors++;

                $this->error("Could not check {$payment->external_ref}: {$e->getMessage()}");
            }
        }

        $this->info(sprintf(
            'Checked %d: %d settled, %d failed, %d still pending, %d errored.',
            $stale->count(), $settled, $failed, $stillPending, $errors,
        ));

        return $errors > 0 ? self::FAILURE : self::SUCCESS;
    }
}
