<?php

namespace App\Services\Billing;

use App\Jobs\ArchiveInvoice;
use App\Models\Customer;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\Order;
use App\Models\Subscription;
use App\Support\CompanyProfile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * Turning what somebody bought into a document that cannot change afterwards.
 *
 * Everything the finished invoice says is copied into `snapshot` here, at the
 * moment the number is assigned: the issuer's registered details, the
 * customer's address, every line, the rate and the reason for it. The PDF is
 * rendered from that and never stored, so the same invoice re-rendered in seven
 * years is the page it was on the day it was issued — whatever has changed in
 * the settings in between.
 *
 * The number and the invoice commit together or not at all. A number taken and
 * then lost to a failure is a gap in a series that must not have one.
 *
 * ## Two kinds of line, and why
 *
 * Everything sold from the catalogue is priced at the till: the price sheet, the
 * Stripe Price and the card charge are one figure — the domestic gross for
 * everybody who is charged VAT, the bare net for a reverse-charge business, whose
 * Stripe Price carries exactly that. A document for such a sale therefore starts
 * from what was CHARGED and divides it — see InvoiceMath::fromCharged() — because
 * a document that rebuilds the total from a net figure and a rate states a sum
 * nobody was ever charged. That was the whole defect: 179,00 € taken and
 * 214,80 € invoiced.
 *
 * A service invoice an operator types is the other kind. Nothing has been
 * charged yet, the figures are what was agreed on the telephone, and they are
 * entered net with the VAT put on top exactly as before.
 *
 * The two never mix on one document, and which one a method produces is stated
 * on the method.
 */
final class IssueInvoice
{
    public function __construct(private readonly InvoiceNumbers $numbers) {}

    /**
     * One invoice covering several orders.
     *
     * Several rather than one-per-order on purpose: a customer who buys a plan
     * and two add-ons in one go has bought once, and sending them three
     * invoices for one purchase is three times the paperwork for the same
     * money. Each order becomes a line, which is also what was asked for —
     * every add-on listed on its own.
     *
     * Each line is what the order actually COST the customer —
     * Order::packageChargedCents(), which is Stripe's own total for the package
     * where the order went through a checkout. It used to be `amount_cents` read
     * as a net figure, and once the catalogue started charging gross that put the
     * VAT on the document twice.
     *
     * A one-off setup fee charged on the same session gets a line of its own, so
     * the document adds up to exactly what the card was charged AND says what each
     * part of it was for.
     *
     * @param  Collection<int, Order>  $orders
     */
    public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice
    {
        // A full gift produces no invoice — no amount, no number drawn from
        // the series. A zero invoice with a sequential number is a bookkeeping
        // foreign body. A discounted grant is unaffected: it still charges
        // something and is invoiced exactly like an ordinary sale below.
        $orders = $orders->reject(fn (Order $order) => $order->isFreeGrant());

        if ($orders->isEmpty()) {
            throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
        }

        $lines = $orders->map(fn (Order $order) => [
            'description' => $order->label(),
            'details' => array_values(array_filter([
                $order->isRecurring() ? __('invoice.line_recurring') : __('invoice.line_once'),
            ])),
            'quantity_milli' => 1000,
            'unit' => '',
            // The package alone. A first purchase can carry the one-off setup fee
            // on the same Stripe session, and it gets a line of its own below —
            // folded into this one it would be a charge the customer can see on
            // their statement and cannot find on their invoice.
            'unit_net_cents' => $order->packageChargedCents(),
        ])->all();

        // The setup fee, once, after the packages. Its own line because it is its
        // own thing: charged one time, not monthly, and named on the document with
        // the same words the price sheet and the checkout used.
        foreach ($orders as $order) {
            if ((int) $order->setup_fee_cents <= 0) {
                continue;
            }

            $lines[] = [
                'description' => __('checkout.setup_fee_line'),
                'details' => [__('invoice.line_once')],
                'quantity_milli' => 1000,
                'unit' => '',
                'unit_net_cents' => (int) $order->setup_fee_cents,
            ];
        }

        return $this->issue(
            customer: $customer,
            lines: $lines,
            currency: strtoupper((string) ($orders->first()->currency ?: 'EUR')),
            series: $series,
            attributes: ['order_id' => $orders->first()->id],
            linesAreCharged: true,
        );
    }

