<?php

namespace App\Domain\Financial\Services;

use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\RevenueMapping;
use App\Domain\Financial\Support\RevenueAllocation;
use App\Domain\Financial\Support\RevenuePosting;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Schema;

/**
 * Decides which ledger accounts a collected amount lands in, and how much of it
 * was tax rather than revenue.
 *
 * This replaces a `match` statement. Before it, "where does this money go" was
 * five itemable classes hardcoded to five revenue codes with no tax anywhere,
 * and the only way to answer a finance manager who said "no, part of that goes
 * to 4010" was a deploy. Now it is a rule an accountant edits, and this class
 * is the thing that reads it.
 *
 * Three guarantees, in order of how badly their absence hurts:
 *
 *   1. The credits always sum to exactly the money that came in. Integer
 *      piasters throughout, floors everywhere, and one designated line takes
 *      the leftover — see RevenueAllocation::assertBalanced().
 *   2. Tax is taken out first and credited to a liability account, never to
 *      revenue. VAT collected is owed to the Authority; booking it as income
 *      overstates revenue and understates a liability, which is the specific
 *      mistake that fails an audit.
 *   3. An academy with no rules configured behaves exactly as the system did
 *      before this existed. Nothing silently moves the day this ships.
 */
class RevenueRouter
{
    /** Where VAT goes when a rule charges tax without naming an account. */
    public const VAT_PAYABLE = '2030';

    /** @var array<string, RevenueMapping|null> keyed "{academy}:{source}:{scopeType}:{scopeId}" */
    private array $cache = [];

    /**
     * Set to false the first time a lookup finds the routing tables missing.
     *
     * Rules are configuration. An installation that has not received the
     * routing migration yet must keep taking money using the built-in
     * behaviour, not refuse a payment at the till because a settings table is
     * absent — and it must not pay for that safety with a schema check on
     * every single collection either, which is why this is only consulted
     * after a query has actually failed.
     */
    private ?bool $tablesPresent = null;

    public function __construct(private LedgerAccountResolver $accounts) {}

    /**
     * Route one payment against an invoice.
     *
     * The payment may be partial, so each invoice line takes a share of it in
     * proportion to that line's own value, and each share is then routed by the
     * rules for the thing that line sold. A 500-pound payment against an
     * invoice that is half kit and half subscription credits both, in the
     * proportions the invoice itself says — and each half obeys its own tax
     * treatment, which is the whole point.
     */
    public function allocatePayment(int $academyId, ?Invoice $invoice, int $amount): RevenueAllocation
    {
        if ($amount <= 0) {
            return RevenueAllocation::empty();
        }

        $buckets = $this->bucketsFor($invoice);

        // No invoice at all, or an invoice with nothing on it. The fallback
        // sources match what LedgerAccountResolver::splitRevenue() chose in the
        // same two cases, so a tenant that configures nothing sees no change.
        if ($buckets === []) {
            return $this->allocateSource(
                $academyId,
                $invoice ? RevenueSource::Subscription : RevenueSource::Other,
                $amount,
            )->assertBalanced();
        }

        $base = array_sum(array_column($buckets, 'weight'));

        if ($base <= 0) {
            return $this->allocateSource($academyId, RevenueSource::Subscription, $amount)->assertBalanced();
        }

        // Biggest first, remainder piastre to the last row — the money rules'
        // rounding convention, and the one splitRevenue() already used.
        usort($buckets, fn ($a, $b) => $b['weight'] <=> $a['weight']);

        $allocation = RevenueAllocation::empty();
        $allocated = 0;
        $lastIndex = count($buckets) - 1;

        foreach ($buckets as $i => $bucket) {
            $share = $i === $lastIndex
                ? $amount - $allocated
                : intdiv($amount * $bucket['weight'], $base);

            $allocated += $share;

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

            // The tax the invoice itself charged on this bucket, scaled to the
            // part of it this payment covers. Only `exclusive` rules read it.
            $chargedTax = $bucket['weight'] > 0
                ? intdiv($bucket['tax'] * $share, $bucket['weight'])
                : 0;

            $allocation = $allocation->merge($this->allocateSource(
                $academyId,
                $bucket['source'],
                $share,
                $bucket['scope_type'],
                $bucket['scope_id'],
                $chargedTax,
            ));
        }

        return $allocation->collapsed()->assertBalanced();
    }

