<?php

namespace App\Domain\Financial\Services;

use App\Domain\Financial\Enums\AccountType;
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\RevenueMapping;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\DB;

/**
 * Writing routing rules — the half of revenue routing that has to say no.
 *
 * Every rule this saves is checked before it can be saved, because a rule is
 * read at the till by code that must never stop and ask. The checks are not
 * paperwork; each one corresponds to a specific way a plausible-looking rule
 * silently produces a wrong ledger:
 *
 *   - a rule with percentages that do not reach 100% and no line to take the
 *     rest quietly overloads whichever line happened to be last;
 *   - two "remainder" lines mean the leftover has two homes;
 *   - a flat amount after the leftover has already been taken can never fire;
 *   - a tax rate pointed at a revenue account books money owed to the Tax
 *     Authority as income, which overstates revenue and hides a liability.
 */
class RevenueRoutingService
{
    /**
     * Where collected money may be sent.
     *
     * Revenue is the ordinary case. Liability is deliberate and important: it
     * is how "this part is not earned yet" (2010 إيرادات مقدمة) and "this part
     * is tax I owe" are expressed. Equity covers an owner's contribution booked
     * through the till. An asset or an expense account is neither — money
     * arriving cannot be a cost, and crediting an asset here would double-count
     * the cash that was already debited.
     */
    private const POSTABLE_TYPES = [AccountType::Revenue, AccountType::Liability, AccountType::Equity];

    public function __construct(private RevenueRouter $router) {}

    /**
     * Create or replace the rule for one revenue source (optionally narrowed
     * to a single product, programme or event).
     *
     * @param  array{is_active?: bool, label?: ?string, tax_mode?: string, tax_rate_bp?: int, tax_account_id?: ?int, notes?: ?string}  $attributes
     * @param  list<array{account_id: int, allocation_type: string, percentage_bp?: ?int, fixed_amount?: ?int, label?: ?string}>  $lines
     */
    public function save(
        int $academyId,
        RevenueSource $source,
        array $attributes,
        array $lines,
        ?int $actorId = null,
        ?string $scopeType = null,
        ?int $scopeId = null,
    ): RevenueMapping {
        $taxMode = TaxMode::from($attributes['tax_mode'] ?? TaxMode::None->value);
        $taxRate = (int) ($attributes['tax_rate_bp'] ?? 0);
        $taxAccountId = $attributes['tax_account_id'] ?? null;

        $lines = $this->normaliseLines($lines);

        $this->assertScopeIsValid($academyId, $source, $scopeType, $scopeId);
        $this->assertLinesAreCoherent($lines);
        $this->assertAccountsArePostable($academyId, $lines);
        $this->assertTaxIsCoherent($academyId, $taxMode, $taxRate, $taxAccountId);

        return DB::transaction(function () use (
            $academyId, $source, $attributes, $lines, $actorId, $scopeType, $scopeId, $taxMode, $taxRate, $taxAccountId
        ) {
            $mapping = RevenueMapping::withoutGlobalScopes()
                ->where('academy_id', $academyId)
                ->where('source_key', $source->value)
                ->when(
                    $scopeType === null,
                    fn ($q) => $q->whereNull('scope_type'),
                    fn ($q) => $q->where('scope_type', $scopeType)->where('scope_id', $scopeId),
                )
                ->lockForUpdate()
                ->first();

            $payload = [
                'academy_id' => $academyId,
                'source_key' => $source->value,
                'scope_type' => $scopeType,
                'scope_id' => $scopeId,
                'label' => $attributes['label'] ?? null,
                'is_active' => (bool) ($attributes['is_active'] ?? true),
                'tax_mode' => $taxMode->value,
                'tax_rate_bp' => $taxMode === TaxMode::None ? 0 : $taxRate,
                'tax_account_id' => $taxMode === TaxMode::None ? null : $taxAccountId,
                'notes' => $attributes['notes'] ?? null,
                'updated_by' => $actorId,
            ];

            if ($mapping) {
                $mapping->fill($payload)->save();
            } else {
                $mapping = RevenueMapping::withoutGlobalScopes()->create($payload);
            }

            // Replace rather than reconcile: a rule is small, and a stale line
            // that survived an edit is a payment posted to an account nobody
            // meant to keep. The old rows are gone but the transactions they
            // produced carry their own copy of what they did.
            $mapping->lines()->delete();

            foreach ($lines as $index => $line) {
                $mapping->lines()->create([
                    'academy_id' => $academyId,
                    'account_id' => $line['account_id'],
                    'allocation_type' => $line['allocation_type'],
                    'percentage_bp' => $line['percentage_bp'],
                    'fixed_amount' => $line['fixed_amount'],
                    'label' => $line['label'],
                    'sort_order' => $index,
                ]);
            }

            $this->router->flush();

            return $mapping->fresh(['lines.account', 'taxAccount']);
        });
    }

