<?php

namespace App\Domain\Financial\Support;

use App\Domain\Shared\Exceptions\DomainException;

/**
 * The full result of routing one collected amount: what was revenue, what was
 * tax, and the credit side of the entry it becomes.
 *
 * The class exists mainly to hold one invariant in one place — the credits sum
 * to exactly the money that came in. An allocation that does not balance is not
 * a rounding annoyance, it is a ledger that no longer adds up, and it must fail
 * at the till rather than quietly a quarter later.
 */
final class RevenueAllocation
{
    /**
     * @param list<RevenuePosting> $postings
     * @param list<string> $warnings things the operator should be told about
     *                               but which do not make the entry wrong
     */
    public function __construct(
        public readonly int $gross,
        public readonly int $net,
        public readonly int $tax,
        public readonly array $postings,
        public readonly array $warnings = [],
    ) {}

    public static function empty(): self
    {
        return new self(0, 0, 0, []);
    }

    public function totalPosted(): int
    {
        return array_sum(array_map(fn (RevenuePosting $p) => $p->amount, $this->postings));
    }

    public function revenuePostings(): array
    {
        return array_values(array_filter($this->postings, fn (RevenuePosting $p) => ! $p->isTax()));
    }

    public function taxPostings(): array
    {
        return array_values(array_filter($this->postings, fn (RevenuePosting $p) => $p->isTax()));
    }

    public function isBalanced(): bool
    {
        return $this->totalPosted() === $this->gross;
    }

    /**
     * Refuse to hand back an entry whose sides disagree.
     *
     * Called by every writer before it touches `transactions`, because a
     * transaction row is immutable: a wrong one can only ever be reversed, not
     * corrected, and it will be read as fact by every report in between.
     */
    public function assertBalanced(): self
    {
        if (! $this->isBalanced()) {
            throw new DomainException(
                'خطأ في توزيع الإيراد: مجموع التوزيع (' . format_money($this->totalPosted()) .
                ') لا يساوي المبلغ المحصَّل (' . format_money($this->gross) . ') — تمت المراجعة قبل الترحيل ولم يُسجَّل أي قيد'
            );
        }

        return $this;
    }

    /** Merge two allocations — a payment covering several invoice lines. */
    public function merge(self $other): self
    {
        return new self(
            $this->gross + $other->gross,
            $this->net + $other->net,
            $this->tax + $other->tax,
            [...$this->postings, ...$other->postings],
            array_values(array_unique([...$this->warnings, ...$other->warnings])),
        );
    }

    /**
     * Collapse postings that land in the same account, keeping every rule that
     * contributed. One payment should not produce nine ledger rows against the
     * same account just because nine invoice lines fed it.
     */
    public function collapsed(): self
    {
        /** @var array<string, RevenuePosting> $byAccount */
        $byAccount = [];
        $labels = [];

        foreach ($this->postings as $posting) {
            $key = $posting->kind . ':' . $posting->account->id;

            if (! isset($byAccount[$key])) {
                $byAccount[$key] = $posting;
                $labels[$key] = $posting->ruleLabels;

                continue;
            }

            $byAccount[$key] = $byAccount[$key]->withAmount($byAccount[$key]->amount + $posting->amount);
            $labels[$key] = array_values(array_unique([...$labels[$key], ...$posting->ruleLabels]));
        }

        $merged = [];

        foreach ($byAccount as $key => $posting) {
            $merged[] = new RevenuePosting(
                $posting->account,
                $posting->amount,
                $posting->kind,
                $labels[$key],
                $posting->mappingId,
                // A collapsed posting no longer belongs to a single rule line.
                count($labels[$key]) > 1 ? null : $posting->mappingLineId,
                $posting->sourceKey,
            );
        }

        return new self($this->gross, $this->net, $this->tax, $merged, $this->warnings);
    }

    public function withWarning(string $warning): self
    {
        return new self(
            $this->gross,
            $this->net,
            $this->tax,
            $this->postings,
            array_values(array_unique([...$this->warnings, $warning])),
        );
    }
}
