<?php

namespace App\Actions;

use App\Models\Instance;
use App\Models\Invoice;
use App\Models\Operator;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * A consumer changes their mind inside fourteen days, and everything that
 * follows from it.
 *
 * One action, because a withdrawal is one event with four consequences and
 * three of them are irreversible. Split across a controller, a command and a
 * page, they would come apart: a service ended with no credit note, a refund
 * sent for a contract still running, a document issued twice by two clicks.
 *
 * In order, and the order is the design:
 *
 *  1. **The right is checked**, by WithdrawalRight and nowhere else. A business
 *     customer is refused here, on the server, whatever the page did or did not
 *     show — every door into this action is the same door.
 *  2. **The withdrawal is claimed**, conditionally, on the contract. Two clicks
 *     in flight write one withdrawal between them; the loser is told it has
 *     already happened rather than sending a second refund.
 *  3. **The paperwork**: EVERY invoice raised inside the window is CANCELLED by a
 *     Storno with its own number — never edited, never deleted. That is the
 *     established rule for correcting an issued document, and a withdrawal is the
 *     clearest case there is for it. Every one of them, and not just the opening
 *     purchase: a module booked on day three and a renewal that fell inside the
 *     fourteen days are charges too, and they used to be left standing because
 *     they carry no `order_id` — see chargesInsideTheWindow().
 *  4. **The money**, which falls out of the paperwork rather than being
 *     computed beside it: the whole of what each cancelled document said, sent
 *     back against the payment that actually took it. FAGG §16 would let us keep
 *     the pro-rata value of the days the cloud ran, and the owner has decided not
 *     to — a withdrawing consumer gets everything back. Two sums that must agree
 *     cannot disagree if only one of them is ever calculated, and here there is
 *     only the one: the Stornos'.
 *  5. **The service ends**, through App\Actions\EndInstanceService and the
 *     `cancellation_scheduled` machinery it already reads — the address comes
 *     down, the DNS record goes, the instance is marked ended. No second way
 *     for an instance to stop being served. Stripe is told to stop billing in
 *     the same breath, immediately: a withdrawal unwinds the contract, so there
 *     is no paid-up term to run out and no next cycle to raise.
 *
 * ## What happens when the money side fails
 *
 * It is caught, written onto the contract and logged as an error — and the
 * withdrawal still stands. The customer's declaration is what ends the
 * contract; a payment provider being unreachable does not un-declare it, and
 * leaving the service running because a refund timed out would charge them for
 * days they have withdrawn from. `withdrawal_refund_error` is where an operator
 * finds the ones that need a hand.
 */
class WithdrawContract
{
    /** Recorded by the customer themselves, in the portal. */
    public const CHANNEL_PORTAL = 'portal';

    /** Taken by telephone or post and typed in by an operator. */
    public const CHANNEL_OPERATOR = 'operator';

    public function __construct(
        private EndInstanceService $endService,
        private BookAddon $bookAddon,
        private IssueInvoice $invoices,
        private StripeClient $stripe,
        private RecordCommercialEvent $record,
    ) {}

    /**
     * @param  string  $channel  one of the CHANNEL_* constants
     * @param  Operator|null  $by  the operator who took the declaration, when it did not come from the portal
     *
     * @throws RuntimeException with the sentence the customer is shown, when the right does not apply
     */
    public function __invoke(
        Subscription $subscription,
        string $channel = self::CHANNEL_PORTAL,
        ?Operator $by = null,
        ?Carbon $at = null,
    ): Subscription {
        $at ??= now();

        $right = WithdrawalRight::for($subscription);

        if (! $right->open) {
            // The refusal is the customer's own sentence, not a developer's:
            // whatever refuses — the portal, the console, a stale tab — says
            // the same thing, in the same words.
            throw new RuntimeException((string) ($right->refusal ?? __('withdrawal.refusal_expired')));
        }

        // The one atomic gate. Everything below happens exactly once because
        // exactly one caller can turn this column over.
        $claimed = Subscription::query()
            ->whereKey($subscription->getKey())
            ->whereNull('withdrawn_at')
            ->update([
                'withdrawn_at' => $at,
                'withdrawal_channel' => $channel,
                'withdrawal_recorded_by' => $by?->id,
                'updated_at' => now(),
            ]);

        $subscription->refresh();

        if ($claimed === 0) {
            throw new RuntimeException(__('withdrawal.refusal_already'));
        }

        $refunded = $this->settle($subscription, $at);

        $this->endEverything($subscription, $at);

        $this->enterInRegister($subscription, $refunded, $at);

        return $subscription->refresh();
    }

