<?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\Support\CompanyProfile;
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.
 */
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.
     *
     * @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.');
        }

        $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)
            );
        }

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

        $treatment = TaxTreatment::for($customer);
        $rate = (int) round($treatment->rate * 10000);

        $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' => '',
            'unit_net_cents' => (int) $order->amount_cents,
        ])->all();

        $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),
        ];

        $invoice = DB::transaction(function () use ($series, $customer, $orders, $snapshot, $totals) {
            [$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.
            $snapshot['meta']['number'] = $number;

            return Invoice::create([
                'invoice_series_id' => $series->id,
                'customer_id' => $customer->id,
                'order_id' => $orders->first()->id,
                'number' => $number,
                'number_year' => $year,
                'number_sequence' => $sequence,
                'issued_on' => now()->toDateString(),
                'due_on' => now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(),
                'snapshot' => $snapshot,
                'net_cents' => $totals['net'],
                'tax_cents' => $totals['tax'],
                'gross_cents' => $totals['gross'],
                'currency' => strtoupper((string) ($orders->first()->currency ?: 'EUR')),
            ]);
        });

        // 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,
        ];
    }
}
