<?php

namespace App\Services\Accounting;

use App\Enums\JournalStatus;
use App\Models\Account;
use App\Models\Branch;
use App\Models\CostCenter;
use App\Models\Department;
use App\Models\Party;
use App\Models\JournalEntry;
use App\Support\Decimal;
use DomainException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

final class JournalPostingService
{
    public function __construct(
        private readonly AccountingPeriodGuard $periodGuard,
        private readonly JournalNumberService $numberService,
    ) {}

    /**
     * @param array<int, array{account_id:int, debit?:string, credit?:string, party_id?:int|null, party_ledger?:string|null, branch_id?:int|null, department_id?:int|null, cost_center_id?:int|null, description?:string|null}> $lines
     */
    public function post(Model $source, array $header, array $lines): JournalEntry
    {
        if (count($lines) < 2) {
            throw new DomainException('A journal requires at least two lines.');
        }
        foreach (['company_id', 'branch_id', 'posting_date', 'source_module', 'description'] as $requiredHeader) {
            if (! array_key_exists($requiredHeader, $header) || $header[$requiredHeader] === null || $header[$requiredHeader] === '') {
                throw new DomainException("Missing journal header value: {$requiredHeader}.");
            }
        }
        if ($source->getAttribute('company_id') !== null && (int) $source->getAttribute('company_id') !== (int) $header['company_id']) {
            throw new DomainException('The journal source does not belong to the selected company.');
        }
        if ($source->getAttribute('branch_id') !== null && (int) $source->getAttribute('branch_id') !== (int) $header['branch_id']) {
            throw new DomainException('The journal source does not belong to the selected branch.');
        }
        if (Decimal::compare((string) ($header['exchange_rate'] ?? '1'), '0') <= 0) {
            throw new DomainException('Journal exchange rate must be greater than zero.');
        }

        $debitTotal = Decimal::scale('0');
        $creditTotal = Decimal::scale('0');
        foreach ($lines as $line) {
            $debit = Decimal::scale((string) ($line['debit'] ?? '0'));
            $credit = Decimal::scale((string) ($line['credit'] ?? '0'));
            if (Decimal::compare($debit, '0') < 0 || Decimal::compare($credit, '0') < 0) {
                throw new DomainException('Journal amounts cannot be negative.');
            }
            if (Decimal::compare($debit, '0') > 0 && Decimal::compare($credit, '0') > 0) {
                throw new DomainException('A journal line cannot contain both debit and credit.');
            }
            if (Decimal::compare($debit, '0') === 0 && Decimal::compare($credit, '0') === 0) {
                throw new DomainException('A journal line must contain a debit or credit.');
            }
            $debitTotal = Decimal::add($debitTotal, $debit);
            $creditTotal = Decimal::add($creditTotal, $credit);
        }
        if (! Decimal::equals($debitTotal, $creditTotal)) {
            throw new DomainException("Journal is not balanced: debit {$debitTotal}, credit {$creditTotal}.");
        }

        return DB::transaction(function () use ($source, $header, $lines, $debitTotal, $creditTotal): JournalEntry {
            $existing = JournalEntry::query()
                ->where('source_type', $source::class)
                ->where('source_id', $source->getKey())
                ->lockForUpdate()
                ->exists();
            if ($existing) {
                throw new DomainException('This source document has already been posted.');
            }

            $postingDate = $header['posting_date'];
            $period = $this->periodGuard->openPeriodFor((int) $header['company_id'], $postingDate);
            $branch = Branch::query()->lockForUpdate()->findOrFail($header['branch_id']);
            if ((int) $branch->company_id !== (int) $header['company_id']) {
                throw new DomainException('The journal branch does not belong to the selected company.');
            }

            $accountIds = collect($lines)->pluck('account_id')->filter()->unique()->values();
            if (Account::query()->where('company_id', $header['company_id'])->whereIn('id', $accountIds)->count() !== $accountIds->count()) {
                throw new DomainException('One or more journal accounts do not belong to the selected company.');
            }
            $partyIds = collect($lines)->pluck('party_id')->filter()->unique()->values();
            if ($partyIds->isNotEmpty() && Party::query()->where('company_id', $header['company_id'])->whereIn('id', $partyIds)->count() !== $partyIds->count()) {
                throw new DomainException('One or more journal parties do not belong to the selected company.');
            }

            $lineBranchIds = collect($lines)->map(fn (array $line) => $line['branch_id'] ?? $header['branch_id'])->filter()->unique()->values();
            if (Branch::query()->where('company_id', $header['company_id'])->whereIn('id', $lineBranchIds)->count() !== $lineBranchIds->count()) {
                throw new DomainException('One or more journal line branches do not belong to the selected company.');
            }

            $departmentIds = collect($lines)->pluck('department_id')->filter()->unique()->values();
            $departments = Department::query()->where('company_id', $header['company_id'])->whereIn('id', $departmentIds)->get()->keyBy('id');
            if ($departments->count() !== $departmentIds->count()) {
                throw new DomainException('One or more journal departments do not belong to the selected company.');
            }
            $costCenterIds = collect($lines)->pluck('cost_center_id')->filter()->unique()->values();
            $costCenters = CostCenter::query()->where('company_id', $header['company_id'])->whereIn('id', $costCenterIds)->get()->keyBy('id');
            if ($costCenters->count() !== $costCenterIds->count()) {
                throw new DomainException('One or more journal cost centres do not belong to the selected company.');
            }
            foreach ($lines as $line) {
                $lineBranchId = (int) ($line['branch_id'] ?? $header['branch_id']);
                if (! empty($line['department_id'])) {
                    $department = $departments->get($line['department_id']);
                    if ($department?->branch_id && (int) $department->branch_id !== $lineBranchId) {
                        throw new DomainException('A journal department does not belong to its line branch.');
                    }
                }
                if (! empty($line['cost_center_id'])) {
                    $costCenter = $costCenters->get($line['cost_center_id']);
                    if ($costCenter?->branch_id && (int) $costCenter->branch_id !== $lineBranchId) {
                        throw new DomainException('A journal cost centre does not belong to its line branch.');
                    }
                }
                if (! empty($line['party_ledger']) && ! in_array($line['party_ledger'], ['customer', 'vendor'], true)) {
                    throw new DomainException('Invalid party ledger on a journal line.');
                }
                if (! empty($line['party_ledger']) && empty($line['party_id'])) {
                    throw new DomainException('A party ledger line requires a party.');
                }
            }

            $journal = JournalEntry::query()->create([
                'company_id' => $header['company_id'],
                'branch_id' => $header['branch_id'],
                'accounting_period_id' => $period->id,
                'journal_number' => $this->numberService->next($branch, $postingDate),
                'posting_date' => $postingDate,
                'document_date' => $header['document_date'] ?? $postingDate,
                'reference_number' => $header['reference_number'] ?? null,
                'source_module' => $header['source_module'],
                'source_type' => $source::class,
                'source_id' => $source->getKey(),
                'description' => $header['description'],
                'currency_code' => $header['currency_code'] ?? config('accounting.base_currency'),
                'exchange_rate' => $header['exchange_rate'] ?? '1.00000000',
                'debit_total' => $debitTotal,
                'credit_total' => $creditTotal,
                'status' => JournalStatus::POSTED,
                'created_by' => auth()->id(),
                'posted_by' => auth()->id(),
                'posted_at' => now(),
            ]);

            foreach (array_values($lines) as $index => $line) {
                $journal->lines()->create([
                    'line_number' => $index + 1,
                    'account_id' => $line['account_id'],
                    'party_id' => $line['party_id'] ?? null,
                    'party_ledger' => $line['party_ledger'] ?? null,
                    'branch_id' => $line['branch_id'] ?? $header['branch_id'],
                    'department_id' => $line['department_id'] ?? null,
                    'cost_center_id' => $line['cost_center_id'] ?? null,
                    'description' => $line['description'] ?? $header['description'],
                    'debit' => Decimal::scale((string) ($line['debit'] ?? '0')),
                    'credit' => Decimal::scale((string) ($line['credit'] ?? '0')),
                    'currency_code' => $header['currency_code'] ?? config('accounting.base_currency'),
                    'exchange_rate' => $header['exchange_rate'] ?? '1.00000000',
                ]);
            }
            return $journal->load('lines');
        }, 3);
    }
}