    /**
     * The paperwork and the money, for every charge inside the window.
     *
     * One Storno per document and one refund per payment, because that is what
     * each of the two things IS: a Storno mirrors exactly one invoice and carries
     * its number, and a Stripe refund goes against exactly one payment. A single
     * refund for the whole sum, sent against the opening payment, would be
     * refused by Stripe for exceeding it — which is the trap the old one-invoice
     * version was hiding behind.
     *
     * Each charge is settled in its own try, so a module invoice whose payment
     * cannot be reached does not stop the opening purchase from being refunded.
     * What actually went back is totalled from the refunds that succeeded, never
     * from what was intended — a contract claiming money moved that did not is
     * the one state nobody may discover three weeks later.
     *
     * @return int what actually went back to the customer, gross, in cents
     */
    private function settle(Subscription $subscription, Carbon $at): int
    {
        $charges = $this->chargesInsideTheWindow($subscription, $at);
        $refunded = 0;
        $references = [];
        $failed = false;

        if ($charges->isEmpty()) {
            // No document was ever issued for this purchase — the company
            // details were incomplete when it was made, or it was a grant. There
            // is nothing to cancel, so the refund is worked out from what was
            // charged instead. Stated here rather than silently skipped: a
            // withdrawal without paperwork is a withdrawal an accountant will
            // ask about.
            try {
                $gross = max(0, $subscription->order?->chargedCents() ?? 0);

                $reference = $this->refund(
                    $subscription,
                    $gross,
                    $this->paymentBehind(null, $subscription),
                    'clupilot-withdrawal-'.$subscription->uuid,
                    $at,
                );

                if ($reference !== null) {
                    $refunded += $gross;
                    $references[] = $reference;
                }
            } catch (Throwable $e) {
                $this->parkMoneyFailure($subscription, $e);
                $failed = true;
            }
        }

        foreach ($charges as $invoice) {
            try {
                // Taken back in full, with its own gapless number, pointing at
                // the one it cancels. The original keeps its number for ever.
                $storno = $this->invoices->cancelling($invoice);

                // The refund IS what the cancellation took back — never a second
                // figure computed beside it, which would have to agree with the
                // document and one day would not.
                $gross = max(0, -(int) $storno->gross_cents);

                $reference = $this->refund(
                    $subscription,
                    $gross,
                    $this->paymentBehind($invoice, $subscription),
                    // Keyed per DOCUMENT and not per contract. One key for the
                    // whole withdrawal would have Stripe replay the first
                    // refund's answer for the second, and the second charge
                    // would silently never be sent back at all.
                    'clupilot-withdrawal-'.$subscription->uuid.'-invoice-'.$invoice->id,
                    $at,
                );

                if ($reference !== null) {
                    $refunded += $gross;
                    $references[] = $reference;
                }
            } catch (Throwable $e) {
                $this->parkMoneyFailure($subscription, $e);
                $failed = true;
            }
        }

        $subscription->update([
            'withdrawal_refund_cents' => $refunded,
            // Every reference, because there is a refund per charge and an
            // operator reconciling this against Stripe needs all of them.
            'withdrawal_refund_reference' => $references === []
                ? null
                : mb_substr(implode(',', $references), 0, 250),
            // Cleared only when nothing went wrong. parkMoneyFailure() has
            // already written the reason otherwise, and clearing it here would
            // erase the one note saying this withdrawal needs a hand.
            ...($failed ? [] : ['withdrawal_refund_error' => null]),
        ]);

        return $refunded;
    }

    /**
     * Every charge this contract raised inside the withdrawal window.
     *
     * `invoices.order_id` alone was the defect: only the opening purchase carries
     * one. A renewal and a Stripe-invoice document are written against the
     * CONTRACT and leave `order_id` null on purpose — see
     * IssueInvoice::forBilledPeriod() and forStripeInvoice(), which say why — so a
     * customer who booked a storage pack on day three and withdrew on day ten had
     * that pack neither cancelled nor refunded. The owner's rule is that a
     * withdrawing consumer gets EVERYTHING back, so every document has to be in
     * this list.
     *
     * Both links, therefore, and both are needed: the opening invoice has an
     * `order_id` and no `subscription_id`, and everything after it is the other
     * way round.
     *
     * Cancellations are excluded by construction — one is not an invoice that can
     * be taken back — and so is anything that already carries a Storno.
     * IssueInvoice::cancelling() refuses the latter in its own right; excluding it
     * here as well means a second withdrawal attempt finds nothing to do rather
     * than raising an exception that would be parked as a money failure.
     *
     * Bounded at the moment of withdrawal rather than at fourteen days. It is the
     * same set — a withdrawal can only be declared inside the window, so every
     * document this contract has is inside it — and the bound that matters is
     * "before the customer changed their mind", which is what `$at` is.
     *
     * @return Collection<int, Invoice>
     */
    private function chargesInsideTheWindow(Subscription $subscription, Carbon $at): Collection
    {
        return Invoice::query()
            ->where(function ($query) use ($subscription) {
                $query->where('subscription_id', $subscription->id);

                if ($subscription->order_id !== null) {
                    $query->orWhere('order_id', $subscription->order_id);
                }
            })
            ->whereNull('cancels_invoice_id')
            ->whereNotIn(
                'id',
                Invoice::query()->whereNotNull('cancels_invoice_id')->select('cancels_invoice_id')
            )
            ->where('created_at', '<=', $at)
            ->orderBy('id')
            ->get();
    }