    /**
     * One invoice for a term that was billed — a renewal.
     *
     * Deliberately NOT forOrders() with an Order made up for the occasion. An
     * Order is something a customer bought: it is created at a checkout, it sits
     * in their cart until it is paid, and provisioning and plan changes consume
     * it. A month rolling over is none of those things, and writing a purchase
     * nobody made would corrupt the one record that says what this customer has
     * actually ordered. The document points at the CONTRACT instead, which is
     * what a renewal renews.
     *
     * The line is what the cycle CHARGED. Where Stripe reported a total it is
     * that total, to the cent; where it did not, it is the contract's own frozen
     * net price put through this customer's treatment, which is the amount the
     * Stripe Price this contract is billed on carries — the domestic gross where
     * VAT is charged, the bare net for a reverse-charge business. `price_cents`
     * itself stays the catalogue's net figure and is not by itself what a document
     * totals to.
     *
     * **The fallback, not the ordinary path.** Modules are items on the Stripe
     * subscription now, so what a cycle actually charged for is on Stripe's own
     * invoice — package line, every module line, any proration — and
     * forStripeInvoice() below builds the document from those. This remains for
     * the cycle invoice that arrives carrying no line detail at all, where the
     * contract's own frozen price is the honest description of the term that was
     * billed. Better a document from the contract than none.
     *
     * `$stripeInvoiceId` is what makes issuing this idempotent: the column is
     * unique, so a redelivered webhook collides in the database and the
     * transaction takes its invoice number back with it.
     */
    public function forBilledPeriod(
        Subscription $subscription,
        Carbon $from,
        Carbon $to,
        ?string $stripeInvoiceId = null,
        ?InvoiceSeries $series = null,
        ?int $chargedCents = null,
    ): Invoice {
        $customer = $subscription->customer;

        if ($customer === null) {
            throw new RuntimeException('Refusing to issue an invoice for a contract with nobody on it.');
        }

        $lines = [[
            'description' => __('billing.cart.plan', ['plan' => ucfirst((string) $subscription->plan)]),
            'details' => [__('invoice.line_period', [
                'from' => $from->local()->format('d.m.Y'),
                // Stripe's period_end is the EXCLUSIVE boundary — the first
                // moment of the next term, which the next invoice will claim in
                // turn. Printed as it comes, the same day would appear on two
                // consecutive documents.
                'to' => $to->greaterThan($from)
                    ? $to->copy()->subDay()->local()->format('d.m.Y')
                    : $to->local()->format('d.m.Y'),
            ])],
            'quantity_milli' => 1000,
            'unit' => '',
            'unit_net_cents' => $chargedCents
                ?? TaxTreatment::for($customer)->chargeCents((int) $subscription->price_cents),
        ]];

        return $this->issue(
            customer: $customer,
            lines: $lines,
            currency: strtoupper((string) ($subscription->currency ?: 'EUR')),
            series: $series,
            linesAreCharged: true,
            attributes: [
                'subscription_id' => $subscription->id,
                // Left null on purpose. The contract's opening order is the
                // FIRST purchase and already has its own invoice; pointing this
                // one at it would file two documents under one sale.
                'order_id' => null,
                'stripe_invoice_id' => $stripeInvoiceId,
            ],
        );
    }

    /**
     * One document for one paid Stripe invoice, carrying every line Stripe
     * charged for.
     *
     * This is the ordinary path now, and it replaces a rule that had stopped
     * being true. A cycle invoice used to be one line at the contract's frozen
     * price, because that was all Stripe billed; modules are items on the
     * subscription today, so a cycle carries the package AND every module still
     * running, a mid-period booking carries that module's prorated line on its
     * own, and an upgrade carries its proration. Each of those is money that was
     * taken, so each of them owes the customer a document — and the owner's
     * rule is one document per charge, with the package and the modules of one
     * cycle together on a single one.
     *
     * **Built from Stripe's lines, not from our snapshot.** Stripe is what
     * actually charged the customer: it worked the pro-rata out against its own
     * period boundaries and its own view of what is already on the account, and
     * a document assembled from the contract would state a sum that is not the
     * one taken. Only the WORDING is ours — see StripeInvoiceLines, which names
     * each line from the catalogue instead of printing a Price id.
     *
     * **Charged in, split here.** Our catalogue is pushed to Stripe as GROSS, so
     * the line amounts that come back are what the customer actually paid, and
     * the document divides that sum into net and VAT rather than adding a rate
     * on top of it. That distinction is not cosmetic: Stripe prorates a term by
     * days and hands back figures that are not the gross of any whole-cent net,
     * and a document rebuilt from a net figure would miss those by a cent.
     *
     * @param  array<int, array<string, mixed>>  $lines  Stripe's `lines.data`
     * @param  array<int, string>  $unnamed  filled with the lines the catalogue could not name
     */
    public function forStripeInvoice(
        Subscription $subscription,
        string $stripeInvoiceId,
        array $lines,
        ?string $currency = null,
        ?InvoiceSeries $series = null,
        array &$unnamed = [],
    ): Invoice {
        $customer = $subscription->customer;

        if ($customer === null) {
            throw new RuntimeException('Refusing to issue an invoice for a contract with nobody on it.');
        }

        $built = app(StripeInvoiceLines::class)->build($lines);
        $unnamed = $built['unnamed'];

        if ($built['lines'] === []) {
            throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
        }

        return $this->issue(
            customer: $customer,
            lines: $built['lines'],
            currency: strtoupper((string) ($currency ?: $subscription->currency ?: 'EUR')),
            series: $series,
            linesAreCharged: true,
            attributes: [
                'subscription_id' => $subscription->id,
                // Left null for the same reason as on a renewal: the contract's
                // opening order is the first purchase and already has its own
                // document.
                'order_id' => null,
                'stripe_invoice_id' => $stripeInvoiceId,
            ],
        );
    }

