<?php

namespace App\Livewire\Admin;

use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * Revenue, from the contracts and the commercial register.
 *
 * What was here before was a story: €7,842 MRR, 1.8 % churn, a twelve-month
 * curve, four payments from customers who do not exist. All of it is gone.
 *
 * Two figures that used to be shown are not shown any more, because there is
 * nothing to compute them from and an estimate would be indistinguishable from
 * a measurement:
 *
 *  - the MRR trend, which needs a monthly revenue history nobody records;
 *  - churn, which needs cancellations over a period, and cancelled_at alone
 *    does not say what the base was.
 *
 * ARR is shown, but only as what it is: this month's recurring revenue times
 * twelve, labelled as a projection rather than as measured turnover.
 */
#[Layout('layouts.admin')]
class Revenue extends Component
{
    public function render()
    {
        $locale = app()->getLocale();
        $totals = $this->recurringTotals();

        return view('livewire.admin.revenue', [
            'kpis' => [
                [
                    'label' => __('admin.rev.mrr'),
                    'value' => $this->format($totals, fn (array $t) => $t['cents'] / 100, $locale),
                    'sub' => __('admin.rev.mrr_sub'),
                ],
                [
                    'label' => __('admin.rev.arr'),
                    'value' => $this->format($totals, fn (array $t) => $t['cents'] * 12 / 100, $locale),
                    'sub' => __('admin.rev.arr_sub'),
                ],
                [
                    'label' => __('admin.rev.arpu'),
                    'value' => $this->format(
                        $totals,
                        fn (array $t) => count($t['customers']) > 0 ? $t['cents'] / count($t['customers']) / 100 : 0,
                        $locale,
                    ),
                    'sub' => __('admin.rev.arpu_sub'),
                ],
                [
                    'label' => __('admin.rev.contracts'),
                    'value' => (string) array_sum(array_column($totals, 'contracts')),
                    'sub' => __('admin.rev.contracts_sub'),
                ],
                [
                    // Shown separately rather than folded into the figures
                    // above: a granted contract at 0 must not drag ARPU down
                    // or read as revenue, and the owner measures himself on
                    // this number too.
                    'label' => __('admin.rev.granted'),
                    'value' => (string) $this->grantedCount(),
                    'sub' => __('admin.rev.granted_sub'),
                ],
            ],
            'planCharts' => $this->planCharts(),
            'payments' => $this->recentPayments($locale),
        ]);
    }

    /**
     * Recurring revenue per currency, off the PAYING contracts.
     *
     * Never summed across currencies. Frozen contract prices, not the
     * catalogue, so a price rise does not retroactively inflate what
     * grandfathered customers are reported to pay. Add-ons included and yearly
     * terms divided — totalMonthlyCents() owns both of those rules.
     *
     * Granted contracts are excluded here, not merely zeroed: a contract with
     * `granted_at` set is a gift or a discount an operator handed out, not a
     * sale, and counting it — even at 0 — would inflate the contracts count
     * this average is divided by and understate ARPU for everyone else.
     *
     * @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
     */
    private function recurringTotals(): array
    {
        $totals = [];

        Subscription::query()
            ->where('status', 'active')
            ->whereNull('granted_at')
            ->with('addons')
            ->chunkById(200, function ($subscriptions) use (&$totals) {
                foreach ($subscriptions as $subscription) {
                    $currency = strtoupper((string) $subscription->currency);
                    $totals[$currency] ??= ['cents' => 0, 'customers' => [], 'contracts' => 0];
                    $totals[$currency]['cents'] += $subscription->totalMonthlyCents();
                    $totals[$currency]['contracts']++;
                    // A customer with two contracts is one customer — ARPU
                    // divided by contracts would understate it.
                    $totals[$currency]['customers'][(int) $subscription->customer_id] = true;
                }
            });

        return $totals;
    }

    /**
     * @param  array<string, array{cents:int, customers:array<int,bool>, contracts:int}>  $totals
     * @param  callable(array{cents:int, customers:array<int,bool>, contracts:int}): float  $amount
     */
    private function format(array $totals, callable $amount, string $locale): string
    {
        if ($totals === []) {
            return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
        }

        return collect($totals)
            ->map(fn (array $t, string $currency) => Number::currency($amount($t), in: $currency, locale: $locale))
            ->implode(' · ');
    }

    /**
     * Recurring revenue split by plan — one chart per currency.
     *
     * By what customers are ON, not by what is on sale: a withdrawn plan still
     * bills, and leaving it out would understate the total the chart sits next
     * to. Granted contracts are excluded — see recurringTotals().
     *
     * Per currency, because a doughnut adds its slices together. Two currencies
     * in one ring produces a total that is not an amount of anything, and it
     * looks exactly as convincing as a correct one.
     *
     * @return array<int, array{currency:string, config:array<string,mixed>}>
     */
    private function planCharts(): array
    {
        $byCurrency = [];

        Subscription::query()
            ->where('status', 'active')
            ->whereNull('granted_at')
            ->with('addons')
            ->chunkById(200, function ($subscriptions) use (&$byCurrency) {
                foreach ($subscriptions as $subscription) {
                    $currency = strtoupper((string) $subscription->currency);
                    $plan = (string) $subscription->plan;
                    $byCurrency[$currency][$plan] = ($byCurrency[$currency][$plan] ?? 0)
                        + $subscription->totalMonthlyCents();
                }
            });

        $charts = [];

        foreach ($byCurrency as $currency => $byPlan) {
            arsort($byPlan);

            $charts[] = [
                'currency' => $currency,
                'config' => [
                    'type' => 'doughnut',
                    'data' => [
                        'labels' => array_map(fn (string $key) => __('billing.plan.'.$key), array_keys($byPlan)),
                        'datasets' => [[
                            'data' => array_map(fn (int $cents) => round($cents / 100, 2), array_values($byPlan)),
                            'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:warning'],
                            'borderWidth' => 0,
                        ]],
                    ],
                    'options' => [
                        'cutout' => '62%',
                        'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
                    ],
                ],
            ];
        }

        return $charts;
    }

    /** How many active contracts are gifts or discounts, not sales. */
    private function grantedCount(): int
    {
        return Subscription::query()->where('status', 'active')->whereNotNull('granted_at')->count();
    }

    /**
     * The last payments actually recorded.
     *
     * Both kinds count: an ordinary billing cycle is written as `renewal`, and
     * `invoice_paid` is reserved for everything else Stripe charges for — a
     * proration, a manual invoice. Listing only the second would show a
     * payments panel with no ordinary payments in it.
     *
     * Gross, because that is what left the customer's account. The register
     * holds net and tax separately for the books.
     *
     * @return array<int, array<string, string>>
     */
    private function recentPayments(string $locale): array
    {
        return SubscriptionRecord::query()
            ->whereIn('event', [SubscriptionRecord::EVENT_RENEWAL, SubscriptionRecord::EVENT_INVOICE_PAID])
            ->orderByDesc('occurred_at')
            ->limit(8)
            ->get()
            ->map(fn (SubscriptionRecord $r) => [
                'customer' => $r->customer_name ?: '—',
                'amount' => Number::currency(
                    (int) ($r->gross_cents ?: $r->net_cents) / 100,
                    in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())),
                    locale: $locale,
                ),
                'when' => $r->occurred_at?->local()->translatedFormat('d. MMM') ?? '—',
            ])
            ->all();
    }
}
