<?php

namespace App\Http\Controllers;

use App\Models\Customer;
use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\SetupFee;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Services\Stripe\StripeClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * Buying a package, without anyone having to write an email about it.
 *
 * The site used to end every path in "anfragen": a mailto: link and a promise
 * to answer the same working day. That is a person's afternoon per customer,
 * for a product whose entire point is that the machine does the work — and it
 * scales exactly as far as the operator's inbox does.
 *
 * So: a signed-in customer picks a package, pays, and provisioning starts by
 * itself. Where no host has room the order is parked rather than refused (see
 * ReserveResources) — that is the ONE case that still needs a human, and it
 * needs them to buy a server, not to answer a mail.
 *
 * This class does not take money and does not create a contract. It opens a
 * Stripe hosted checkout and gets out of the way; the purchase becomes real
 * when Stripe says so, on the webhook, which is the only place that may believe
 * a payment happened. A "thank you" page proves nothing — the customer can open
 * it by typing the URL.
 */
class CheckoutController extends Controller
{
    /**
     * Send the customer to Stripe for one package.
     *
     * Everything here is re-derived from the catalogue: the plan key arrives
     * from a form, and a form field is a string somebody can retype. The price
     * charged is the one the catalogue holds for the version on sale right now,
     * never a figure carried in the request.
     */
    public function start(
        Request $request,
        StripeClient $stripe,
        PlanCatalogue $catalogue,
        PlanPrices $planPrices,
    ): RedirectResponse {
        $data = $request->validate([
            'plan' => ['required', 'string', 'max:64'],
            'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY],
            // Acceptance of the terms, which is where everything about this
            // sale is regulated: the delivery, the price, the fourteen-day
            // withdrawal right, and the consumer's express request that we
            // begin inside that window (FAGG §16, §356 BGB) — a cloud is built
            // the moment the payment lands, so that request cannot be avoided
            // and is stated on the checkbox as well as in the terms.
            //
            // Required. An order cannot be submitted without it: the box is the
            // declaration, and the browser only disables the button — this is
            // the line that actually refuses.
            'terms_accepted' => ['accepted'],
        ], [
            'terms_accepted.accepted' => __('checkout.terms_required'),
        ]);

        $term = $data['term'] ?? Subscription::TERM_MONTHLY;
        $user = $request->user();

        // Read once and handed to everything below that has to know who is
        // buying. Which of a package's two Stripe Prices this session uses is
        // decided from it, and a second lookup is a second chance to decide it
        // differently.
        $customer = $user === null
            ? null
            : Customer::query()->where('email', $user->email)->first();

        // Somebody who already has a running contract is not buying a second
        // one by accident: changing package is a plan change, which prorates
        // and keeps their data where it is. A second checkout would build them
        // a second, empty cloud and bill for both.
        if ($this->hasLiveContract($customer)) {
            return redirect()->route('billing')->with('status', __('checkout.already_customer'));
        }

        if (! $stripe->isConfigured()) {
            // Nothing to send them to. Better a plain sentence than a redirect
            // into an error page on somebody else's domain.
            Log::error('Checkout attempted while Stripe is not configured.');

            return back()->withErrors(['plan' => __('checkout.unavailable')]);
        }

        try {
            // Die einzige Stelle, an der ein Paketschlüssel VON AUSSEN in
            // currentVersion() läuft. Dass ein internes Paket in keiner Liste
            // steht, ist kein Schutz — eine URL ist keine Liste. Geworfen statt
            // sofort abgelehnt, damit es genau in den Fang darunter fällt und
            // dieselbe Antwort bekommt wie ein Schlüssel, den es gar nicht
            // gibt: wer die Preisliste nicht kennt, soll aus der Antwort nicht
            // lernen können, dass es dieses Paket gibt.
            if (PlanFamily::query()->where('key', $data['plan'])->value('internal')) {
                throw new RuntimeException("Plan '{$data['plan']}' is internal and not sold at checkout.");
            }

            $version = $catalogue->currentVersion($data['plan']);
            $price = $version->prices->firstWhere('term', $term);
        } catch (Throwable $e) {
            // An unknown, withdrawn or overlapping plan. The catalogue throws
            // by design; a shop must not guess which of two versions was meant.
            Log::warning('Checkout for an unavailable plan', ['plan' => $data['plan'], 'exception' => $e]);

            return back()->withErrors(['plan' => __('checkout.plan_gone')]);
        }

        // WHO is buying decides WHICH of the package's two Stripe Prices this
        // session uses: the domestic gross for everybody who is charged VAT, the
        // bare net for a business in another member state whose VAT id is
        // verified, because reverse charge means no VAT is owed to us at all.
        // Decided by TaxTreatment and read from PlanPrices — nothing about the
        // customer is examined here, or there would be two answers to one
        // question and no way to tell which an invoice was written against.
        $treatment = TaxTreatment::for($customer);
        $priceId = $price === null ? null : $planPrices->liveFor($price, $treatment);

        // A Price Stripe has never been told about. Sending the customer on would
        // open a checkout for nothing — and for a reverse-charge business, refusing
        // is the only safe answer even though a Price does exist: the domestic one
        // would take a fifth more than they owe, which is the overcharge this whole
        // rule exists to end. A refused checkout is loud, one command cures it, and
        // the log line names the command.
        if ($priceId === null) {
            Log::error('Checkout for a plan that is not on sale in Stripe. Run stripe:sync-catalogue.', [
                'plan' => $data['plan'],
                'term' => $term,
                'reverse_charge' => $treatment->reverseCharge,
            ]);

            return back()->withErrors(['plan' => __('checkout.plan_gone')]);
        }

        // The one-off setup fee as this customer is charged it, or null where the
        // operator has set none. Advertised on the price sheet and on the booking
        // page for months and charged to nobody; it rides along as a second,
        // non-recurring line on this session, which Stripe puts on the initial
        // invoice only. Subject to the same rule as the package: a reverse-charge
        // business pays the bare net of it.
        $setup = SetupFee::checkoutLine((string) $price->currency, $treatment);

        try {
            $url = $stripe->createCheckoutSession(
                priceId: $priceId,
                successUrl: route('checkout.done'),
                cancelUrl: route('order'),
                // Exactly the keys StripeWebhookController reads back. The
                // VERSION goes along, so the contract is opened on what the
                // customer was shown even if a new version is published while
                // they are typing their card number.
                metadata: [
                    'plan' => $version->family->key,
                    'plan_version_id' => (string) $version->id,
                    'datacenter' => app(HostCapacity::class)->preferredDatacenter(),
                    // Exactly '1', because StripeWebhookController compares
                    // against exactly that: a consent that can be produced by a
                    // typo is not a consent. It travels through Stripe rather
                    // than being stamped here — the contract only exists once
                    // the payment does, and a consent recorded for a checkout
                    // somebody abandoned would be a consent to nothing.
                    //
                    // The KEY keeps its old name deliberately. The form field it
                    // came from is called terms_accepted now, but a checkout
                    // session opened before a deploy is paid after it, and the
                    // webhook would find nothing under a new name. What it
                    // records is unchanged: the customer declared, at this
                    // moment, that we may start at once.
                    'immediate_start' => '1',
                    // What part of Stripe's amount_total is the setup fee, so the
                    // webhook can tell the two apart again: the package's charge
                    // is what the register holds against the contract price, and
                    // the fee is a line of its own on the first invoice.
                    'setup_fee_cents' => (string) ($setup['amount_cents'] ?? 0),
                ],
                customerEmail: $user?->email,
                oneOff: $setup,
            );
        } catch (Throwable $e) {
            Log::error('Stripe would not open a checkout session', ['exception' => $e]);

            return back()->withErrors(['plan' => __('checkout.unavailable')]);
        }

        return redirect()->away($url);
    }

    /**
     * Where Stripe sends them back to.
     *
     * Deliberately says "we are building it", not "you have paid": this page is
     * a redirect target, and a redirect target is a URL anybody can type. What
     * the customer actually sees underneath is the provisioning card on their
     * dashboard, which is driven by a run that only the webhook can start.
     */
    public function done(): RedirectResponse
    {
        return redirect()->route('dashboard')->with('status', __('checkout.thanks'));
    }

    /** Does this customer already have a contract that is being billed? */
    private function hasLiveContract(?Customer $customer): bool
    {
        return $customer !== null && Subscription::query()
            ->where('customer_id', $customer->id)
            ->whereIn('status', ['active', 'past_due'])
            ->exists();
    }
}