    /**
     * Route a single amount from one revenue source.
     *
     * @param int|null $chargedTax tax the invoice already charged on this
     *                             amount, for rules whose price excludes tax
     */
    public function allocateSource(
        int $academyId,
        RevenueSource $source,
        int $amount,
        ?string $scopeType = null,
        ?int $scopeId = null,
        ?int $chargedTax = null,
    ): RevenueAllocation {
        if ($amount <= 0) {
            return RevenueAllocation::empty();
        }

        $mapping = $this->mappingFor($academyId, $source, $scopeType, $scopeId);

        if (! $mapping || $mapping->lines->isEmpty()) {
            return $this->legacyAllocation($academyId, $source, $amount);
        }

        return $this->allocateWith($academyId, $mapping, $source, $amount, $chargedTax);
    }

    /**
     * Route an amount through a rule that was handed in rather than looked up.
     *
     * This is what makes the editing screen's preview honest: it runs the rule
     * the accountant is typing — unsaved, not yet in the database — through the
     * same arithmetic that will post the real entry tonight, instead of a
     * second implementation that agrees with it right up until it doesn't.
     */
    public function allocateWith(
        int $academyId,
        RevenueMapping $mapping,
        RevenueSource $source,
        int $amount,
        ?int $chargedTax = null,
    ): RevenueAllocation {
        if ($amount <= 0) {
            return RevenueAllocation::empty();
        }

        [$tax, $taxAccount, $warnings] = $this->resolveTax($academyId, $mapping, $amount, $chargedTax);

        $net = $amount - $tax;
        $postings = [];

        if ($tax > 0 && $taxAccount) {
            $postings[] = new RevenuePosting(
                $taxAccount,
                $tax,
                'tax',
                [$this->taxRuleLabel($mapping)],
                $mapping->id,
                null,
                $source->value,
            );
        }

        $postings = [...$postings, ...$this->splitNet($mapping, $net, $source)];

        $allocation = new RevenueAllocation($amount, $net, $tax, $postings, $warnings);

        return $this->closeRounding($allocation, $academyId, $source);
    }

    /**
     * The rule that governs this source: the one written for this specific
     * item if there is one, otherwise the academy's default for the source.
     *
     * Only active rules count. Deactivating a rule is how an accountant says
     * "go back to the built-in behaviour" without deleting their work.
     */
    public function mappingFor(
        int $academyId,
        RevenueSource $source,
        ?string $scopeType = null,
        ?int $scopeId = null,
    ): ?RevenueMapping {
        if ($this->tablesPresent === false) {
            return null;
        }

        $key = "{$academyId}:{$source->value}:{$scopeType}:{$scopeId}";

        if (array_key_exists($key, $this->cache)) {
            return $this->cache[$key];
        }

        $query = fn () => RevenueMapping::withoutGlobalScopes()
            ->with(['lines.account', 'taxAccount'])
            ->where('academy_id', $academyId)
            ->where('source_key', $source->value)
            ->where('is_active', true);

        try {
            $mapping = null;

            if ($scopeType !== null && $scopeId !== null) {
                $mapping = $query()
                    ->where('scope_type', $scopeType)
                    ->where('scope_id', $scopeId)
                    ->first();
            }

            $mapping ??= $query()->whereNull('scope_type')->first();
        } catch (QueryException $e) {
            // Only a missing table degrades to the built-in behaviour. Any
            // other database failure is a real one and must still surface,
            // rather than quietly sending money to a default account.
            if (Schema::hasTable('revenue_mappings')) {
                throw $e;
            }

            $this->tablesPresent = false;

            return null;
        }

        $this->tablesPresent = true;

        return $this->cache[$key] = $mapping;
    }

    /** Forget resolved rules — the editing screen changes them mid-request. */
    public function flush(): void
    {
        $this->cache = [];
    }

