<?php

namespace App\Domain\Financial\Services;

use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Enums\TransactionType;
use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class PaymentService
{
    public function __construct(
        private InvoiceService $invoiceService,
        private LedgerAccountResolver $accounts,
        private RevenueRouter $router,
    ) {}

    /**
     * Record a confirmed inbound (or outbound) payment and post it to the ledger.
     *
     * Every guard below used to live in the UI — InvoiceShow and
     * CollectPaymentWizard each carried their own copy — so any new caller
     * (the POS, the portal, an approved InstaPay proof) inherited none of
     * them. They are enforced here now, inside the same transaction and
     * against a locked re-read of the invoice, because a disabled button is
     * not a concurrency control.
     */
    public function recordPayment(array $data, User $creator): Payment
    {
        return DB::transaction(function () use ($data, $creator) {
            $amount = (int) ($data['amount'] ?? 0);

            if ($amount <= 0) {
                throw new DomainException('مبلغ الدفعة يجب أن يكون أكبر من صفر');
            }

            $invoice = null;

            if (! empty($data['invoice_id'])) {
                // Locked re-read: the due amount decided against must be the
                // one this transaction commits against, not the one the form
                // rendered with.
                $invoice = Invoice::withoutGlobalScopes()
                    ->where('id', $data['invoice_id'])
                    ->lockForUpdate()
                    ->first();

                if (! $invoice) {
                    throw new DomainException('الفاتورة غير موجودة');
                }

                $this->assertInvoiceIsPayable($invoice);

                if (($data['direction'] ?? 'inbound') === 'inbound' && $amount > $invoice->due_amount) {
                    throw new DomainException(
                        'المبلغ أكبر من المتبقي على الفاتورة (' . format_money($invoice->due_amount) . ')'
                    );
                }

                $data['payer_type'] ??= $invoice->billable_type;
                $data['payer_id'] ??= $invoice->billable_id;
                $data['academy_id'] ??= $invoice->academy_id;

                if ((int) $data['academy_id'] !== (int) $invoice->academy_id) {
                    throw new DomainException('الفاتورة تخص أكاديمية أخرى');
                }

                // Branch is the same class of assertion as academy, and was
                // missing. The locked re-read above deliberately drops every
                // global scope, so the branch scope does not protect this path
                // either: a tampered invoice_id would otherwise let one branch
                // record a payment — and its ledger rows — against another
                // branch's invoice. Only a genuine disagreement is refused;
                // an invoice from before the branch backfill carries none, and
                // the caller's value is then what files the payment.
                $claimedBranchId = $data['branch_id'] ?? null;

                if ($invoice->branch_id && $claimedBranchId
                    && (int) $claimedBranchId !== (int) $invoice->branch_id) {
                    throw new DomainException('الفاتورة تخص فرعاً آخر');
                }

                $data['branch_id'] = $invoice->branch_id ?: $claimedBranchId;

                $currency = $data['currency'] ?? $invoice->currency ?? 'EGP';
                if ($invoice->currency && $currency !== $invoice->currency) {
                    throw new DomainException('عملة الدفعة لا تطابق عملة الفاتورة');
                }
                $data['currency'] = $currency;
            }

            if (empty($data['academy_id'])) {
                throw new DomainException('لا يمكن تسجيل دفعة بدون أكاديمية');
            }

            if (empty($data['reference'])) {
                $data['reference'] = 'PAY-' . now()->format('YmdHis') . '-' . ($data['invoice_id'] ?? 'X');
            }

            $payment = Payment::create([
                ...$data,
                'amount' => $amount,
                'status' => PaymentStatus::Confirmed,
                'confirmed_at' => now(),
                'created_by' => $creator->id,
            ]);

            if ($invoice) {
                $this->invoiceService->updatePaidAmount($invoice, $payment->amount, $creator);
            }

            $this->postToLedger($payment, $invoice);

            PaymentReceived::dispatch($payment, $creator);

            return $payment;
        });
    }

    /**
     * Confirm a payment that was created up-front in the Pending state by an
     * online gateway, and post it to the ledger.
     *
     * Gateways cannot use recordPayment(): the row has to exist before the
     * customer is redirected, so the money arrives against a payment that is
     * already there. What they were doing instead was mutating the invoice
     * inline — no lock, no ledger row at all, and an idempotency guard that
     * was dead code because the finder already filtered on status = Pending.
     * A gateway that retries its webhook therefore credited the invoice twice.
     *
     * @param  int|null  $expectedAmount  Amount the gateway says it captured,
     *                                    in piasters. A mismatch is refused.
     */
    public function confirmPending(Payment $payment, User $actor, ?int $expectedAmount = null): Payment
    {
        return DB::transaction(function () use ($payment, $actor, $expectedAmount) {
            $locked = Payment::withoutGlobalScopes()
                ->where('id', $payment->id)
                ->lockForUpdate()
                ->firstOrFail();

            // Idempotency that actually holds: a second webhook for the same
            // payment finds it confirmed and returns without touching money.
            if ($locked->status !== PaymentStatus::Pending) {
                return $locked;
            }

            if ($expectedAmount !== null && $expectedAmount !== (int) $locked->amount) {
                throw new DomainException(
                    'المبلغ المستلم من بوابة الدفع لا يطابق مبلغ الدفعة'
                );
            }

            $invoice = null;

            if ($locked->invoice_id) {
                $invoice = Invoice::withoutGlobalScopes()
                    ->where('id', $locked->invoice_id)
                    ->lockForUpdate()
                    ->first();

                if ($invoice) {
                    $this->assertInvoiceIsPayable($invoice);
                }
            }

            $locked->status = PaymentStatus::Confirmed;
            $locked->confirmed_at = now();
            $locked->save();

            if ($invoice) {
                $this->invoiceService->updatePaidAmount($invoice, (int) $locked->amount, $actor);
            }

            $this->postToLedger($locked, $invoice);

            PaymentReceived::dispatch($locked, $actor);

            return $locked;
        });
    }

    public function refund(Payment $payment, int $amount, User $creator): Payment
    {
        return DB::transaction(function () use ($payment, $amount, $creator) {
            $refund = Payment::create([
                'academy_id' => $payment->academy_id,
                // Inherit the branch of the payment being refunded, otherwise the
                // refund lands in no branch and disappears from every branch view.
                'branch_id' => $payment->branch_id,
                'invoice_id' => $payment->invoice_id,
                'reference' => 'REF-' . $payment->reference,
                'direction' => 'outbound',
                'method' => $payment->method,
                'status' => PaymentStatus::Confirmed,
                'payer_type' => $payment->payer_type,
                'payer_id' => $payment->payer_id,
                'amount' => $amount,
                'currency' => $payment->currency,
                'payment_date' => now()->toDateString(),
                'confirmed_at' => now(),
                'notes' => "Refund for payment #{$payment->reference}",
                'created_by' => $creator->id,
            ]);

            if ($payment->invoice_id) {
                $this->invoiceService->updatePaidAmount(
                    $payment->invoice()->withoutGlobalScopes()->lockForUpdate()->first(),
                    -$amount,
                    $creator
                );
            }

            $this->postToLedger($refund, $refund->invoice, TransactionType::Refund);

            return $refund;
        });
    }

    /**
     * A payment may only settle an invoice that is actually open. Draft is
     * allowed on purpose: InvoiceService::create() issues every invoice as a
     * draft and the POS settles it in the same transaction, so blocking draft
     * would close the till.
     */
    private function assertInvoiceIsPayable(Invoice $invoice): void
    {
        $blocked = [
            InvoiceStatus::Cancelled,
            InvoiceStatus::Paid,
            InvoiceStatus::Refunded,
            InvoiceStatus::WrittenOff,
        ];

        if (in_array($invoice->status, $blocked, true)) {
            throw new DomainException(
                'لا يمكن تسجيل دفعة على فاتورة بحالة: ' . $invoice->status->label()
            );
        }
    }

    /**
     * Post the payment to the double-entry ledger.
     *
     * `transactions` is a single row carrying both sides (debit_account_id +
     * credit_account_id), so a payment spread across more than one revenue
     * account produces one row per account, all linked to the same payment.
     *
     * Which accounts those are is no longer a decision this class makes. The
     * academy's own routing rules decide — including how much of the money was
     * tax owed to the Authority rather than revenue at all — and the allocation
     * is refused before a single row is written unless the credits add up to
     * exactly the money that came in.
     */
    private function postToLedger(Payment $payment, ?Invoice $invoice, ?TransactionType $type = null): void
    {
        $isInbound = $payment->direction === 'inbound';
        $type ??= $isInbound ? TransactionType::PaymentReceived : TransactionType::PaymentMade;

        $asset = $this->accounts->assetAccountForMethod(
            $payment->academy_id,
            $payment->method?->value ?? (string) $payment->method
        );

        $allocation = $this->router->allocatePayment(
            (int) $payment->academy_id,
            $invoice,
            (int) $payment->amount,
        );

        foreach ($allocation->postings as $posting) {
            Transaction::create([
                'academy_id' => $payment->academy_id,
                'branch_id' => $payment->branch_id,
                // Money in debits the asset it landed in and credits revenue
                // (or the tax liability). Money out reverses both sides.
                'debit_account_id' => $isInbound ? $asset->id : $posting->account->id,
                'credit_account_id' => $isInbound ? $posting->account->id : $asset->id,
                'payment_id' => $payment->id,
                'invoice_id' => $payment->invoice_id,
                'amount' => $posting->amount,
                'currency' => $payment->currency,
                'type' => $type,
                'description' => $this->buildDescription($payment, $type),
                'transaction_date' => $payment->payment_date,
                // Which rule sent this money here, recorded on the row itself:
                // the rule can be edited tomorrow, the entry cannot.
                'metadata' => $posting->metadata(),
                'created_by' => $payment->created_by,
            ]);
        }
    }

    private function buildDescription(Payment $payment, TransactionType $type): string
    {
        return match ($type) {
            TransactionType::PaymentReceived => "Payment received: {$payment->amount} {$payment->currency}",
            TransactionType::PaymentMade => "Payment made: {$payment->amount} {$payment->currency}",
            TransactionType::Refund => "Refund: {$payment->amount} {$payment->currency}",
            default => "Transaction: {$payment->amount} {$payment->currency}",
        };
    }
}
