<?php

namespace App\Livewire\Admin;

use App\Models\Customer;
use App\Models\InvoiceSeries;
use App\Models\Subscription;
use App\Services\Billing\InvoiceMath;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\TaxTreatment;
use App\Support\CompanyProfile;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;

/**
 * An invoice for work that was done, typed by the person who did it.
 *
 * Not everything we are paid for comes off a price list. Somebody asks whether
 * their data can be moved into Nextcloud; we look at it, we say what it will
 * cost, we do it. That is a real invoice with no order and no contract behind
 * it, and until now the only way to produce one was to write it somewhere else
 * — which puts it outside the series, and a numbered series with a document
 * missing from it is worth nothing at an audit.
 *
 * So it goes through the same door as every other invoice: the same series and
 * therefore the same consecutive number, the same tax treatment (a business in
 * another member state gets its reverse charge here too), the same immutable
 * snapshot, the same archive jobs.
 *
 * This page only writes what the operator typed. It invents no price and
 * applies no rule of its own — what is on the lines is what was agreed on the
 * phone. Everything it shows before the button is a preview of what the
 * document will say, computed by InvoiceMath, so nobody has to issue one to
 * find out what it adds up to.
 */
#[Layout('layouts.admin')]
class NewInvoice extends Component
{
    /**
     * Who it is for. The uuid, because that is what a URL and a select carry.
     *
     * In the query string so a link can arrive with the recipient already
     * chosen — from a customer's own page, or from the support request that
     * asked for the work — and so a reload does not lose them.
     */
    #[Url(as: 'customer')]
    public string $customerUuid = '';

    /** Which series draws the number. Empty means the active invoice series. */
    public string $seriesId = '';

    /**
     * The lines, as a person types them.
     *
     * Amounts are held as strings in euro rather than integers in cents: this
     * is a form, and asking somebody to type 12000 for a hundred and twenty
     * euro is how an invoice ends up a hundred times too large. Converted once,
     * on the way out.
     *
     * @var array<int, array{description: string, detail: string, quantity: string, unit: string, price: string}>
     */
    public array $lines = [];

    public function mount(): void
    {
        $this->authorize('site.manage');

        $this->addLine();
    }

    public function addLine(): void
    {
        $this->lines[] = [
            'description' => '',
            'detail' => '',
            'quantity' => '1',
            'unit' => '',
            'price' => '',
        ];
    }

    /**
     * Remove a line, but never the last one.
     *
     * An invoice with no lines is not a draft state worth having — the page
     * would show a total of nothing and a button that refuses.
     */
    public function removeLine(int $index): void
    {
        if (count($this->lines) <= 1) {
            return;
        }

        unset($this->lines[$index]);
        $this->lines = array_values($this->lines);
    }

    /**
     * Issue it.
     *
     * There is no draft and no edit afterwards, deliberately: an issued invoice
     * cannot lawfully be changed, and a "save as draft" that draws a number
     * would be the same thing with a friendlier name. The number is taken at
     * this click and not before.
     */
    public function issue(IssueInvoice $issuer): void
    {
        $this->authorize('site.manage');

        $data = $this->validate([
            'customerUuid' => 'required|string',
            'seriesId' => 'nullable|string',
            'lines' => 'required|array|min:1',
            'lines.*.description' => 'required|string|max:255',
            'lines.*.detail' => 'nullable|string|max:255',
            // Down to a thousandth, because hours are billed in quarters and
            // 0.25 has to survive the trip.
            'lines.*.quantity' => 'required|numeric|min:0.001|max:100000',
            'lines.*.unit' => 'nullable|string|max:32',
            // Negative allowed: a credit line on an ordinary invoice is how a
            // goodwill deduction is shown without a second document.
            'lines.*.price' => 'required|numeric|min:-1000000|max:1000000',
        ]);

        $customer = Customer::query()->where('uuid', $data['customerUuid'])->first();

        if ($customer === null) {
            $this->addError('customerUuid', __('new_invoice.no_customer'));

            return;
        }

        $series = $data['seriesId'] === ''
            ? null
            : InvoiceSeries::query()->whereKey($data['seriesId'])->first();

        try {
            $invoice = $issuer->forService(
                customer: $customer,
                lines: $this->linesForIssue(),
                // The currency the catalogue is priced in, not a second
                // setting: a service invoice in another currency than the
                // packages would be a second answer to a question that already
                // has one.
                currency: Subscription::catalogueCurrency(),
                series: $series,
            );
        } catch (Throwable $e) {
            // The company profile being incomplete is the usual one, and it is
            // a refusal by design: an invoice without a registered name, an
            // address or a VAT number is not a valid invoice, and issuing it
            // would burn a number that can never be reused.
            $this->addError('lines', $e->getMessage());

            return;
        }

        $this->redirectRoute('admin.invoices', navigate: true);

        session()->flash('status', __('new_invoice.issued', ['number' => $invoice->number]));
    }

    /**
     * The lines in the shape IssueInvoice wants them.
     *
     * round(), not a cast: (int) (12.95 * 100) is 1294 on a binary float, and
     * an invoice a cent short of what was typed is the kind of error nobody
     * finds until a customer does.
     *
     * @return array<int, array<string, mixed>>
     */
    private function linesForIssue(): array
    {
        return array_map(fn (array $line) => [
            'description' => trim($line['description']),
            'details' => array_filter([trim((string) $line['detail'])]),
            'quantity_milli' => (int) round(((float) $line['quantity']) * 1000),
            'unit' => trim((string) $line['unit']),
            'unit_net_cents' => (int) round(((float) $line['price']) * 100),
        ], $this->lines);
    }

    public function render()
    {
        $this->authorize('site.manage');

        $customer = $this->customerUuid === ''
            ? null
            : Customer::query()->where('uuid', $this->customerUuid)->first();

        // The same treatment the document will carry, shown before it is
        // issued: an operator writing an invoice for a business in another
        // member state should see "reverse charge, 0 %" on the page, not
        // discover it on the PDF.
        $treatment = TaxTreatment::for($customer);
        $rate = (int) round($treatment->rate * 10000);

        return view('livewire.admin.new-invoice', [
            'customers' => Customer::query()
                ->whereNull('closed_at')
                ->orderBy('name')
                ->get(['id', 'uuid', 'name', 'email']),
            'series' => InvoiceSeries::query()->where('kind', 'invoice')->orderBy('name')->get(),
            'customer' => $customer,
            'treatment' => $treatment,
            'totals' => InvoiceMath::totals($this->linesForIssue(), null, $rate),
            'currency' => Subscription::catalogueCurrency(),
            // Said on the page rather than only thrown at the click: the
            // company profile is somebody else's tab, and finding out at the
            // last step costs the whole form.
            'missing' => CompanyProfile::missingForInvoicing(),
        ]);
    }
}