    /**
     * One invoice for work that was done, typed by a person.
     *
     * Not everything we are paid for is a package. Somebody asks whether their
     * data can be moved into Nextcloud, we look at it, we say yes, we do it —
     * and that is an invoice with no order and no contract behind it, because
     * nobody bought it from a price list.
     *
     * It goes through the same door as every other invoice on purpose: the same
     * series, so the number stays consecutive; the same tax treatment, so a
     * business in another member state gets its reverse charge here too; the
     * same immutable snapshot and the same archive jobs. A second way of making
     * invoices would be a second numbering — and a series with a document
     * missing from it is worth nothing at an audit.
     *
     * Lines arrive as a person typed them, NET, with the VAT put on top — the
     * one door into this class that still works that way, and deliberately. A
     * catalogue sale has already been charged a gross figure that the document
     * has to divide; a service invoice charges nothing yet, and the console page
     * asks the operator for net amounts because that is what was agreed on the
     * telephone.
     *
     * @param  array<int, array{description: string, details?: array<int, string>, quantity_milli: int, unit?: string, unit_net_cents: int}>  $lines
     */
    public function forService(
        Customer $customer,
        array $lines,
        string $currency = 'EUR',
        ?InvoiceSeries $series = null,
    ): Invoice {
        if ($lines === []) {
            throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
        }

        return $this->issue(
            customer: $customer,
            // Normalised here rather than trusted: the optional keys are
            // optional to the caller, and InvoiceMath and the renderer both
            // read them without asking whether they are there.
            lines: array_map(fn (array $line) => [
                'description' => (string) $line['description'],
                'details' => array_values(array_filter((array) ($line['details'] ?? []))),
                'quantity_milli' => (int) $line['quantity_milli'],
                'unit' => (string) ($line['unit'] ?? ''),
                'unit_net_cents' => (int) $line['unit_net_cents'],
            ], array_values($lines)),
            currency: strtoupper($currency),
            series: $series,
            // No order and no contract: this document points at neither, and
            // saying so with a null is more honest than inventing an Order
            // nobody placed just to have something to point at.
            attributes: [],
        );
    }