    /**
     * Remove a rule written for one specific item. The source's own default
     * takes over again — which is why only overrides can be deleted: deleting
     * a default would leave the source with nothing to fall back to but the
     * built-in behaviour, and "turn it off" already says that more clearly.
     */
    public function deleteOverride(RevenueMapping $mapping): void
    {
        if ($mapping->isDefault()) {
            throw new DomainException('لا يمكن حذف القاعدة الافتراضية — أوقف تفعيلها بدلاً من ذلك');
        }

        DB::transaction(function () use ($mapping) {
            $mapping->lines()->delete();
            $mapping->delete();
            $this->router->flush();
        });
    }

    /**
     * Add an account to the chart, so an accountant who wants the money in a
     * new account does not need an engineer to make one.
     *
     * @param  array{code: string, name_ar: string, name?: ?string, type: string}  $data
     */
    public function createAccount(int $academyId, array $data): FinancialAccount
    {
        $code = trim($data['code']);
        $type = AccountType::tryFrom($data['type'] ?? '');

        if ($code === '' || ! preg_match('/^[0-9A-Za-z\-]{2,20}$/', $code)) {
            throw new DomainException('رقم الحساب يجب أن يكون من 2 إلى 20 حرفاً أو رقماً');
        }

        if (! $type) {
            throw new DomainException('نوع الحساب غير صحيح');
        }

        return DB::transaction(function () use ($academyId, $code, $type, $data) {
            $exists = FinancialAccount::withoutGlobalScopes()
                ->where('academy_id', $academyId)
                ->where('code', $code)
                ->exists();

            if ($exists) {
                throw new DomainException("رقم الحساب {$code} مستخدم بالفعل في شجرة الحسابات");
            }

            return FinancialAccount::withoutGlobalScopes()->create([
                'academy_id' => $academyId,
                'code' => $code,
                'name' => $data['name'] ?: $data['name_ar'],
                'name_ar' => $data['name_ar'],
                'type' => $type->value,
                'category' => $type === AccountType::Revenue ? 'operating' : null,
                'is_system' => false,
                'is_active' => true,
            ]);
        });
    }

    /**
     * @param  list<array<string, mixed>>  $lines
     * @return list<array{account_id: int, allocation_type: string, percentage_bp: ?int, fixed_amount: ?int, label: ?string}>
     */
    private function normaliseLines(array $lines): array
    {
        $out = [];

        foreach ($lines as $line) {
            $type = AllocationType::tryFrom($line['allocation_type'] ?? '');
            $accountId = (int) ($line['account_id'] ?? 0);

            if (! $type || $accountId <= 0) {
                continue;
            }

            $out[] = [
                'account_id' => $accountId,
                'allocation_type' => $type->value,
                'percentage_bp' => $type === AllocationType::Percentage ? max(0, (int) ($line['percentage_bp'] ?? 0)) : null,
                'fixed_amount' => $type === AllocationType::Fixed ? max(0, (int) ($line['fixed_amount'] ?? 0)) : null,
                'label' => ($line['label'] ?? null) ?: null,
            ];
        }

        return $out;
    }

    private function assertScopeIsValid(int $academyId, RevenueSource $source, ?string $scopeType, ?int $scopeId): void
    {
        if ($scopeType === null && $scopeId === null) {
            return;
        }

        if ($scopeType === null || $scopeId === null) {
            throw new DomainException('تخصيص القاعدة لصنف بعينه يحتاج الصنف ونوعه معاً');
        }

        if ($scopeType !== $source->scopeModel()) {
            throw new DomainException('هذا المصدر لا يقبل التخصيص لصنف بعينه');
        }

        // The item has to be this academy's. Without this, an id from another
        // tenant saves a rule that can never match anything — a rule the
        // accountant wrote, sees in the list, and would reasonably believe is
        // in force.
        $exists = $scopeType::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->whereKey($scopeId)
            ->exists();

        if (! $exists) {
            throw new DomainException('الصنف المطلوب تخصيص القاعدة له غير موجود في هذه الأكاديمية');
        }
    }

