<?php

namespace App\Support\Invoice;

use App\Models\Sales\Invoice;
use App\Services\InvoiceService;
use App\Support\BarcodeGenerator;
use Throwable;

/**
 * A printable invoice, normalised into one shape that both paper layouts and
 * both call sites (the Master Control live preview and the real tenant-side
 * print) render from. Preview and print cannot drift because there is exactly
 * one builder and one pair of blades.
 *
 * Money is NOT recomputed here. Every figure is read from the persisted
 * invoice, because {@see InvoiceService} owns the arithmetic and
 * has one deliberate asymmetry a re-derivation would silently "fix": VAT is
 * charged on the line total after line discounts, but invoice-level discount
 * (header + voucher + loyalty) is subtracted after VAT is already fixed. A
 * print that recalculated tax on the net would disagree with the ledger.
 */
final class InvoiceDocument
{
    public function __construct(
        public readonly InvoiceBusiness $business,
        public readonly string $number,
        public readonly ?string $physicalReference,
        public readonly string $issuedAt,
        public readonly array $customer,
        public readonly array $meta,
        public readonly array $lines,
        public readonly array $vatBreakdown,
        public readonly array $discountBreakdown,
        public readonly array $payments,
        public readonly float $grossSubtotal,
        public readonly float $lineDiscountTotal,
        public readonly float $invoiceDiscountTotal,
        public readonly float $vatTotal,
        public readonly float $netTotal,
        public readonly float $paidAmount,
        public readonly float $balance,
        // POS cash overpayment snapshots: what the customer actually handed
        // over (tendered) and what was returned from the drawer (change). Null
        // for exact payments and for overpayments converted to store credit —
        // only a genuine cash-overpayment sale carries both.
        public readonly ?float $tenderedAmount,
        public readonly ?float $changeAmount,
        public readonly string $currencySymbol,
        public readonly ?string $note,
        public readonly bool $isSample = false,
        public readonly ?string $paymentStatus = null,
        public readonly ?string $orderStatus = null,
        public readonly ?string $amendedFrom = null,
    ) {}

    /**
     * Build from a persisted invoice. Relations are eager-loaded by the caller;
     * anything missing degrades to a blank line rather than throwing, because a
     * print must never be the thing that fails at the counter.
     */
    public static function fromInvoice(Invoice $invoice, InvoiceBusiness $business, string $currencySymbol = 'Rs.'): self
    {
        $lines = [];

        foreach ($invoice->items as $item) {
            $lines[] = InvoiceDocumentLine::fromItem($item);
        }

        $grossSubtotal = (float) $invoice->subtotal;
        $lineDiscountTotal = 0.0;
        foreach ($invoice->items as $item) {
            $lineDiscountTotal += (float) $item->discount_amount;
        }

        // `invoices.discount` is the sum of line + header + voucher + loyalty
        // discounting. The line portion is already shown per row, so the
        // summary block only carries the remainder.
        $invoiceDiscountTotal = round((float) $invoice->discount - $lineDiscountTotal, 2);

        return new self(
            business: $business,
            number: (string) $invoice->invoice_number,
            physicalReference: $invoice->physical_reference,
            issuedAt: $invoice->created_at?->format('d M Y, H:i') ?? '',
            customer: self::customerBlock($invoice),
            meta: self::metaBlock($invoice),
            lines: $lines,
            vatBreakdown: self::vatBreakdown($lines),
            discountBreakdown: self::discountBreakdown($invoice, $invoiceDiscountTotal),
            payments: self::payments($invoice),
            grossSubtotal: $grossSubtotal,
            lineDiscountTotal: round($lineDiscountTotal, 2),
            invoiceDiscountTotal: max(0, $invoiceDiscountTotal),
            vatTotal: (float) $invoice->vat_total,
            netTotal: (float) $invoice->net_total,
            paidAmount: (float) $invoice->paid_amount,
            balance: (float) $invoice->balance,
            tenderedAmount: $invoice->tendered_amount !== null ? (float) $invoice->tendered_amount : null,
            changeAmount: $invoice->change_amount !== null ? (float) $invoice->change_amount : null,
            currencySymbol: $currencySymbol,
            note: $invoice->note,
            paymentStatus: $invoice->paymentStatus?->name,
            orderStatus: $invoice->orderStatus?->name,
            amendedFrom: $invoice->amendedFrom?->invoice_number,
        );
    }