    /**
     * Take an issued invoice back, by issuing the document that says so.
     *
     * The only lawful way to undo an invoice. Nothing is edited and nothing is
     * deleted — an issued number has been on a document somebody has, and it can
     * never be reused or withdrawn — so the original stays exactly as it is and
     * a SECOND document, in the Storno series with its own gapless number,
     * states the same figures with the opposite sign. `cancels_invoice_id` is
     * what ties the pair together.
     *
     * Mirrored from the original's own snapshot rather than rebuilt from
     * today's settings: a cancellation has to state the same issuer, the same
     * recipient, the same lines and above all the same TAX RATE as the document
     * it cancels. Recomputing the rate would produce a cancellation that does
     * not cancel — a 20 % invoice taken back at 21 % leaves a cent standing for
     * ever.
     *
     * Only the meta changes: its own number, today's date, the Storno wording
     * and a reference to the number being taken back. The reverse-charge note
     * is carried over with everything else, because a zero-rated cancellation
     * needs the same justification on it that the zero-rated invoice did.
     *
     * Refuses to cancel a cancellation. Two Stornos of one invoice would take
     * the same money back twice on paper, and a Storno of a Storno is a
     * re-issue, which is a new invoice and not this.
     */
    public function cancelling(Invoice $original, ?InvoiceSeries $series = null): Invoice
    {
        $customer = $original->customer;

        if ($customer === null) {
            throw new RuntimeException('Refusing to cancel an invoice with nobody on it.');
        }

        if ($original->cancels_invoice_id !== null) {
            throw new RuntimeException('Refusing to cancel a cancellation: re-issue the document instead.');
        }

        if (Invoice::query()->where('cancels_invoice_id', $original->id)->exists()) {
            throw new RuntimeException("Invoice {$original->number} has already been cancelled.");
        }

        $series ??= InvoiceSeries::query()->where('kind', 'cancellation')->where('active', true)->firstOrFail();

        $snapshot = (array) $original->snapshot;
        $rate = (int) ($snapshot['totals']['rate_basis_points'] ?? 0);

        // Every line at its own price with the sign turned over. Quantities are
        // left alone: a negative quantity of a positive price adds up to the
        // same figure and reads, on the page, as though we had delivered minus
        // one of something.
        $lines = array_map(function (array $line) {
            $line['unit_net_cents'] = -(int) $line['unit_net_cents'];

            return $line;
        }, (array) ($snapshot['lines'] ?? []));

        // The original's own figures, negated — not recomputed from the lines.
        // A document built from a charged amount holds a split that its own
        // lines cannot be made to reproduce (the odd cent lives on one of them),
        // so recomputing would take back a cent more or less than was invoiced,
        // and a cancellation that does not cancel leaves that cent standing for
        // ever.
        $totals = array_map(
            fn ($value) => -(int) $value,
            array_diff_key((array) ($snapshot['totals'] ?? []), ['rate_basis_points' => null]),
        ) + ['subtotal' => 0, 'lines_net' => 0, 'adjustment' => 0, 'net' => 0, 'tax' => 0, 'gross' => 0]
            + ['rate_basis_points' => $rate];

        $snapshot['lines'] = $lines;
        $snapshot['totals'] = $totals;
        $snapshot['meta'] = (array) ($snapshot['meta'] ?? []);
        $snapshot['meta']['title'] = __('invoice.cancellation_title');
        $snapshot['meta']['intro'] = __('invoice.cancellation_intro', ['number' => (string) $original->number]);
        $snapshot['meta']['issued_on'] = now()->local()->format('d.m.Y');
        // A Storno asks for no money, so it has no due date and no payment
        // terms. Leaving the original's there would tell the customer to pay a
        // negative amount within fourteen days.
        $snapshot['meta']['due_on'] = null;
        $snapshot['meta']['payment_terms'] = null;

        return $this->persist(
            series: $series,
            customer: $customer,
            snapshot: $snapshot,
            totals: $totals,
            currency: (string) $original->currency,
            dueOn: null,
            attributes: [
                'cancels_invoice_id' => $original->id,
                // The same sale, so the same order and contract: a cancellation
                // that pointed at nothing could not be found from the thing it
                // undoes.
                'order_id' => $original->order_id,
                'subscription_id' => $original->subscription_id,
            ],
        );
    }

    /**
     * @param  bool  $linesAreCharged  true when `unit_net_cents` holds what the
     *                                 customer was actually charged, tax included —
     *                                 every catalogue sale. False for the service
     *                                 invoice an operator types net.
     */
    private function issue(
        Customer $customer,
        array $lines,
        string $currency,
        ?InvoiceSeries $series,
        array $attributes,
        bool $linesAreCharged = false,
    ): Invoice {
        $series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail();

        $treatment = TaxTreatment::for($customer);
        $rate = $treatment->basisPoints();

        if ($linesAreCharged) {
            // The charged figure is the fixed point, whatever it was: at a rate of
            // zero the whole of it becomes net rather than the document shrinking
            // by a VAT the price never contained. That is what keeps the total
            // equal to the money for a reverse-charge business, who is charged the
            // bare net and whose document therefore states that same net at 0 %.
            ['lines' => $lines, 'totals' => $totals] = InvoiceMath::fromCharged($lines, $rate);
            $totals += ['rate_basis_points' => $rate];
        } else {
            $totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
        }

        $snapshot = [
            'issuer' => CompanyProfile::all(),
            'customer' => $this->customerBlock($customer),
            'lines' => $lines,
            'totals' => $totals,
            'meta' => $this->meta($customer, $treatment),
        ];

        return $this->persist(
            series: $series,
            customer: $customer,
            snapshot: $snapshot,
            totals: $totals,
            currency: $currency,
            dueOn: now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(),
            attributes: $attributes,
        );
    }

