<?php

namespace App\Livewire;

use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Subscription;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
use Throwable;

/**
 * Cancel the customer's package (R5). Per the founder's decision: effective at
 * the end of the billing term, irreversible once confirmed; at term end the
 * customer receives a finished data export, then the instance is deprovisioned
 * (both mocked for now). Requires typing the instance subdomain to confirm.
 *
 * ## Stripe is told first, and everything else follows from its answer
 *
 * This used to write three columns on the instance and stop there — the contract
 * was untouched and Stripe was never told anything at all, so the card went on
 * being charged every month for a package that had ended, for ever. The order of
 * the two writes is therefore the design:
 *
 *  1. **Stripe is asked to stop at the period end.** If it refuses or cannot be
 *     reached, NOTHING here is recorded and the customer is asked to try again.
 *     Recording a cancellation we could not make effective is the defect this
 *     class was built out of: the customer sees "gekündigt" on their settings page
 *     and keeps paying. A cancellation has no deadline — unlike the statutory
 *     withdrawal in App\Actions\WithdrawContract, which stands whatever Stripe
 *     says — so a retry a minute later costs nobody anything.
 *  2. **Then our own rows.** The reverse failure — Stripe stopped, our write lost
 *     — is the harmless direction: the customer is not charged again, and
 *     Stripe's `customer.subscription.deleted` at the period end closes the
 *     contract here through the machinery that already reads it.
 *
 * The order to stop carries an idempotency key on the contract, so the retry
 * after a timeout that in fact went through is provably a no-op.
 */
class ConfirmCancelPackage extends ModalComponent
{
    use ResolvesCustomer;

    public string $confirmName = '';

    /** Why. One of Subscription::CANCEL_REASONS. */
    public string $reason = '';

    /** What they wanted to say about it — required when the reason is "other". */
    public string $note = '';

    /**
     * Ob der Kunde zum Laufzeitende einen Datenexport will. Drei Zustände,
     * nicht zwei: `null` heißt unbeantwortet und ist beim Kündigen nicht
     * zulässig — diese Frage ist der Angelpunkt des ganzen Vorhabens
     * (Erinnerungen, Export-Vorbereitung) und darf nicht übersprungen
     * werden. `boolean` lässt `true` UND `false` durch: ein bewusstes Nein
     * ist eine ebenso gültige Antwort wie ein Ja und darf nicht wie eine
     * fehlende Antwort behandelt werden.
     */
    #[Validate('required|boolean')]
    public ?bool $exportWish = null;

    public function cancelPackage()
    {
        $customer = $this->customer();
        // Target the ACTIVE package explicitly — a newer failed/deprovisioned
        // record must not shadow an older active instance (v1: one active/customer).
        $instance = $customer?->instances()->where('status', 'active')->latest('id')->first();

        if ($instance === null) {
            // Explain rather than silently closing: no customer behind this login
            // (e.g. an operator account) or no active package to cancel.
            $this->addError('confirmName', __($customer === null ? 'dashboard.no_customer_action' : 'settings.no_package'));

            return null;
        }

        // Typed confirmation must match the instance subdomain.
        if (trim($this->confirmName) !== (string) $instance->subdomain) {
            $this->addError('confirmName', __('settings.cancel_mismatch'));

            return;
        }

        // Asked, and required. A departure with no reason on it is the one fact
        // about it worth having, and "we can ask them later" means never: the
        // customer is here now and never comes back to this form.
        if (! in_array($this->reason, Subscription::CANCEL_REASONS, true)) {
            $this->addError('reason', __('settings.cancel_reason_required'));

            return;
        }

        // "Sonstiges" with nothing beside it is the same as no answer at all.
        if ($this->reason === 'other' && trim($this->note) === '') {
            $this->addError('note', __('settings.cancel_note_required'));

            return;
        }

        // Nicht überspringbar, aus demselben Grund wie der Kündigungsgrund
        // oben: die Kündigung ist der einzige Moment, in dem der Kunde
        // ohnehin über seine Daten nachdenkt, und danach fragt niemand mehr.
        // Vor Stripe geprüft, wie alles andere hier — ein abgebrochener
        // Versuch soll nichts anstoßen, weder bei Stripe noch bei uns.
        $this->validate();

        $contract = $this->contractFor($customer, $instance);

        if (! $this->stopBilling($contract)) {
            $this->addError('confirmName', __('settings.cancel_billing_unreachable'));

            return;
        }

        $instance->update([
            'status' => 'cancellation_scheduled',
            'cancel_requested_at' => now(),
            'service_ends_at' => $this->currentPeriodEnd($instance, $contract),
            'export_wish' => $this->exportWish,
        ]);

        // The contract carries the request too. Without it nothing in the billing
        // half of the application can tell a cancelled contract from an untouched
        // one — and the status deliberately stays `active`, because until the term
        // runs out this is a paying customer.
        $contract?->update([
            'cancel_requested_at' => now(),
            'cancel_reason' => $this->reason,
            'cancel_reason_note' => trim($this->note) ?: null,
        ]);

        return $this->redirectRoute('settings', navigate: true);
    }

