<?php

namespace App\Actions;

use App\Exceptions\IdentityCollisionException;
use App\Mail\InvoiceMail;
use App\Mail\OrderConfirmationMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

/**
 * Turns a paid Stripe event into a customer + order + provisioning run. The
 * order's unique stripe_event_id is the idempotency key: a duplicate webhook
 * never starts a second run.
 */
class StartCustomerProvisioning
{
    public function __construct(
        private OpenSubscription $openSubscription,
        private ApplyStripeBillingEvent $billing,
    ) {}

    /**
     * @param  array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,setup_fee_cents?:int,currency:string,immediate_start_consent?:bool}  $event
     */
    public function fromStripeEvent(array $event): ?Order
    {
        $existing = Order::query()->where('stripe_event_id', $event['id'])->first();

        if ($existing !== null) {
            $this->resume($existing);

            return null;
        }

        $customer = $this->resolveCustomer($event);

        try {
            $customer->ensureUser(); // portal login (also enables admin impersonation)
        } catch (IdentityCollisionException) {
            // Same principle as openContract() below: the order is the record
            // that money changed hands, so a paid order still gets recorded
            // and provisioned. Only the colliding portal LOGIN is withheld —
            // R21 — until an operator resolves the address conflict by hand
            // (rename the operator, or the customer). Until then this
            // customer has no self-service sign-in and cannot be
            // impersonated either; both call ensureUser() the same way.
            Log::error('Portal login withheld: this customer email already belongs to an operator account.', [
                'customer_id' => $customer->id, 'email' => $customer->email,
            ]);
        }

        try {
            [$order, $run] = DB::transaction(function () use ($event, $customer) {
                $order = Order::create([
                    'customer_id' => $customer->id,
                    'plan' => $event['plan'],
                    // Set once Stripe checkout carries it (phase 5). Null means
                    // "whatever is on sale when the webhook lands", which is
                    // what this did before the column existed.
                    'plan_version_id' => $event['plan_version_id'] ?? null,
                    'amount_cents' => $event['amount_cents'],
                    // How much of `amount_cents` was the one-off setup fee. Kept
                    // beside the total rather than subtracted from it: a withdrawal
                    // sends back everything that was taken and the confirmation
                    // mail states everything that was taken, while the register and
                    // the invoice need the package and the fee apart — see
                    // Order::packageChargedCents().
                    'setup_fee_cents' => (int) ($event['setup_fee_cents'] ?? 0),
                    'currency' => $event['currency'],
                    'datacenter' => $event['datacenter'],
                    'stripe_event_id' => $event['id'],
                    'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
                    // Kept so a withdrawal has something to refund against; see
                    // App\Actions\WithdrawContract.
                    'stripe_payment_intent_id' => $event['stripe_payment_intent_id'] ?? null,
                    'stripe_invoice_id' => $event['stripe_invoice_id'] ?? null,
                    // Stamped only where the consumer actually gave it. Nothing
                    // turns on it any more: a withdrawal refunds the whole
                    // amount whether it is there or not, because the owner has
                    // decided not to charge for the days the cloud ran. Kept as
                    // a record of what the customer was shown and ticked.
                    'immediate_start_consent_at' => ($event['immediate_start_consent'] ?? false) === true ? now() : null,
                    'status' => 'paid',
                ]);

                $run = ProvisioningRun::create([
                    'subject_type' => Order::class,
                    'subject_id' => $order->id,
                    'pipeline' => 'customer',
                    'status' => ProvisioningRun::STATUS_PENDING,
                    'current_step' => 0,
                    // Snapshot resolved branding so retries apply identical inputs
                    // (unset customer → CluPilot defaults, resolved once here).
                    'context' => ['branding' => $customer->brandingResolved()],
                ]);

                return [$order, $run];
            });
        } catch (UniqueConstraintViolationException) {
            // A concurrent delivery won the race. Both requests will answer
            // Stripe with a 2xx, so this is the last look anyone takes at this
            // payment — if the winner then dies before opening the contract, no
            // retry is coming to fix it. So finish its work rather than just
            // stepping aside. The unique index on subscriptions.order_id keeps
            // both from opening one.
            $winner = Order::query()->where('stripe_event_id', $event['id'])->first();

            if ($winner !== null) {
                $this->resume($winner);
            }

            return null;
        }

        $this->openContract($order);

        AdvanceRunJob::dispatch($run->uuid); // after commit

        $this->confirmByMail($order, $customer);

        return $order;
    }