    /**
     * The part that is the same for every document there will ever be: refuse
     * while the company details are incomplete, take the number, write the row,
     * copy it to the archive.
     *
     * One method rather than one per kind, because the number and the row have
     * to commit together — and a second copy of that rule is how one kind of
     * document would end up leaving a gap in a series the other kind keeps
     * intact.
     *
     * @param  array<string, mixed>  $snapshot
     * @param  array<string, int>  $totals
     * @param  array<string, mixed>  $attributes
     */
    private function persist(
        InvoiceSeries $series,
        Customer $customer,
        array $snapshot,
        array $totals,
        string $currency,
        ?string $dueOn,
        array $attributes,
    ): Invoice {
        $missing = CompanyProfile::missingForInvoicing();

        if ($missing !== []) {
            // Refusing is the correct outcome. An invoice without a registered
            // name, an address or a VAT number is not a valid invoice here, and
            // issuing one consumes a number that can never be reused.
            throw new RuntimeException(
                'Refusing to issue an invoice before the company details are complete: '.implode(', ', $missing)
            );
        }

        $invoice = DB::transaction(function () use ($series, $customer, $snapshot, $totals, $currency, $dueOn, $attributes) {
            [$number, $sequence, $year] = $this->numbers->next($series);

            // The number is only true once it is on the document, so both are
            // written inside the same transaction the counter was advanced in.
            // That is also what keeps a redelivered Stripe invoice from eating a
            // number: the unique index rejects the row, the transaction rolls
            // back, and the counter comes back with it.
            $snapshot['meta']['number'] = $number;

            return Invoice::create([
                'invoice_series_id' => $series->id,
                'customer_id' => $customer->id,
                'number' => $number,
                'number_year' => $year,
                'number_sequence' => $sequence,
                'issued_on' => now()->toDateString(),
                'due_on' => $dueOn,
                'snapshot' => $snapshot,
                'net_cents' => $totals['net'],
                'tax_cents' => $totals['tax'],
                'gross_cents' => $totals['gross'],
                'currency' => $currency,
                ...$attributes,
            ]);
        });

        // After the commit, never inside it: a worker can pick a job up before
        // the transaction lands, and it would then look for an invoice that
        // does not exist yet.
        //
        // One job per destination. The reason for having a second is that the
        // first can fail, and a single job writing to both would retry the one
        // that worked every time the other did not.
        //
        // Dispatched rather than done here because a destination can be a
        // network mount that blocks for its whole timeout, and an invoice must
        // not fail to be issued because a NAS in an office is rebooting.
        foreach (ExportTarget::query()->where('active', true)->get() as $target) {
            ArchiveInvoice::dispatch($invoice, $target);
        }

        return $invoice;
    }

    /**
     * The recipient, as the document will show them.
     *
     * The billing address is one free-text field today, so it is carried as
     * lines rather than pulled apart into street and city here — guessing which
     * line is which would put a postcode where a street belongs on a document
     * nobody can correct afterwards.
     *
     * @return array<string, mixed>
     */
    private function customerBlock(Customer $customer): array
    {
        $address = trim((string) $customer->billing_address);

        return [
            'name' => (string) $customer->name,
            'address_lines' => $address === ''
                ? []
                : array_values(array_filter(array_map('trim', preg_split('/\R/', $address) ?: []))),
            'vat_id' => (string) $customer->vat_id,
            'email' => (string) $customer->email,
        ];
    }

    /** @return array<string, mixed> */
    private function meta(Customer $customer, TaxTreatment $treatment): array
    {
        $days = (int) CompanyProfile::get('payment_days', 14);

        return [
            'title' => __('invoice.title'),
            'number' => '',  // filled inside the transaction, once it is real
            'customer_number' => 'KN-'.str_pad((string) $customer->id, 4, '0', STR_PAD_LEFT),
            'issued_on' => now()->local()->format('d.m.Y'),
            'due_on' => now()->local()->addDays($days)->format('d.m.Y'),
            'currency' => 'EUR',
            'salutation' => __('invoice.salutation'),
            'intro' => __('invoice.intro'),
            'adjustment_label' => __('invoice.adjustment'),
            'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== ''
                ? (string) CompanyProfile::get('payment_terms')
                : __('invoice.payment_default', ['days' => $days]),
            // The note that makes a zero-rated invoice lawful. Without it the
            // document says nothing about why no VAT was charged, which is the
            // one thing an auditor looks for.
            'closing' => $treatment->reverseCharge ? __('invoice.reverse_charge') : null,
        ];
    }
}