    /**
     * One weighted bucket per thing the invoice sold, plus the platform fee
     * when the academy has chosen to route it separately.
     *
     * @return list<array{source: RevenueSource, scope_type: ?string, scope_id: ?int, weight: int, tax: int}>
     */
    private function bucketsFor(?Invoice $invoice): array
    {
        if (! $invoice) {
            return [];
        }

        $buckets = [];

        // Always read the lines unscoped rather than trusting a loaded relation.
        // `invoice_items` is branch-scoped through its parent, and the ledger
        // must post every line an invoice carries regardless of which branch the
        // person taking the money happens to be looking at.
        $lines = $invoice->items()
            ->withoutGlobalScopes()
            ->get(['itemable_type', 'itemable_id', 'total_amount', 'tax_amount']);

        foreach ($lines as $line) {
            $source = RevenueSource::forItemable($line->itemable_type);
            $scopeType = $line->itemable_type ?: null;
            $scopeId = $scopeType ? (int) $line->itemable_id : null;

            $key = $source->value . ':' . $scopeType . ':' . $scopeId;

            $buckets[$key] ??= [
                'source' => $source,
                'scope_type' => $scopeType,
                'scope_id' => $scopeId,
                'weight' => 0,
                'tax' => 0,
            ];

            $buckets[$key]['weight'] += max(0, (int) $line->total_amount);
            $buckets[$key]['tax'] += max(0, (int) $line->tax_amount);
        }

        // The platform fee is a column on the invoice, not a line, so it has
        // always ridden along inside whatever the lines were. It only becomes
        // its own bucket once an academy activates the rule and says where it
        // belongs — otherwise nothing about today's split changes.
        $fee = max(0, (int) ($invoice->service_fee_amount ?? 0));

        if ($fee > 0 && $this->mappingFor((int) $invoice->academy_id, RevenueSource::ServiceFee)) {
            $buckets['service_fee'] = [
                'source' => RevenueSource::ServiceFee,
                'scope_type' => null,
                'scope_id' => null,
                'weight' => $fee,
                'tax' => 0,
            ];
        }

        return array_values(array_filter($buckets, fn ($b) => $b['weight'] > 0));
    }

    /**
     * How much of this amount is tax, and where it goes.
     *
     * @return array{0: int, 1: FinancialAccount|null, 2: list<string>}
     */
    private function resolveTax(int $academyId, RevenueMapping $mapping, int $amount, ?int $chargedTax): array
    {
        $warnings = [];

        $tax = match ($mapping->tax_mode) {
            TaxMode::None => 0,

            // The price contains the tax: net = gross × 10000 / (10000 + rate),
            // floored, and the tax is whatever is left. Deriving the net first
            // and subtracting means the two always add back to the gross, which
            // rounding the tax directly does not guarantee.
            TaxMode::Inclusive => $amount - intdiv($amount * 10000, 10000 + max(0, (int) $mapping->tax_rate_bp)),

            // The invoice added tax as its own figure; post exactly that and
            // never more than came in.
            TaxMode::Exclusive => min(max(0, (int) $chargedTax), $amount),
        };

        if ($mapping->tax_mode === TaxMode::Exclusive && $tax === 0 && $mapping->tax_rate_bp > 0) {
            $warnings[] = 'القاعدة تحتسب ضريبة مضافة على السعر، لكن الفاتورة لم تحمل سطر ضريبة — لم تُرحَّل ضريبة على هذا المبلغ';
        }

        if ($tax <= 0) {
            return [0, null, $warnings];
        }

        $account = $mapping->taxAccount;

        if (! $account) {
            // The table constraint forbids this, but a rule written before the
            // constraint, or by a direct SQL edit on a client database, must
            // not be allowed to post tax into nowhere.
            $account = $this->findAccount($academyId, self::VAT_PAYABLE);
        }

        if (! $account) {
            $warnings[] = 'لا يوجد حساب ضريبة معرَّف — أُدرج المبلغ كاملاً كإيراد';

            return [0, null, $warnings];
        }

        return [$tax, $account, $warnings];
    }