    /** @param list<array<string, mixed>> $lines */
    private function assertLinesAreCoherent(array $lines): void
    {
        if ($lines === []) {
            throw new DomainException('أضف سطر توزيع واحداً على الأقل — لا يمكن ترك إيراد بلا حساب');
        }

        $remainders = 0;
        $percentageTotal = 0;
        $hasFixed = false;

        foreach ($lines as $line) {
            switch (AllocationType::from($line['allocation_type'])) {
                case AllocationType::Remainder:
                    $remainders++;
                    break;
                case AllocationType::Percentage:
                    if ($line['percentage_bp'] <= 0) {
                        throw new DomainException('النسبة يجب أن تكون أكبر من صفر');
                    }
                    $percentageTotal += $line['percentage_bp'];
                    break;
                case AllocationType::Fixed:
                    if ($line['fixed_amount'] <= 0) {
                        throw new DomainException('المبلغ الثابت يجب أن يكون أكبر من صفر');
                    }
                    $hasFixed = true;
                    break;
            }
        }

        if ($remainders > 1) {
            throw new DomainException('سطر "الباقي" لا يتكرر — لا يمكن أن يذهب المتبقي إلى حسابين');
        }

        if ($percentageTotal > 10000) {
            throw new DomainException(
                'مجموع النسب ' . number_format($percentageTotal / 100, 2) . '% — لا يجوز أن يتجاوز 100%'
            );
        }

        if ($remainders === 1) {
            return;
        }

        if ($hasFixed) {
            throw new DomainException('قاعدة فيها مبلغ ثابت تحتاج سطر "الباقي" ليستوعب ما تبقى من المبلغ');
        }

        if ($percentageTotal !== 10000) {
            throw new DomainException(
                'مجموع النسب ' . number_format($percentageTotal / 100, 2) . '% — أكمله إلى 100% أو أضف سطر "الباقي"'
            );
        }
    }

    /** @param list<array<string, mixed>> $lines */
    private function assertAccountsArePostable(int $academyId, array $lines): void
    {
        $ids = array_values(array_unique(array_column($lines, 'account_id')));

        $accounts = FinancialAccount::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->whereIn('id', $ids)
            ->get()
            ->keyBy('id');

        foreach ($ids as $id) {
            $account = $accounts->get($id);

            if (! $account) {
                throw new DomainException('حساب غير موجود في شجرة حسابات هذه الأكاديمية');
            }

            if (! $account->is_active) {
                throw new DomainException("الحساب {$account->code} غير مفعَّل");
            }

            if (! in_array($account->type, self::POSTABLE_TYPES, true)) {
                throw new DomainException(
                    "الحساب {$account->code} ({$account->name_ar}) من نوع أصول أو مصروفات — " .
                    'الإيراد المحصَّل يُرحَّل لحساب إيراد أو التزام أو حقوق ملكية فقط'
                );
            }
        }
    }

    private function assertTaxIsCoherent(int $academyId, TaxMode $mode, int $rate, ?int $taxAccountId): void
    {
        if ($mode === TaxMode::None) {
            return;
        }

        if ($rate <= 0 || $rate > 10000) {
            throw new DomainException('نسبة الضريبة يجب أن تكون بين 0.01% و100%');
        }

        if (! $taxAccountId) {
            throw new DomainException('اختر حساب الضريبة — الضريبة المحصَّلة التزام على الأكاديمية ولا يصح تركها بلا حساب');
        }

        $account = FinancialAccount::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->where('id', $taxAccountId)
            ->first();

        if (! $account) {
            throw new DomainException('حساب الضريبة غير موجود في شجرة الحسابات');
        }

        // The one rule in this file that is accounting rather than arithmetic.
        // VAT collected from a customer is money held on behalf of the Tax
        // Authority; booking it to a revenue account reports it as the
        // academy's own income and leaves the debt it represents invisible.
        if ($account->type !== AccountType::Liability) {
            throw new DomainException(
                "حساب الضريبة يجب أن يكون حساب التزامات — {$account->code} ({$account->name_ar}) ليس كذلك. " .
                'الضريبة المحصَّلة مستحقة لمصلحة الضرائب وليست إيراداً للأكاديمية'
            );
        }
    }
}