    /**
     * The Stripe payment a charge was settled by.
     *
     * Stripe refunds a PAYMENT, not a subscription and not an invoice, and each
     * charge inside the window was a different payment: the checkout, a renewal,
     * a module prorated in the middle of a term. So every document is sent back
     * against the money that actually paid for it.
     *
     * The opening purchase is the one document with no Stripe invoice of its own —
     * it is written from the ORDER, which is where the checkout's PaymentIntent
     * was recorded, falling back to the invoice that charged the card, resolved
     * through Stripe once. A contract with neither was never paid for through
     * Stripe at all (a grant, a contract opened by hand), and there is nothing to
     * send back.
     */
    private function paymentBehind(?Invoice $invoice, Subscription $subscription): ?string
    {
        if ($invoice?->stripe_invoice_id !== null) {
            return $this->stripe->invoicePaymentReference((string) $invoice->stripe_invoice_id);
        }

        $order = $subscription->order;
        $reference = $order?->stripe_payment_intent_id;

        if ($reference === null && $order?->stripe_invoice_id !== null) {
            $reference = $this->stripe->invoicePaymentReference((string) $order->stripe_invoice_id);
        }

        return $reference;
    }

    /**
     * Put one charge's money back where it came from.
     *
     * @return string|null Stripe's reference for the refund, or null when there
     *                     was nothing to send back
     */
    private function refund(
        Subscription $subscription,
        int $grossCents,
        ?string $reference,
        string $idempotencyKey,
        Carbon $at,
    ): ?string {
        if ($grossCents <= 0) {
            return null;
        }

        if ($reference === null) {
            throw new RuntimeException(
                'No Stripe payment is recorded for this charge, so the refund has to be sent by hand.'
            );
        }

        // A retry after a timeout that in fact went through replays Stripe's
        // first answer rather than sending the money a second time — which is
        // the single most expensive mistake this file could make.
        $refundId = $this->stripe->refund($reference, $grossCents, idempotencyKey: $idempotencyKey);

        Log::info('Refunded a withdrawal.', [
            'subscription' => $subscription->uuid,
            'refund' => $refundId,
            'gross_cents' => $grossCents,
            'withdrawn_at' => $at->toIso8601String(),
        ]);

        return $refundId;
    }

    /**
     * A withdrawn contract ends: the modules stop, the address comes down and
     * the contract is closed.
     *
     * Through the machinery that already does this, never beside it. The
     * instance is put into `cancellation_scheduled` with its service ending NOW
     * — which is what a withdrawal means, unlike a cancellation, where the
     * customer keeps the term they paid for — and EndInstanceService is asked
     * the same question it is asked by the nightly sweep.
     *
     * The modules are ended outright rather than at the period end, for the same
     * reason: there is no term left to keep. The customer has withdrawn from the
     * contract the modules hang off, and BookAddon::cancel() takes them off
     * Stripe and out of the register.
     *
     * The order of the two Stripe calls is the reason the subscription is stopped
     * LAST and not first. BookAddon::cancel() takes each module's item off the
     * subscription, and an item cannot be removed from a subscription that no
     * longer exists — so cancelling the whole thing first would make every one of
     * those removals fail and park a sync error on a contract that is already
     * over.
     */
    private function endEverything(Subscription $subscription, Carbon $at): void
    {
        foreach (SubscriptionAddon::query()->where('subscription_id', $subscription->id)->active()->get() as $addon) {
            $this->bookAddon->cancel($addon);
        }

        $this->stopBilling($subscription);

        $instance = $this->instanceOf($subscription);

        if ($instance !== null && $instance->status !== 'ended') {
            $instance->update([
                'status' => 'cancellation_scheduled',
                'cancel_requested_at' => $at,
                // Now, not the end of the term. A withdrawal unwinds the
                // contract; there is no paid-up period to run out.
                'service_ends_at' => $at,
            ]);

            ($this->endService)($instance->refresh());
        }

        $subscription->update([
            'status' => 'cancelled',
            'cancelled_at' => $at,
        ]);
    }

