<?php

namespace App\Actions;

use App\Mail\InvoiceMail;
use App\Models\Invoice;
use App\Models\Subscription;
use App\Services\Billing\IssueInvoice;
use App\Services\Stripe\StripeClient;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

/**
 * The document a paid Stripe invoice owes the customer, and the one mail that
 * carries it.
 *
 * **One document per charge.** That is the owner's rule and it is a legal one,
 * not a preference: every amount taken from a customer needs its own invoice
 * with its own number. Two months of package, a storage module booked in the
 * middle of month three, and the cycle that follows are four charges and four
 * documents — the fourth being the one that carries package and storage
 * together, because they were charged together.
 *
 * This used to issue for `subscription_cycle` alone, and everything else — a
 * proration, a module, a charge raised by hand — was money received that no
 * paper of ours covered. It was written down as a decision and it was the wrong
 * one: Stripe charged for those, so they need documents.
 *
 * **Never allowed to fail the webhook.** Stripe retries anything that is not a
 * 2xx, and there is nothing a retry could fix about an incomplete company
 * profile or a mail server that is down — it would simply arrive again, fail
 * again, and eventually be given up on, taking the register entry's retry with
 * it. So everything here is caught and logged. The payment is recorded whatever
 * happens to the paperwork; a missing document is recoverable, a lost payment is
 * not.
 *
 * **Idempotent against the Stripe invoice.** An invoice number comes out of a
 * gapless series and can never be handed out twice, so the guard is the unique
 * `invoices.stripe_invoice_id` rather than a check — two deliveries arriving
 * together would both pass a check. The number is drawn inside the same
 * transaction the row is written in, so a collision takes the number back with
 * it and the series keeps its sequence.
 *
 * Issuing and mailing are separate steps on purpose. `sent_at` records that the
 * mail was handed to the mailer, so a delivery arriving after the document was
 * written but before the mail went out finishes the job instead of skipping it —
 * and one that arrives afterwards does neither twice.
 */
class IssueStripeInvoice
{
    /**
     * @param  array<string, mixed>  $stripeInvoice  the invoice as Stripe sent it
     * @param  Carbon|null  $from  the term this invoice billed, for the fallback below
     * @return Invoice|null the document, or null when there is not one and could not be one
     */
    public function __invoke(
        Subscription $subscription,
        array $stripeInvoice,
        ?Carbon $from = null,
        ?Carbon $to = null,
    ): ?Invoice {
        $stripeInvoiceId = (string) ($stripeInvoice['id'] ?? '');

        if ($stripeInvoiceId === '') {
            // Without it there is no way to tell a redelivery from a second
            // charge, and guessing wrong costs an invoice number.
            Log::warning('Not issuing a document: the Stripe invoice carries no id, so issuing it could not be made idempotent.', [
                'subscription' => $subscription->uuid,
            ]);

            return null;
        }

        $invoice = $this->issue($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);

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

        $this->send($subscription, $invoice);

        return $invoice;
    }

    /** @param  array<string, mixed>  $stripeInvoice */
    private function issue(
        Subscription $subscription,
        string $stripeInvoiceId,
        array $stripeInvoice,
        ?Carbon $from,
        ?Carbon $to,
    ): ?Invoice {
        $existing = Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();

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

        try {
            $lines = $this->lines($stripeInvoiceId, $stripeInvoice);
        } catch (Throwable $e) {
            // Deliberately no document at all rather than a partial one. The
            // total would still be right and the reader would have no way to see
            // what they had paid for, which is the kind of invoice that gets
            // rejected at an audit. A later redelivery or a replay can still
            // issue it.
            Log::error('Could not read the lines of a paid Stripe invoice, so no document was issued for it.', [
                'subscription' => $subscription->uuid,
                'stripe_invoice' => $stripeInvoiceId,
                'exception' => $e->getMessage(),
            ]);

            return null;
        }

        try {
            if ($lines === []) {
                return $this->fromContract($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);
            }

            $unnamed = [];

            $invoice = app(IssueInvoice::class)->forStripeInvoice(
                subscription: $subscription,
                stripeInvoiceId: $stripeInvoiceId,
                lines: $lines,
                currency: is_string($stripeInvoice['currency'] ?? null) ? $stripeInvoice['currency'] : null,
                unnamed: $unnamed,
            );

            $this->reportUnnamed($subscription, $stripeInvoiceId, $unnamed);
            $this->reportMismatch($invoice, $stripeInvoice);

            return $invoice;
        } catch (UniqueConstraintViolationException) {
            // A concurrent delivery won the race. Its document is the one to
            // send; this one's invoice number went back into the series when the
            // transaction rolled back.
            return Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
        } catch (Throwable $e) {
            // Refuses while the company details are incomplete, which is the
            // correct outcome — see IssueInvoice. The payment itself stands: it
            // is in the register, and the document can be issued once the
            // details are there.
            Log::warning('Could not issue a document for this paid Stripe invoice.', [
                'subscription' => $subscription->uuid,
                'stripe_invoice' => $stripeInvoiceId,
                'exception' => $e->getMessage(),
            ]);

            return null;
        }
    }