    /**
     * Invoice the payment, and tell the customer once.
     *
     * Both halves are idempotent and live in invoiceOnce() and mailInvoiceOnce()
     * below, because this is not the only caller any more: resume() has to be able
     * to finish exactly this work when a crash cut it off, and two copies of "have
     * we invoiced this yet" would be two answers.
     *
     * Sent here rather than when provisioning finishes: those are minutes apart
     * at best and can be much longer, and somebody who has just been charged
     * and heard nothing assumes the worst about both the charge and the
     * product. CloudReady announces the end; this announces the beginning.
     *
     * After the transaction, never inside it: a queued job can be picked up by
     * a worker before the commit lands, and it would then read an order that
     * does not exist yet.
     *
     * Never allowed to fail the webhook. Stripe retries anything that is not a
     * 2xx, and a retry here would re-enter provisioning — a second machine
     * built — over a mail server or a missing VAT number.
     */
    private function confirmByMail(Order $order, Customer $customer): void
    {
        $invoice = $this->invoiceOnce($order, $customer);

        if ($invoice !== null) {
            $this->mailInvoiceOnce($invoice, $customer);

            return;
        }

        $address = $customer->email;

        if (! is_string($address) || $address === '') {
            return;
        }

        // No invoice, and there could not be one: an incomplete company profile,
        // or a purchase that was a full gift. The confirmation says what it can.
        //
        // Sent from HERE only, never from resume(). It has nothing to stamp — an
        // order carries no `sent_at` — so a redelivery could not tell that it had
        // already gone out, and Stripe redelivers until it gets a 2xx. The invoice
        // mail is the one a crash must not lose, and that one is stamped.
        try {
            $mail = new OrderConfirmationMail($order, (string) $customer->name);
            Mail::mailer($mail->mailer)->to($address)->queue($mail);
        } catch (Throwable $e) {
            Log::warning('Could not queue the purchase confirmation', [
                'order' => $order->id,
                'exception' => $e->getMessage(),
            ]);
        }
    }

    /**
     * The document this purchase owes, issued exactly once however often we are
     * asked.
     *
     * The guard is the invoice already filed against this order, because an
     * invoice number comes out of a gapless series and can never be handed out
     * twice. Cancellations are excluded from the search: a Storno carries the same
     * `order_id` as the document it takes back, so counting one would leave a
     * withdrawn purchase looking as though it had been invoiced by its own
     * cancellation.
     *
     * Not a database-level guard, and it cannot be one — `invoices.order_id` is
     * legitimately shared by an invoice and its Storno, so it can never be unique.
     * What this closes is the case that actually happens: Stripe redelivering the
     * same event, one delivery after another.
     */
    private function invoiceOnce(Order $order, Customer $customer): ?Invoice
    {
        $existing = Invoice::query()
            ->where('order_id', $order->id)
            ->whereNull('cancels_invoice_id')
            ->orderBy('id')
            ->first();

        if ($existing !== null) {
            return $existing;
        }

        try {
            // Refuses while the company details are incomplete, which is the
            // correct outcome — an invoice without a registered name or a VAT
            // number is not a valid invoice, and issuing one consumes a number
            // that can never be handed out again. The purchase is unaffected:
            // the order stands, the machine gets built, and the invoice can be
            // issued from the console once the details are there.
            return app(IssueInvoice::class)->forOrders($customer, collect([$order]));
        } catch (Throwable $e) {
            Log::warning('Could not issue an invoice for this order', [
                'order' => $order->id,
                'exception' => $e->getMessage(),
            ]);

            return null;
        }
    }