    /**
     * Eigene Meldung für die Exportfrage. Im Bestand liegt keine
     * `lang/de/validation.php` — ohne Überschreibung wäre die eingebaute
     * Laravel-Meldung die einzige englische Zeile in einem sonst
     * durchgehend deutschen Dialog.
     */
    protected function messages(): array
    {
        return [
            'exportWish.required' => __('settings.cancel_export_required'),
        ];
    }

    /**
     * Ask Stripe to raise no further cycle for this contract.
     *
     * @return bool whether the cancellation may now be recorded
     */
    private function stopBilling(?Subscription $contract): bool
    {
        // 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 machine built by hand with no contract
        // behind it at all.
        if ($contract?->stripe_subscription_id === null) {
            return true;
        }

        try {
            app(StripeClient::class)->cancelSubscription(
                (string) $contract->stripe_subscription_id,
                // At the period end, because that is what the customer is
                // promised on this very modal: everything stays available to the
                // end of the term they have already paid for.
                StripeClient::CANCEL_AT_PERIOD_END,
                // Keyed on the contract, because a contract is cancelled once.
                idempotencyKey: 'clupilot-cancel-'.$contract->uuid,
            );

            return true;
        } catch (Throwable $e) {
            Log::error('A customer asked to cancel and Stripe could not be told to stop, so nothing was recorded.', [
                'subscription' => $contract->uuid,
                'stripe_subscription' => $contract->stripe_subscription_id,
                'error' => $e->getMessage(),
            ]);

            return false;
        }
    }

    /**
     * The contract this machine is billed through.
     *
     * `subscriptions.instance_id` is the link and is written when resources are
     * reserved. The order is the fallback because an order belongs to exactly one
     * purchase; the customer is the last resort, and only when they have exactly
     * one contract — guessing between two would cancel the wrong one, which is
     * worse than cancelling nothing.
     */
    private function contractFor(?Customer $customer, Instance $instance): ?Subscription
    {
        if ($instance->subscription !== null) {
            return $instance->subscription;
        }

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

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

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

        $contracts = $customer->subscriptions()->where('status', 'active')->get();

        return $contracts->count() === 1 ? $contracts->first() : null;
    }

    /**
     * The end of the term the customer has already paid for.
     *
     * The contract knows it, and it is the only thing that does: `term` is
     * monthly or yearly, and `current_period_end` is the boundary Stripe pushes
     * forward on every renewal — so it is right for both, and right for a
     * customer who is three months into a yearly contract. Walking months forward
     * from the order date, which is what this used to do, took eleven months of
     * paid service off every yearly customer who cancelled.
     *
     * The fallback below is only for a machine with NO contract behind it — one
     * built by hand, or one whose contract failed to open. There is genuinely
     * nothing else to read there: no term is recorded anywhere, so a month is the
     * only assumption available, and it is the shorter of the two.
     */
    private function currentPeriodEnd(Instance $instance, ?Subscription $contract): Carbon
    {
        if ($contract?->current_period_end !== null) {
            // Taken as it stands, even when it is already past. A term that has
            // run out has run out — the customer is in dunning, not in a paid
            // period — and inventing another month for them would give away
            // service nobody decided to give.
            return $contract->current_period_end->copy();
        }

        $start = $instance->order?->created_at ?? $instance->created_at ?? now();

        // Always add from the immutable start so the billing-day anchor is kept
        // (chaining addMonth would drift a Jan-31 start to Feb-28 → Mar-28).
        $months = 1;
        while ($start->copy()->addMonthsNoOverflow($months)->lessThanOrEqualTo(now())) {
            $months++;
        }

        return $start->copy()->addMonthsNoOverflow($months);
    }

    public function render()
    {
        $customer = $this->customer();
        $instance = $customer?->instances()->where('status', 'active')->latest('id')->first();

        // Whether withdrawing is still open to them. Offered as a way OUT of
        // this dialogue rather than as a reason inside it: a withdrawal is a
        // different act with a different consequence — the whole amount back and
        // the service ending the same day — and somebody who is entitled to one
        // should not cancel by mistake and lose it. WithdrawalRight answers for
        // a business customer as well: it says no.
        // contractFor() needs an instance; with none there is nothing to
        // withdraw from either, and WithdrawalRight answers null with "no".
        $withdrawal = WithdrawalRight::for(
            $instance === null ? null : $this->contractFor($customer, $instance)
        );

        return view('livewire.confirm-cancel-package', [
            'subdomain' => $instance?->subdomain ?? '',
            'reasons' => Subscription::CANCEL_REASONS,
            'canWithdraw' => $withdrawal->applies && $withdrawal->open,
            'withdrawalEndsAt' => $withdrawal->endsAt,
        ]);
    }
}