    /**
     * Split the net across the rule's lines: flat amounts off the top, then
     * percentages of the net, then one line takes what is left.
     *
     * Every share is capped at what actually remains, so a rule whose fixed
     * amounts exceed a small payment degrades to "the early lines take what
     * there is" rather than posting money that never arrived.
     *
     * @return list<RevenuePosting>
     */
    private function splitNet(RevenueMapping $mapping, int $net, RevenueSource $source): array
    {
        if ($net <= 0) {
            return [];
        }

        // Zero-padded so the sort is by pass, then the accountant's own order,
        // then insertion — and never by the accident of string length.
        $lines = $mapping->lines
            ->sortBy(fn ($line) => sprintf(
                '%d-%05d-%012d',
                $line->allocation_type->pass(),
                (int) $line->sort_order,
                (int) $line->id,
            ))
            ->values();

        $remaining = $net;
        $postings = [];

        foreach ($lines as $line) {
            if ($remaining <= 0) {
                break;
            }

            $share = match ($line->allocation_type) {
                AllocationType::Fixed => min((int) $line->fixed_amount, $remaining),
                AllocationType::Percentage => min(intdiv($net * (int) $line->percentage_bp, 10000), $remaining),
                AllocationType::Remainder => $remaining,
            };

            if ($share <= 0 || ! $line->account) {
                continue;
            }

            $remaining -= $share;

            $postings[] = new RevenuePosting(
                $line->account,
                $share,
                'revenue',
                [$this->lineRuleLabel($mapping, $line)],
                $mapping->id,
                $line->id,
                $source->value,
            );
        }

        // Percentages that do not reach 100% and no remainder line: the leftover
        // has to land somewhere or the entry does not balance. The last line
        // takes it, which is the same convention as every other split here.
        if ($remaining > 0 && $postings !== []) {
            $last = array_key_last($postings);
            $postings[$last] = $postings[$last]->withAmount($postings[$last]->amount + $remaining);
        }

        return $postings;
    }

    /**
     * Last line of defence: if the postings still do not add up to the money
     * that came in — a rule with no usable account, a net of zero — put the
     * difference somewhere real rather than writing a lopsided entry.
     */
    private function closeRounding(RevenueAllocation $allocation, int $academyId, RevenueSource $source): RevenueAllocation
    {
        $shortfall = $allocation->gross - $allocation->totalPosted();

        if ($shortfall === 0) {
            return $allocation;
        }

        $postings = $allocation->postings;

        if ($postings !== []) {
            $last = array_key_last($postings);
            $postings[$last] = $postings[$last]->withAmount($postings[$last]->amount + $shortfall);

            return new RevenueAllocation(
                $allocation->gross,
                $allocation->net,
                $allocation->tax,
                $postings,
                $allocation->warnings,
            );
        }

        $fallback = $this->legacyAllocation($academyId, $source, $shortfall);

        return new RevenueAllocation(
            $allocation->gross,
            $allocation->net,
            $allocation->tax,
            $fallback->postings,
            [...$allocation->warnings, 'لم تُنتج قاعدة التوزيع أي حساب صالح — رُحِّل المبلغ للحساب الافتراضي للمصدر'],
        );
    }

    /**
     * What the system did before routing existed: the whole amount to the one
     * revenue account the source's class was hardcoded to.
     */
    private function legacyAllocation(int $academyId, RevenueSource $source, int $amount): RevenueAllocation
    {
        $account = $this->accounts->byCode($academyId, $source->legacyAccountCode());

        return new RevenueAllocation($amount, $amount, 0, [
            new RevenuePosting($account, $amount, 'revenue', ['الافتراضي المدمج'], null, null, $source->value),
        ]);
    }

    private function findAccount(int $academyId, string $code): ?FinancialAccount
    {
        return FinancialAccount::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->where('code', $code)
            ->first();
    }

    private function taxRuleLabel(RevenueMapping $mapping): string
    {
        $rate = rtrim(rtrim(number_format($mapping->taxRatePercent(), 2), '0'), '.');

        return "ضريبة {$rate}% — {$mapping->tax_mode->label()}";
    }

    private function lineRuleLabel(RevenueMapping $mapping, $line): string
    {
        $scope = $mapping->isDefault() ? 'افتراضي' : 'مخصص';

        return "{$mapping->source_key->label()} ({$scope}): {$line->shareLabel()} → {$line->account->code}";
    }
}
