<?php

namespace App\Domain\Expenses\Services;

use App\Domain\Expenses\Models\ExpenseApprovalRule;
use App\Domain\Identity\Models\User;
use App\Support\Money\Money;
use Illuminate\Validation\ValidationException;

/**
 * Who may approve what (§40).
 *
 * The bands are rows, so an agency changes its thresholds without a
 * deployment. Nothing here is hard-coded to an amount.
 */
class ApprovalMatrix
{
    /**
     * The band an amount falls into, or null when no matrix is configured.
     */
    public function bandFor(int $companyId, Money $amount): ?ExpenseApprovalRule
    {
        return ExpenseApprovalRule::where('company_id', $companyId)
            ->where('currency', $amount->currency)
            ->active()
            ->get()
            // Ordered narrowest first, so the first band that covers the
            // amount is the lowest authority that can sign it off.
            ->first(fn (ExpenseApprovalRule $rule) => $rule->covers($amount->minorUnits));
    }

    /**
     * Checks an approver against the matrix.
     *
     * With no matrix configured the permission alone is the control, which is
     * how a small agency starts before it writes its bands down.
     *
     * @throws ValidationException
     */
    public function assertMayApprove(User $actor, int $companyId, Money $amount): void
    {
        $band = $this->bandFor($companyId, $amount);

        if ($band === null) {
            return;
        }

        if ($actor->hasRole($band->approver_role)) {
            return;
        }

        // Anyone senior to the band may also sign: the matrix sets a floor,
        // not a ceiling.
        if ($this->outranks($actor, $companyId, $band, $amount)) {
            return;
        }

        throw ValidationException::withMessages([
            'amount' => [
                sprintf(
                    '%s needs approval from %s.',
                    $amount->format(),
                    str_replace('-', ' ', $band->approver_role),
                ),
            ],
        ]);
    }

    /**
     * Whether the actor holds a role from a band above this one — those people
     * approve larger amounts, so they can approve smaller ones too.
     */
    private function outranks(User $actor, int $companyId, ExpenseApprovalRule $band, Money $amount): bool
    {
        $higher = ExpenseApprovalRule::where('company_id', $companyId)
            ->where('currency', $amount->currency)
            ->active()
            ->get()
            ->filter(fn (ExpenseApprovalRule $rule) => $rule->sort_order > $band->sort_order
                || ($rule->up_to_amount === null && $band->up_to_amount !== null));

        return $higher->contains(fn (ExpenseApprovalRule $rule) => $actor->hasRole($rule->approver_role));
    }

    /**
     * The whole matrix, for display and configuration.
     *
     * @return array<int, array<string, mixed>>
     */
    public function forCompany(int $companyId): array
    {
        return ExpenseApprovalRule::where('company_id', $companyId)
            ->active()
            ->get()
            ->map(fn (ExpenseApprovalRule $rule) => [
                'id' => $rule->uuid,
                'up_to_amount' => $rule->up_to_amount,
                'currency' => $rule->currency,
                'ceiling_label' => $rule->ceilingLabel(),
                'approver_role' => $rule->approver_role,
                'sort_order' => $rule->sort_order,
            ])
            ->all();
    }
}