    /** @return array<string, string|null> */
    private static function customerBlock(Invoice $invoice): array
    {
        $customer = $invoice->customer;

        if ($customer === null) {
            return ['name' => 'Walk-in customer', 'isRegistered' => false];
        }

        $address = implode(', ', array_filter([
            $customer->street_line1,
            $customer->street_line2,
            $customer->city?->name ?? $customer->legacy_city,
        ], fn ($v): bool => filled($v)));

        return [
            'name' => $customer->business_name,
            'phone' => $customer->phone,
            'email' => $customer->email,
            'nic' => $customer->nic,
            'address' => $address === '' ? null : $address,
            'deliveryAddress' => $invoice->deliveryAddress?->street_line1,
            'isRegistered' => true,
        ];
    }

    /** @return array<string, string|null> */
    private static function metaBlock(Invoice $invoice): array
    {
        return [
            'branch' => $invoice->branch?->name,
            'branchAddress' => $invoice->branch?->address,
            'branchPhone' => $invoice->branch?->phone,
            'channel' => $invoice->channel,
            'till' => $invoice->till?->name,
            'rep' => $invoice->rep?->name,
        ];
    }

    /**
     * Tax grouped by rate — a legal requirement on a VAT invoice where more
     * than one rate appears, and this schema allows exactly that (rate is
     * per-product).
     *
     * @param  list<InvoiceDocumentLine>  $lines
     * @return list<array{rate: float, base: float, vat: float}>
     */
    private static function vatBreakdown(array $lines): array
    {
        $groups = [];

        foreach ($lines as $line) {
            $key = number_format($line->vatRate, 2, '.', '');
            $groups[$key] ??= ['rate' => $line->vatRate, 'base' => 0.0, 'vat' => 0.0];
            $groups[$key]['base'] += $line->lineTotal;
            $groups[$key]['vat'] += $line->vatAmount;
        }

        $groups = array_values(array_map(fn (array $g): array => [
            'rate' => $g['rate'],
            'base' => round($g['base'], 2),
            'vat' => round($g['vat'], 2),
        ], $groups));

        usort($groups, fn (array $a, array $b): int => $a['rate'] <=> $b['rate']);

        return $groups;
    }

    /**
     * Splits the single `discount` column back into its sources. Vouchers and
     * loyalty are recoverable from their own ledgers; the cart-value rule
     * comes from the invoice's own snapshot columns; whatever is left over is
     * the manually keyed header discount.
     *
     * @return list<array{label: string, amount: float}>
     */
    private static function discountBreakdown(Invoice $invoice, float $invoiceDiscountTotal): array
    {
        if ($invoiceDiscountTotal <= 0) {
            return [];
        }

        $rows = [];
        $accounted = 0.0;

        foreach ($invoice->voucherRedemptions ?? [] as $redemption) {
            if ($redemption->is_reversed) {
                continue;
            }

            $amount = (float) $redemption->redeemed_amount;
            $accounted += $amount;
            $rows[] = [
                'label' => 'Voucher '.($redemption->voucher?->code ?? ''),
                'amount' => $amount,
            ];
        }

        foreach ($invoice->loyaltyTransactions ?? [] as $transaction) {
            // Redemptions are the negative rows; earnings don't discount.
            if ($transaction->is_reversed || (float) $transaction->points >= 0) {
                continue;
            }

            $points = abs((float) $transaction->points);
            $rate = (float) ($transaction->program?->redemption_rate ?? 0);

            // The ledger stores points, not money — InvoiceService derives the
            // discount as points / redemption_rate, so the print must too.
            $amount = $rate > 0 ? round($points / $rate, 2) : 0.0;

            if ($amount <= 0) {
                continue;
            }

            $accounted += $amount;
            $rows[] = [
                'label' => 'Loyalty points redeemed ('.rtrim(rtrim(number_format($points, 2, '.', ''), '0'), '.').' pts)',
                'amount' => $amount,
            ];
        }

        // The automatic cart-value rule ("Spend & Save") rides on the
        // invoice's own snapshot columns — there is no per-invoice ledger row
        // to reconstruct it from, so the amounts are stored at sale time.
        $cartDiscount = (float) $invoice->cart_discount_amount;
        if ($cartDiscount > 0) {
            $accounted += $cartDiscount;
            $rows[] = [
                'label' => 'Cart discount'.($invoice->cart_discount_name ? " ({$invoice->cart_discount_name})" : ''),
                'amount' => $cartDiscount,
            ];
        }

        $remainder = round($invoiceDiscountTotal - $accounted, 2);

        if ($remainder > 0) {
            $rows[] = ['label' => 'Invoice discount', 'amount' => $remainder];
        }

        return $rows;
    }