    /**
     * Tell Stripe to stop, now.
     *
     * The call nothing in this application used to make. A withdrawal marked the
     * contract cancelled, ended the service and sent the money back, and the
     * subscription at Stripe went on charging the card every month — for a
     * machine that had been switched off, with `invoice.paid` arriving here each
     * time and drawing a real invoice number for it.
     *
     * Immediately and not at the period end, which is the whole difference
     * between this and a customer's own cancellation: a withdrawal unwinds the
     * contract from the beginning, the entire amount has just gone back, and
     * there is no paid-up term left for the customer to use up.
     *
     * A failure does not un-declare the withdrawal — same principle as the refund,
     * for the same reason — but it is the most urgent thing on this contract, so
     * it is said on the contract as well as in the log. Written after settle(),
     * so the note cannot be cleared by a refund that succeeded.
     */
    private function stopBilling(Subscription $subscription): void
    {
        // Nothing to stop, and nothing has gone wrong. A granted package was
        // never sold through Stripe — GrantSubscription leaves the id null on
        // purpose — and neither was a contract opened by hand.
        if ($subscription->stripe_subscription_id === null) {
            return;
        }

        try {
            $this->stripe->cancelSubscription(
                (string) $subscription->stripe_subscription_id,
                StripeClient::CANCEL_IMMEDIATELY,
                // Keyed on the contract, because a contract is withdrawn from
                // once. A retry after a timeout replays Stripe's first answer.
                idempotencyKey: 'clupilot-withdrawal-stop-'.$subscription->uuid,
            );
        } catch (Throwable $e) {
            Log::error('A withdrawal was declared and Stripe was not told to stop charging for it.', [
                'subscription' => $subscription->uuid,
                'stripe_subscription' => $subscription->stripe_subscription_id,
                'error' => $e->getMessage(),
            ]);

            $this->note($subscription, 'Stripe was not told to stop: '.$e->getMessage());
        }
    }

    /**
     * The machine this contract pays for.
     *
     * `subscriptions.instance_id` is the link, and it is written when resources
     * are reserved — so a contract withdrawn from before the build got that far
     * has none. The order and then the customer are the fallbacks, in that
     * order, because an order belongs to exactly one purchase and a customer can
     * have more than one instance behind them.
     */
    private function instanceOf(Subscription $subscription): ?Instance
    {
        if ($subscription->instance !== null) {
            return $subscription->instance;
        }

        if ($subscription->order_id !== null) {
            $byOrder = Instance::query()->where('order_id', $subscription->order_id)->latest('id')->first();

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

        return Instance::query()
            ->where('customer_id', $subscription->customer_id)
            ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
            ->latest('id')
            ->first();
    }

    /**
     * The refund did not go out. Said on the contract, not only in a log.
     *
     * What this describes is a customer who has withdrawn, whose service has
     * stopped, and who has not had their money back — the one state nobody may
     * discover from a log file three weeks later.
     */
    private function parkMoneyFailure(Subscription $subscription, Throwable $e): void
    {
        Log::error('A withdrawal was declared but the money did not go back.', [
            'subscription' => $subscription->uuid,
            'customer' => $subscription->customer_id,
            'error' => $e->getMessage(),
        ]);

        $this->note($subscription, $e->getMessage());
    }

    /**
     * Write a sentence an operator has to read onto the contract itself.
     *
     * Appended rather than overwritten, because a withdrawal can go wrong in more
     * than one way at once — a module invoice whose payment could not be reached
     * AND an order to stop billing that Stripe refused — and an operator who read
     * only the last of them would send the money back and leave the customer
     * subscribed.
     */
    private function note(Subscription $subscription, string $sentence): void
    {
        $existing = trim((string) $subscription->withdrawal_refund_error);

        $subscription->update([
            'withdrawal_refund_error' => mb_substr(
                $existing === '' ? $sentence : $existing.' | '.$sentence,
                0,
                250,
            ),
        ]);
    }

    /**
     * The entry that says this happened, in the register that cannot be edited.
     *
     * Negative, because it is revenue leaving. The gross is what actually went
     * back, so a withdrawal that could not be refunded records a zero and the
     * `withdrawal_refund_error` beside it says why — rather than claiming money
     * moved that did not.
     */
    private function enterInRegister(Subscription $subscription, int $refundedGross, Carbon $at): void
    {
        ($this->record)(
            event: SubscriptionRecord::EVENT_WITHDRAWAL,
            subscription: $subscription->refresh(),
            netCents: -(int) $subscription->price_cents,
            at: $at,
            extra: ['withdrawal' => [
                'channel' => $subscription->withdrawal_channel,
                // Nothing is charged for them — the refund is the whole amount —
                // but how long the service actually ran is a fact about this
                // contract, and the register is where facts about it are kept.
                'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at),
                'term_days' => WithdrawalRight::termDays($subscription),
                'refunded_gross_cents' => $refundedGross,
            ]],
            chargedGrossCents: -$refundedGross,
            eventKey: 'withdrawal:'.$subscription->uuid,
        );
    }
}
