<?php

namespace App\Services\Billing;

use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use Illuminate\Support\Carbon;

/**
 * Stripe's own invoice lines, turned into lines a customer can read.
 *
 * The document is built from what Stripe CHARGED, not from our snapshot of what
 * the contract says. Those two agree almost always and the exception is the
 * whole point: a proration, a module booked on the eleventh, a quantity changed
 * mid-term — Stripe worked each of those out against its own period boundaries,
 * and a document assembled from our figures would state a sum that is not the
 * one taken. A document that does not match the money is the one thing that must
 * never happen.
 *
 * What is ours is the WORDING. Stripe's line descriptions are its own ("1 ×
 * price_1QxYz (at €10.00 / month)"), and the customer register is where a module
 * has a name — so each line is matched back to the catalogue by its Price id and
 * named from the customer register in the language files.
 *
 * **A line we cannot name is kept.** It is carried with whatever description
 * Stripe sent and reported to the caller, which logs it. A document missing a
 * line that was charged is worse than an ugly one: the total would still be
 * right and the reader would have no way to see what they had paid for.
 *
 * Amounts are what was CHARGED, which is not the same figure for everybody: our
 * catalogue is pushed to Stripe twice, at the domestic gross and at the bare net,
 * and the Price a contract is billed on decides which of the two its lines carry.
 * IssueInvoice divides the sum according to the customer's own treatment rather
 * than adding a rate to it — so a domestic line is split into net and VAT, and a
 * reverse-charge line, which contained no VAT to begin with, becomes net at 0 %.
 * Either way the document totals to the money. See TaxTreatment.
 *
 * Naming a line is unaffected by there being two Prices per sellable thing: a
 * Price id is unique in both registers, so it resolves to one catalogue row
 * whichever of the pair it is.
 */
final class StripeInvoiceLines
{
    /**
     * @param  array<int, array<string, mixed>>  $lines  Stripe's `lines.data`
     * @return array{lines: array<int, array<string, mixed>>, unnamed: array<int, string>}
     */
    public function build(array $lines): array
    {
        $documentLines = [];
        $unnamed = [];

        foreach ($lines as $line) {
            $amount = (int) ($line['amount'] ?? 0);
            $quantity = max(1, (int) ($line['quantity'] ?? 1));
            $priceId = $this->priceId($line);
            $description = $this->name($priceId, $line);

            if ($description === null) {
                $unnamed[] = $priceId ?? (string) ($line['id'] ?? 'unknown');
                $description = $this->fallbackName($line);
            }

            // The unit price only when it divides cleanly. Stripe's line amount
            // is what was charged, and it is the figure the document has to
            // total to — so a quantity that does not divide it exactly is shown
            // as one line at the full amount rather than as a unit price the
            // reader could multiply and get a different answer. InvoiceMath
            // applies the same rule again once the amount has been split into
            // net, which is the figure actually printed.
            $unit = intdiv($amount, $quantity);
            $divides = $unit * $quantity === $amount;

            $documentLines[] = [
                'description' => $description,
                'details' => $this->details($line),
                'quantity_milli' => $divides ? $quantity * 1000 : 1000,
                'unit' => '',
                'unit_net_cents' => $divides ? $unit : $amount,
            ];
        }

        return ['lines' => $documentLines, 'unnamed' => $unnamed];
    }

    /**
     * The Stripe Price a line billed against.
     *
     * Two shapes, because Stripe moved it: older invoice lines carry `price`
     * inline, newer ones put it under `pricing.price_details`. Both are read
     * rather than one, so an account on either API version is named correctly.
     */
    private function priceId(array $line): ?string
    {
        $id = $line['price']['id']
            ?? $line['pricing']['price_details']['price']
            ?? null;

        return is_string($id) && $id !== '' ? $id : null;
    }

    /**
     * What this line is, in the words the customer knows it by. Null when the
     * catalogue has never heard of it.
     */
    private function name(?string $priceId, array $line): ?string
    {
        $metadata = (array) ($line['price']['metadata'] ?? []);

        // A module, by the Price we minted for it. Archived Prices are in that
        // table too and are looked up exactly like live ones — a contract keeps
        // billing on the Price it was put on until it is deliberately moved, and
        // a line nobody can name is the one failure this class exists to avoid.
        // The metadata is the same answer from the other direction and covers a
        // Price created against this account before the table existed.
        $addonKey = $priceId === null
            ? null
            : StripeAddonPrice::query()->where('stripe_price_id', $priceId)->value('addon_key');

        $addonKey ??= is_string($metadata['addon'] ?? null) ? $metadata['addon'] : null;

        if (is_string($addonKey) && app(AddonCatalogue::class)->knows($addonKey)) {
            return app(AddonCatalogue::class)->name($addonKey);
        }

        // The package, by the priced row stripe:sync-catalogue pushed. Named by
        // FAMILY rather than by the version number: "Paket Team" is what the
        // customer bought, and the version is a fact about our catalogue.
        //
        // `plan_prices.stripe_price_id` only ever holds the Price on sale right
        // now, so a contract still billing on a superseded one is found through
        // `stripe_plan_prices`, which keeps every Price a row has ever had.
        $planPrice = $priceId === null ? null : PlanPrice::query()
            ->where('stripe_price_id', $priceId)
            ->with('version.family')
            ->first();

        if ($planPrice === null && $priceId !== null) {
            $planPrice = StripePlanPrice::query()
                ->where('stripe_price_id', $priceId)
                ->with('planPrice.version.family')
                ->first()?->planPrice;
        }

        $family = $planPrice?->version?->family;

        $family ??= is_string($metadata['plan_family'] ?? null)
            ? PlanFamily::query()->where('key', $metadata['plan_family'])->first()
            : null;

        return $family === null ? null : __('billing.cart.plan', ['plan' => $family->name]);
    }

    /**
     * The best we can say about a line the catalogue does not know.
     *
     * Stripe's own description if it sent one — it is at least true — and
     * otherwise a sentence saying plainly that this is a charge from the payment
     * provider. Never nothing: the line stays on the document either way.
     */
    private function fallbackName(array $line): string
    {
        $description = $line['description'] ?? null;

        return is_string($description) && trim($description) !== ''
            ? trim($description)
            : __('invoice.line_unnamed');
    }

    /**
     * The small print under a line: which term it covers, and whether it was
     * worked out pro rata.
     *
     * @return array<int, string>
     */
    private function details(array $line): array
    {
        $details = [];

        $from = $line['period']['start'] ?? null;
        $to = $line['period']['end'] ?? null;

        if (is_numeric($from) && is_numeric($to)) {
            $start = Carbon::createFromTimestamp((int) $from);
            $end = Carbon::createFromTimestamp((int) $to);

            $details[] = __('invoice.line_period', [
                'from' => $start->local()->format('d.m.Y'),
                // Stripe's period end is the EXCLUSIVE boundary — the first
                // moment of the next term, which the next invoice claims in
                // turn. Printed as it comes, the same day would appear on two
                // consecutive documents.
                'to' => $end->greaterThan($start)
                    ? $end->copy()->subDay()->local()->format('d.m.Y')
                    : $end->local()->format('d.m.Y'),
            ]);
        }

        // Said out loud, because a customer looking at a figure that is not the
        // monthly price they know has to be able to see why.
        if (($line['proration'] ?? false) === true) {
            $details[] = __('invoice.line_prorated');
        }

        return $details;
    }
}