    /**
     * @return list<array{method: string, reference: string|null, amount: float, status: string|null, pending: bool}>
     */
    private static function payments(Invoice $invoice): array
    {
        $rows = [];

        foreach ($invoice->claims as $claim) {
            $transaction = $claim->transaction;

            // A soft-deleted (reversed) payment resolves to null through the
            // relation. PaymentService has already taken it back out of
            // paid_amount, so printing it as a tender would overstate what the
            // customer handed over.
            if ($transaction === null) {
                continue;
            }

            $status = $transaction->status?->name;

            $reference = implode(' · ', array_filter([
                $transaction->reference,
                $transaction->cheque_number !== null ? 'Cheque '.$transaction->cheque_number : null,
                $transaction->cheque_bank,
            ], fn ($v): bool => filled($v)));

            $rows[] = [
                'method' => $transaction->method?->name ?? 'Payment',
                'reference' => $reference === '' ? null : $reference,
                'amount' => (float) $claim->amount,
                'status' => $status,
                // A cheque sits PENDING and does not reduce the balance until
                // it clears, so the print has to say so rather than implying
                // the invoice is settled.
                'pending' => $status !== null && ! in_array(strtolower($status), ['approved', 'utilized'], true),
            ];
        }

        return $rows;
    }

    public function money(float $amount): string
    {
        return $this->currencySymbol.' '.number_format($amount, 2);
    }

    /**
     * The net total written out in words — long-standing practice on a
     * commercial invoice because it makes the payable figure impossible to
     * alter after issue.
     */
    public function amountInWords(): string
    {
        $rupees = (int) floor($this->netTotal);
        $cents = (int) round(($this->netTotal - $rupees) * 100);

        $words = ucfirst(self::spell($rupees));

        if ($cents > 0) {
            $words .= ' and '.self::spell($cents).' cents';
        }

        return $words.' only';
    }