    /**
     * Every line Stripe charged for, complete.
     *
     * The event carries them already. It does not always carry ALL of them — a
     * contract with enough modules on it passes the number Stripe puts in a
     * webhook — and `has_more` is how it says so. A document missing a charged
     * line is worse than an extra API call, so the complete list is fetched
     * whenever Stripe admits to holding one.
     *
     * @param  array<string, mixed>  $stripeInvoice
     * @return array<int, array<string, mixed>>
     */
    private function lines(string $stripeInvoiceId, array $stripeInvoice): array
    {
        $lines = (array) ($stripeInvoice['lines']['data'] ?? []);

        if (($stripeInvoice['lines']['has_more'] ?? false) !== true) {
            return $lines;
        }

        return app(StripeClient::class)->invoiceLines($stripeInvoiceId);
    }

    /**
     * The document for a cycle invoice that arrived without any line detail.
     *
     * The contract's own frozen price is an honest description of the term that
     * was billed, and better than leaving a paid renewal with no paper at all.
     * Only for a cycle: a proration or a charge raised by hand describes
     * something the contract cannot answer for, and a line invented for those
     * would be a guess printed on a document nobody can correct afterwards.
     *
     * @param  array<string, mixed>  $stripeInvoice
     */
    private function fromContract(
        Subscription $subscription,
        string $stripeInvoiceId,
        array $stripeInvoice,
        ?Carbon $from,
        ?Carbon $to,
    ): ?Invoice {
        $isCycle = (string) ($stripeInvoice['billing_reason'] ?? '') === 'subscription_cycle';

        if (! $isCycle || $from === null || $to === null) {
            Log::warning('A paid Stripe invoice carried no lines, so no document could be built from it.', [
                'subscription' => $subscription->uuid,
                'stripe_invoice' => $stripeInvoiceId,
                'billing_reason' => $stripeInvoice['billing_reason'] ?? null,
            ]);

            return null;
        }

        // The total Stripe took, where it said so. A document has to add up to
        // the money, and the contract's own price at today's rate is only the
        // next best answer — it would miss a cycle billed before a rate change.
        $total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;

        return app(IssueInvoice::class)->forBilledPeriod(
            $subscription,
            $from,
            $to,
            $stripeInvoiceId,
            chargedCents: is_numeric($total) ? (int) $total : null,
        );
    }

    /**
     * Say out loud that a line was charged which the catalogue could not name.
     *
     * The line is on the document either way, carrying whatever description
     * Stripe sent. This is the trail for whoever has to work out what was sold —
     * a Price created by hand in Stripe's dashboard, or a module removed from
     * the catalogue while somebody was still paying for it.
     *
     * @param  array<int, string>  $unnamed
     */
    private function reportUnnamed(Subscription $subscription, string $stripeInvoiceId, array $unnamed): void
    {
        if ($unnamed === []) {
            return;
        }

        Log::warning('A charged line was put on an invoice under Stripe’s own description, because this catalogue could not name it.', [
            'subscription' => $subscription->uuid,
            'stripe_invoice' => $stripeInvoiceId,
            'prices' => $unnamed,
        ]);
    }

    /**
     * Say out loud when the document and the money do not agree.
     *
     * Our catalogue is pushed to Stripe as GROSS, so the line amounts that come
     * back are what the customer paid and the document divides that sum rather
     * than adding a rate to it. Stripe's total and our gross must therefore be
     * the SAME NUMBER, to the cent, for every customer — including a
     * reverse-charge one, whose whole charged amount is stated as net at 0 %.
     *
     * This used to accept our net as an alternative, on the grounds that an
     * account might not collect tax for us. That tolerance is what let the
     * defect run: 179,00 € taken against a document for 214,80 € matched the
     * "net" branch and was never reported. Anything but equality is now worth a
     * warning, and the likeliest cause is a Price that stripe:sync-catalogue has
     * replaced with nobody having run stripe:reprice-subscriptions.
     *
     * Never an exception. The document is issued and the payment is recorded;
     * raising here would only cost Stripe its 2xx and change nothing.
     *
     * @param  array<string, mixed>  $stripeInvoice
     */
    private function reportMismatch(Invoice $invoice, array $stripeInvoice): void
    {
        $total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;

        if (! is_numeric($total)) {
            return;
        }

        $total = (int) $total;

        if ($total === $invoice->gross_cents) {
            return;
        }

        Log::warning('An invoice was issued for a sum Stripe did not charge.', [
            'invoice' => $invoice->number,
            'stripe_invoice' => $invoice->stripe_invoice_id,
            'stripe_total' => $total,
            'net' => $invoice->net_cents,
            'gross' => $invoice->gross_cents,
        ]);
    }

    private function send(Subscription $subscription, Invoice $invoice): void
    {
        $customer = $subscription->customer;
        $address = $customer?->email;

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

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

            // Only once it is actually with the mailer. Stamped so a redelivery
            // does not send the same invoice a second time, and left unstamped
            // on a failure so that one still can.
            $invoice->update(['sent_at' => now()]);
        } catch (Throwable $e) {
            Log::warning('Could not queue the invoice mail for this payment.', [
                'invoice' => $invoice->number,
                'exception' => $e->getMessage(),
            ]);
        }
    }
}