    /**
     * Hand the invoice to the mailer, once.
     *
     * `sent_at` is the stamp, the same one App\Actions\IssueStripeInvoice uses,
     * and it is written only after the mailer has accepted the message — so a
     * redelivery arriving after the document was written but before the mail went
     * out finishes the job, and one arriving afterwards does neither twice.
     *
     * ONE mail, not two. The invoice is issued at the moment the money lands, so
     * an order confirmation and an invoice mail would otherwise go out in the same
     * second for the same event. The invoice mail says everything the confirmation
     * said and carries the document, so it replaces it whenever there is one.
     */
    private function mailInvoiceOnce(Invoice $invoice, Customer $customer): void
    {
        $address = $customer->email;

        if (! is_string($address) || $address === '' || $invoice->sent_at !== null) {
            return;
        }

        try {
            $mail = new InvoiceMail($invoice, (string) $customer->name);
            Mail::mailer($mail->mailer)->to($address)->queue($mail);

            $invoice->update(['sent_at' => now()]);
        } catch (Throwable $e) {
            Log::warning('Could not queue the invoice mail for this purchase', [
                'order' => $invoice->order_id,
                'invoice' => $invoice->number,
                'exception' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Finish what a crash interrupted.
     *
     * The order commits before the contract is opened, so that a failure there
     * can never erase the record of a payment — but that leaves a window in
     * which a paid order exists with no contract and nothing running. Stripe
     * retries until it gets a 2xx, so a retry is exactly the chance to close
     * that window. Simply returning "already processed" would strand a paying
     * customer permanently, because no later webhook would ever look again.
     *
     * Safe to run on every duplicate: opening a contract is idempotent. A run
     * that was merely never dispatched needs no nudge from here — the scheduler
     * tick sweeps pending runs. A run that already ran and FAILED for want of
     * the contract does, because nothing sweeps failed runs.
     *
     * ## The invoice is the other half, and it used to be the half that was lost
     *
     * This returned as soon as a contract existed, and the only production call to
     * IssueInvoice::forOrders() is in confirmByMail() — which runs AFTER the
     * order commits. A worker killed in between left a paid purchase with no
     * invoice, for good and in silence: the next redelivery found the contract
     * already open, returned here, and nothing anywhere sweeps for an uninvoiced
     * order. So the invoice and its mail are finished here too, on every
     * redelivery, and the guards in invoiceOnce()/mailInvoiceOnce() are what make
     * "however many times Stripe asks" come out as one invoice, one number and
     * one mail.
     */
    private function resume(Order $order): void
    {
        if (! $order->subscription()->exists()) {
            $this->openContract($order);

            if ($order->subscription()->exists()) {
                $this->reviveRunStrandedWithoutAContract($order);
            }
        }

        $customer = $order->customer;

        if ($customer === null) {
            return;
        }

        $invoice = $this->invoiceOnce($order, $customer);

        if ($invoice !== null) {
            $this->mailInvoiceOnce($invoice, $customer);
        }
    }

    /**
     * Restart a run that failed only because the contract was missing.
     *
     * Narrow on purpose. `no_subscription` is returned before anything is built
     * — the run never got past validating the order or reserving resources — so
     * there is nothing half-made to trip over. A run that failed for any other
     * reason failed at something a contract does not fix, and restarting it
     * would just repeat whatever went wrong.
     */
    private function reviveRunStrandedWithoutAContract(Order $order): void
    {
        $run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();

        if ($run?->status !== ProvisioningRun::STATUS_FAILED
            || ! str_contains((string) $run->error, 'no_subscription')) {
            return;
        }

        // onProvisioningFailed() marked the order failed on the way down.
        $order->update(['status' => 'paid']);

        $run->update([
            'status' => ProvisioningRun::STATUS_PENDING,
            'current_step' => 0,
            'attempt' => 0,
            'next_attempt_at' => null,
            'error' => null,
        ]);

        AdvanceRunJob::dispatch($run->uuid);

        Log::info('Restarted a run that was stranded without a contract.', [
            'order_id' => $order->id, 'run' => $run->uuid,
        ]);
    }

    /**
     * Freeze what they bought, while the catalogue still says what they were
     * shown.
     *
     * Deliberately AFTER the order is committed, and deliberately swallowing
     * its failure. The order is the record that money changed hands: if opening
     * the contract could roll it back, an unsellable plan, a missing price or
     * any unforeseen fault would erase the evidence of a payment we have
     * already taken — and Stripe, seeing no 2xx, would retry a webhook that can
     * never succeed. A missing contract is recoverable and loud: the run stops
     * at ValidateOrder with `no_subscription`, in the operator's face.
     *
     * Two cases are expected to land here: a plan the catalogue cannot sell,
     * and a payment in a currency it cannot price — freezing a EUR price onto a
     * CHF payment would put the contract and the payment in permanent
     * disagreement.
     */
    private function openContract(Order $order): void
    {
        try {
            // A checkout that recorded its version is owed THAT version, even
            // if the window has closed or the plan has been withdrawn since —
            // they paid for what they were shown. Only a purchase with no
            // recorded version has to ask what is on sale now.
            $deliverable = $order->plan_version_id !== null
                ? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
                : Subscription::knowsPlan($order->plan);

            $sellable = $deliverable
                && strtoupper((string) $order->currency) === Subscription::catalogueCurrency();

            if (! $sellable) {
                Log::warning('No contract opened: the catalogue cannot sell this order.', [
                    'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
                ]);

                return;
            }

            $subscription = ($this->openSubscription)($order);

            // Anything Stripe sent about this contract before it existed —
            // a renewal, a failure, a cancellation — applied now, in the order
            // they were raised.
            $this->billing->replayHeldFor($subscription);
        } catch (Throwable $e) {
            Log::error('Failed to open a contract for a paid order.', [
                'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Find or create the customer, race-safe against the unique email index so a
     * concurrent first-time purchase can't create two customers for one email.
     *
     * `customer_type` is deliberately NOT set here, and that is a decision
     * rather than an omission. Nobody in this code path has asked the question:
     * a Stripe event carries a name, an address and a payment, and none of those
     * says whether the person buying is a consumer or a business. Writing either
     * answer would be inventing one — so the column stays NULL, which every
     * reader treats as a consumer, which is the protective answer. Ordinarily
     * there is nothing to invent: the buyer signed up first and was asked then
     * (App\Actions\Fortify\CreateNewUser). This branch is the customer whose
     * Stripe email differs from the one they registered with, and the operator
     * or the customer can record the answer afterwards in the portal settings.
     *
     * @param  array{email:string,name:?string,stripe_customer_id:?string}  $event
     */
    private function resolveCustomer(array $event): Customer
    {
        try {
            return Customer::query()->firstOrCreate(
                ['email' => $event['email']],
                ['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
            );
        } catch (UniqueConstraintViolationException) {
            return Customer::query()->where('email', $event['email'])->firstOrFail();
        }
    }
}