    private static function spell(int $number): string
    {
        if ($number === 0) {
            return 'zero';
        }

        $units = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
            'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
            'seventeen', 'eighteen', 'nineteen'];
        $tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];

        // Indian numbering: the tenant base is Sri Lanka, where amounts are read
        // in lakhs and crores rather than millions.
        $scales = [10000000 => 'crore', 100000 => 'lakh', 1000 => 'thousand', 100 => 'hundred'];

        foreach ($scales as $value => $name) {
            if ($number >= $value) {
                $leading = intdiv($number, $value);
                $remainder = $number % $value;

                return trim(self::spell($leading).' '.$name.($remainder > 0 ? ' '.self::spell($remainder) : ''));
            }
        }

        if ($number < 20) {
            return $units[$number];
        }

        $remainder = $number % 10;

        return $tens[intdiv($number, 10)].($remainder > 0 ? '-'.$units[$remainder] : '');
    }

    /**
     * Lines regrouped so bundle components print under a single bundle heading
     * instead of appearing as unexplained loose parts.
     *
     * @return list<array{bundle: string|null, lines: list<InvoiceDocumentLine>}>
     */
    public function groupedLines(): array
    {
        $groups = [];

        foreach ($this->lines as $line) {
            $key = $line->bundleGroupKey ?? 'line-'.count($groups);

            if (! isset($groups[$key])) {
                $groups[$key] = ['bundle' => $line->bundleGroupKey !== null ? $line->bundleName : null, 'lines' => []];
            }

            $groups[$key]['lines'][] = $line;
        }

        return array_values($groups);
    }

    public function netSubtotal(): float
    {
        return round($this->grossSubtotal - $this->lineDiscountTotal, 2);
    }

    public function totalDiscount(): float
    {
        return round($this->lineDiscountTotal + $this->invoiceDiscountTotal, 2);
    }

    public function hasVatBreakdown(): bool
    {
        return $this->business->vatEnabled && count($this->vatBreakdown) > 0 && $this->vatTotal > 0;
    }

    public function hasPendingTender(): bool
    {
        foreach ($this->payments as $payment) {
            if ($payment['pending']) {
                return true;
            }
        }

        return false;
    }

    /**
     * Whether cash change was handed back on this invoice — a genuine POS cash
     * overpayment. Deliberately distinct from `balance`, which is money still
     * owed: when change is given the invoice is settled and the balance is
     * zero.
     */
    public function hasChangeReturned(): bool
    {
        return $this->changeAmount !== null && $this->changeAmount > 0.005;
    }

    /**
     * Whether the invoice is settled — every tender that clears has been
     * received. A pending tender (e.g. an uncleared cheque) never reduces the
     * balance until it clears, so a zero balance can only mean every tender
     * has settled — the only invoices that earn the ink PAID seal on the
     * printed document. Mirrors PaymentService::recomputeInvoicePayment() and
     * Invoice::isFullyPaid(): only a zero (or sub-cent) balance reports as
     * fully paid.
     */
    public function isFullyPaid(): bool
    {
        return $this->balance <= 0.001;
    }

    /**
     * The branch row the letterhead prints for THIS invoice: only the branch
     * the sale was made from — never the landlord's full branch list. The
     * landlord's per-branch rows are matched by label when they exist; a row
     * the landlord never entered falls back to the branch record itself
     * (meta.branch / branchAddress / branchPhone), so a label mismatch can
     * never resurrect the other branches onto a customer's receipt. Callers
     * only print the address (index 0's `address`) — name and phone are kept
     * in the row for the landlord-list match, not for display.
     *
     * @return list<array{label: string|null, address: string|null, phone: string|null}>
     */
    public function printedBranches(): array
    {
        $name = $this->meta['branch'] ?? null;
        if ($name === null || $name === '') {
            return [];
        }

        $needle = strtolower(trim($name));
        $matches = array_values(array_filter(
            $this->business->branches,
            fn (array $branch): bool => strtolower(trim((string) ($branch['label'] ?? ''))) === $needle,
        ));

        if ($matches !== []) {
            return $matches;
        }

        $row = [
            'label' => $name,
            'address' => $this->meta['branchAddress'] ?? null,
            'phone' => $this->meta['branchPhone'] ?? null,
        ];

        if (array_filter($row, fn ($value): bool => $value !== null && $value !== '') === []) {
            return [];
        }

        return [$row];
    }

    /** A VAT-registered seller must head the document "Tax Invoice". */
    public function title(): string
    {
        return $this->business->vatEnabled && $this->business->taxRegistrationNo !== null
            ? 'Tax Invoice'
            : 'Invoice';
    }

    /**
     * A CODE128 barcode of the invoice number, embedded inline (not
     * hot-linked, unlike the product barcode route) so it renders identically
     * on the Master Control sample preview, which has no persisted invoice to
     * link an image route to. Lets the invoice be scanned back up at a
     * counter or in a returns queue instead of typed in by hand.
     *
     * A print must never be the thing that fails at the counter, so a bad
     * value degrades to no barcode rather than an exception.
     */
    public function barcodeSvg(): string
    {
        if (blank($this->number)) {
            return '';
        }

        try {
            $svg = app(BarcodeGenerator::class)->svg($this->number);
        } catch (Throwable) {
            return '';
        }

        // The generator prepends an XML prologue + DOCTYPE meant for a
        // standalone .svg file; invalid at this position once embedded
        // inline inside the print's own HTML document.
        return preg_replace('/^<\?xml.*?\?>\s*<!DOCTYPE[^>]*>\s*/is', '', $svg) ?? '';
    }
}
