<?php

namespace App\Services;

use App\Models\Customers\Customer;
use App\Models\Lookups\PaymentMethod;
use App\Models\Lookups\Status;
use App\Models\Returns\CreditNote;
use App\Models\Sales\Invoice;
use App\Models\Sales\Transaction;
use App\Models\Sales\TransactionClaim;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use RuntimeException;

/**
 * Owns transactions, transaction claims, and the link to customer credit.
 *
 * Splits = the SRS A5 model: at checkout a customer can pay with multiple
 * methods at once. Each method becomes one `transactions` row; allocations to
 * invoices are `transaction_claims` rows.
 */
class PaymentService
{
    public function __construct(
        private readonly DocumentNumberService $numbers,
        private readonly CustomerCreditService $credit,
        private readonly CreditNoteService $creditNotes,
    ) {}

    /**
     * Create a standalone transaction (no invoice yet). Used by the
     * Transactions module's "Create Transaction" form.
     *
     * @param  array{customer_id:int,branch_id?:int,payment_method:string,amount:float,reference?:string,note?:string,taken_on?:string,cheque_number?:string,cheque_bank?:string,cheque_branch_code?:string,cheque_date?:string}  $data
     */
    public function createStandalone(array $data): Transaction
    {
        $method = PaymentMethod::findActiveByCode($data['payment_method'])
            ?? throw new InvalidArgumentException('Unknown or unavailable payment method.');

        $this->assertRequiredFields($method, $data);

        return DB::transaction(function () use ($data, $method) {
            $isCheque = $method->code === PaymentMethod::CHEQUE;
            $statusCode = $isCheque ? Status::PENDING : Status::APPROVED;
            $statusId = Status::idOf(Status::CTX_TRANSACTION, $statusCode);

            $usable = $isCheque ? 0 : $data['amount'];
            $usablePending = $isCheque ? $data['amount'] : 0;

            $txn = Transaction::create([
                'transaction_number' => $this->numbers->next('transaction'),
                'customer_id' => $data['customer_id'],
                'branch_id' => $data['branch_id'] ?? null,
                'payment_method_id' => $method->id,
                'status_id' => $statusId,
                'amount' => $data['amount'],
                'used_amount' => 0,
                'usable_amount' => $usable,
                'usable_pending' => $usablePending,
                'reference' => $data['reference'] ?? null,
                'note' => $data['note'] ?? null,
                'taken_on' => $data['taken_on'] ?? now(),
                'cheque_number' => $data['cheque_number'] ?? null,
                'cheque_bank' => $data['cheque_bank'] ?? null,
                'cheque_branch_code' => $data['cheque_branch_code'] ?? null,
                'cheque_date' => $data['cheque_date'] ?? null,
            ]);

            AuditLog::record('transaction.created', $txn, [
                'amount' => (string) $data['amount'],
                'method' => $method->code,
                'status' => $statusCode,
            ]);

            return $txn;
        });
    }

    /**
     * Create a customer transaction and allocate it across the supplied invoices.
     * Allocation order is the order supplied by the caller; "select all" UIs send
     * invoices oldest first.
     *
     * @param  array{customer_id:int,branch_id?:int,payment_method_id:int,amount:float,reference?:string,note?:string,taken_on?:string,cheque_number?:string,cheque_bank?:string,cheque_branch_code?:string,cheque_date?:string}  $data
     * @param  array<int,array{invoice_id:int,amount:float}>  $allocations
     */
    public function createAllocatedTransaction(array $data, array $allocations = []): Transaction
    {
        $method = PaymentMethod::query()->find($data['payment_method_id'])
            ?? throw new InvalidArgumentException('Unknown payment method.');

        if (! $method->is_active) {
            throw new InvalidArgumentException("{$method->name} is not currently accepted.");
        }

        $this->assertRequiredFields($method, $data);

        return DB::transaction(function () use ($data, $allocations, $method) {
            $customer = Customer::query()->findOrFail($data['customer_id']);
            $amount = round((float) $data['amount'], 2);
            $isCheque = $method->code === PaymentMethod::CHEQUE;

            $transaction = Transaction::create([
                'transaction_number' => $this->numbers->next('transaction'),
                'customer_id' => $customer->id,
                'branch_id' => $data['branch_id'] ?? null,
                'payment_method_id' => $method->id,
                'status_id' => Status::idOf(
                    Status::CTX_TRANSACTION,
                    $isCheque ? Status::PENDING : Status::APPROVED,
                ),
                'amount' => $amount,
                'used_amount' => 0,
                'usable_amount' => $isCheque ? 0 : $amount,
                'usable_pending' => $isCheque ? $amount : 0,
                'reference' => $data['reference'] ?? null,
                'note' => $data['note'] ?? null,
                'taken_on' => $data['taken_on'] ?? now(),
                'cheque_number' => $data['cheque_number'] ?? null,
                'cheque_bank' => $data['cheque_bank'] ?? null,
                'cheque_branch_code' => $data['cheque_branch_code'] ?? null,
                'cheque_date' => $data['cheque_date'] ?? null,
            ]);

            $allocated = 0.0;
            $touchedInvoices = collect();
            $seenInvoices = [];

            foreach ($allocations as $allocation) {
                $invoiceId = (int) ($allocation['invoice_id'] ?? 0);
                if (isset($seenInvoices[$invoiceId])) {
                    throw new RuntimeException('The same invoice cannot be allocated more than once.');
                }
                $seenInvoices[$invoiceId] = true;
                $allocationAmount = round((float) $allocation['amount'], 2);

                if ($allocationAmount <= 0) {
                    throw new InvalidArgumentException('Invoice allocation amounts must be positive.');
                }

                if ($allocated + $allocationAmount > $amount + 0.001) {
                    throw new RuntimeException('Invoice allocations exceed the transaction amount.');
                }

                $invoice = Invoice::query()
                    ->whereKey($allocation['invoice_id'])
                    ->where('customer_id', $customer->id)
                    ->lockForUpdate()
                    ->first();

                if (! $invoice) {
                    throw new RuntimeException('Selected invoice does not belong to the transaction customer.');
                }

                if ((float) $invoice->balance <= 0.001) {
                    throw new RuntimeException("Invoice {$invoice->invoice_number} is already paid.");
                }

                if ($allocationAmount > (float) $invoice->balance + 0.001) {
                    throw new RuntimeException("Allocation exceeds the balance on invoice {$invoice->invoice_number}.");
                }

                TransactionClaim::create([
                    'claim_number' => $this->numbers->next('claim'),
                    'transaction_id' => $transaction->id,
                    'invoice_id' => $invoice->id,
                    'amount' => $allocationAmount,
                    'created_by' => Auth::id(),
                ]);

                $allocated += $allocationAmount;
                $touchedInvoices->push($invoice);
            }

            $leftover = round($amount - $allocated, 2);

            if ($isCheque) {
                $transaction->update([
                    'used_amount' => 0,
                    'usable_amount' => 0,
                    'usable_pending' => $leftover,
                ]);
            } else {
                if ($leftover > 0.001) {
                    $this->creditNotes->issueForOverpayment($customer->id, $leftover, $transaction->id);
                }

                $transaction->update([
                    'used_amount' => $allocated,
                    'usable_amount' => 0,
                    'usable_pending' => 0,
                    'status_id' => Status::idOf(Status::CTX_TRANSACTION, Status::UTILIZED),
                ]);
            }

            $touchedInvoices->each(fn (Invoice $invoice) => $this->recomputeInvoicePayment($invoice));

            AuditLog::record('transaction.allocated', $transaction, [
                'amount' => (string) $amount,
                'allocated' => (string) $allocated,
                'leftover_credit' => (string) $leftover,
            ]);

            return $transaction->refresh();
        });
    }

    /**
     * Settle (part of) an invoice with one or more payment splits.
     *
     * @param  array<int,array{method:string,amount:float,...}>  $splits
     * @return array{transactions:Transaction[], claims:TransactionClaim[], shortfall:float}
     */
    public function settleInvoice(Invoice $invoice, array $splits, bool $confirmShortfall = false): array
    {
        if ($invoice->payment_status_id === Status::idOf(Status::CTX_INVOICE_PAYMENT, Status::PAID)) {
            throw new RuntimeException('Invoice is already paid.');
        }

        return DB::transaction(function () use ($invoice, $splits) {
            $invoice->refresh()->load('items');

            $balanceBefore = (float) $invoice->balance;
            $totalSplit = collect($splits)->sum('amount');
            $shortfall = max(0, $balanceBefore - $totalSplit);

            if ($totalSplit > $balanceBefore + 0.001) {
                throw new RuntimeException(
                    'Payment of Rs. '.number_format($totalSplit, 2)
                    .' exceeds the outstanding balance of Rs. '.number_format($balanceBefore, 2)
                    .' on invoice '.$invoice->invoice_number.'. Overpayment is not allowed.'
                );
            }

            $createdTxns = [];
            $createdClaims = [];

            foreach ($splits as $split) {
                $method = PaymentMethod::findActiveByCode($split['method'])
                    ?? throw new InvalidArgumentException("Unknown or unavailable method: {$split['method']}");

                $this->assertRequiredFields($method, $split);

                if ($method->code === PaymentMethod::CREDIT) {
                    if (! empty($split['credit_note_id'])) {
                        $creditNote = CreditNote::query()->findOrFail($split['credit_note_id']);
                        $this->creditNotes->claim($creditNote, $invoice, (float) $split['amount']);
                    } else {
                        $this->credit->debit($invoice->customer, (float) $split['amount'], $invoice);
                    }

                    $txn = $this->createSettlementTransaction($invoice, $method, $split, isCheque: false);
                    $createdTxns[] = $txn;
                    $createdClaims[] = $this->createClaim($txn, $invoice, (float) $split['amount']);

                    continue;
                }

                $isCheque = $method->code === PaymentMethod::CHEQUE;
                $txn = $this->createSettlementTransaction($invoice, $method, $split, $isCheque);
                $createdTxns[] = $txn;
                $createdClaims[] = $this->createClaim($txn, $invoice, (float) $split['amount']);
            }

            $this->recomputeInvoicePayment($invoice);

            return ['transactions' => $createdTxns, 'claims' => $createdClaims, 'shortfall' => $shortfall];
        });
    }

    /**
     * Record the amount a customer had already settled on a legacy (pre-system)
     * invoice as an opening settlement: a utilized OTHER-method transaction
     * claimed against the invoice, backdated to the invoice date so it never
     * pollutes current-period collections. Keeps recomputeInvoicePayment() the
     * single source of truth for paid_amount/balance.
     */
    public function recordLegacyOpeningSettlement(Invoice $invoice, float $amount): Transaction
    {
        $method = PaymentMethod::findByCode(PaymentMethod::OTHER)
            ?? throw new RuntimeException('The "other" payment method lookup is missing.');

        $transaction = Transaction::create([
            'transaction_number' => $this->numbers->next('transaction'),
            'customer_id' => $invoice->customer_id,
            'branch_id' => $invoice->branch_id,
            'payment_method_id' => $method->id,
            'status_id' => Status::idOf(Status::CTX_TRANSACTION, Status::UTILIZED),
            'amount' => $amount,
            'used_amount' => $amount,
            'usable_amount' => 0,
            'usable_pending' => 0,
            'note' => 'Opening settlement — paid before the legacy invoice was entered into the system.',
            'taken_on' => $invoice->created_at,
        ]);

        // Backdate to the invoice date so period reports stay truthful.
        $transaction->created_at = $invoice->created_at;
        $transaction->updated_at = $invoice->created_at;
        $transaction->saveQuietly();

        $this->createClaim($transaction, $invoice, $amount);

        $this->recomputeInvoicePayment($invoice);

        return $transaction;
    }

    /**
     * Approve a pending cheque (or bank transfer awaiting clearance).
     */
    public function approveTransaction(Transaction $transaction): Transaction
    {
        return DB::transaction(function () use ($transaction) {
            $transaction->refresh();

            if ($transaction->status->code !== Status::PENDING) {
                throw new RuntimeException('Transaction is not pending.');
            }

            $transaction->update([
                'status_id' => Status::idOf(Status::CTX_TRANSACTION, Status::APPROVED),
                'usable_amount' => $transaction->amount,
                'usable_pending' => 0,
                'used_amount' => $transaction->claims()->sum('amount'),
            ]);

            $claimedAmount = (float) $transaction->claims()->sum('amount');
            $leftover = round((float) $transaction->amount - $claimedAmount, 2);

            if ($leftover > 0.001 && $transaction->customer_id) {
                $this->creditNotes->issueForOverpayment((int) $transaction->customer_id, $leftover, $transaction->id);
                $transaction->update(['usable_amount' => 0]);
            }

            // If fully claimed or converted to credit, mark utilized.
            if ($claimedAmount + max(0, $leftover) >= (float) $transaction->amount - 0.001) {
                $transaction->update([
                    'status_id' => Status::idOf(Status::CTX_TRANSACTION, Status::UTILIZED),
                ]);
            }

            // Re-evaluate downstream invoices
            foreach ($transaction->claims as $claim) {
                if ($claim->invoice) {
                    $this->recomputeInvoicePayment($claim->invoice);
                }
            }

            AuditLog::record('transaction.approved', $transaction);

            return $transaction;
        });
    }

    /**
     * Reject a cheque (or bank transfer) that bounced or was declined — from
     * pending, OR from approved/utilized when the bank returns it after it was
     * already confirmed. Leaves the claims in place for history but excludes
     * them from invoice balances, since recomputeInvoicePayment() only counts
     * approved/utilized transactions.
     */
    public function rejectTransaction(Transaction $transaction, ?string $reason = null): Transaction
    {
        return DB::transaction(function () use ($transaction, $reason) {
            $transaction->refresh();

            if (! in_array($transaction->status->code, [Status::PENDING, Status::APPROVED, Status::UTILIZED], true)) {
                throw new RuntimeException('Only pending or confirmed transactions can be rejected.');
            }

            $this->assertNoOverpaymentCreditNote($transaction, 'rejected');

            $transaction->update([
                'status_id' => Status::idOf(Status::CTX_TRANSACTION, Status::REJECTED),
                'used_amount' => 0,
                'usable_amount' => 0,
                'usable_pending' => 0,
            ]);

            foreach ($transaction->claims as $claim) {
                if ($claim->invoice) {
                    $this->recomputeInvoicePayment($claim->invoice);
                }
            }

            AuditLog::record('transaction.rejected', $transaction, [
                'reason' => $reason,
            ]);

            return $transaction;
        });
    }

    /**
     * Undo a confirmation (or a rejection) and put the transaction back to
     * pending, so a mistaken tick can be corrected. Invoices it was counted
     * against are recomputed without it.
     */
    public function revertTransactionToPending(Transaction $transaction): Transaction
    {
        return DB::transaction(function () use ($transaction) {
            $transaction->refresh();

            if (! in_array($transaction->status->code, [Status::APPROVED, Status::UTILIZED, Status::REJECTED], true)) {
                throw new RuntimeException('Only confirmed or rejected transactions can be reverted to pending.');
            }

            $this->assertNoOverpaymentCreditNote($transaction, 'reverted');

            $claimed = (float) $transaction->claims()->sum('amount');

            $transaction->update([
                'status_id' => Status::idOf(Status::CTX_TRANSACTION, Status::PENDING),
                'used_amount' => 0,
                'usable_amount' => 0,
                'usable_pending' => max(0, round((float) $transaction->amount - $claimed, 2)),
            ]);

            foreach ($transaction->claims as $claim) {
                if ($claim->invoice) {
                    $this->recomputeInvoicePayment($claim->invoice);
                }
            }

            AuditLog::record('transaction.reverted_to_pending', $transaction);

            return $transaction;
        });
    }

    /**
     * Confirming a cheque with an unallocated remainder auto-issues a credit
     * note (see approveTransaction). That credit may already be spent, so
     * un-confirming such a transaction must be an explicit two-step: void or
     * settle the credit note first, then reject/revert the cheque.
     */
    private function assertNoOverpaymentCreditNote(Transaction $transaction, string $action): void
    {
        $creditNote = CreditNote::query()
            ->where('note', 'like', "%overpayment on transaction #{$transaction->id}")
            ->whereHas('status', fn ($q) => $q->where('code', '!=', Status::VOID))
            ->first();

        if ($creditNote) {
            throw new RuntimeException(
                "This transaction issued credit note {$creditNote->cn_number} for its unallocated amount when it was confirmed. "
                ."Void that credit note first, then it can be {$action}.",
            );
        }
    }

    /**
     * Server-side backstop for {@see PaymentMethod::validateRequiredFields()} —
     * every payment-creation entry point (POS, invoice settlement/add-payment,
     * standalone/allocated transactions) funnels through this service, so
     * enforcing here catches a tampered or stale client regardless of which
     * form submitted it.
     *
     * @param  array<string,mixed>  $data
     */
    private function assertRequiredFields(PaymentMethod $method, array $data): void
    {
        if ($error = PaymentMethod::validateRequiredFields($method->code, $data)) {
            throw new RuntimeException($error);
        }
    }

    /**
     * @param  array<string,mixed>  $split
     */
    private function createSettlementTransaction(Invoice $invoice, PaymentMethod $method, array $split, bool $isCheque): Transaction
    {
        $amount = round((float) $split['amount'], 2);

        return Transaction::create([
            'transaction_number' => $this->numbers->next('transaction'),
            'customer_id' => $invoice->customer_id,
            'branch_id' => $invoice->branch_id,
            'payment_method_id' => $method->id,
            'status_id' => Status::idOf(
                Status::CTX_TRANSACTION,
                $isCheque ? Status::PENDING : Status::UTILIZED,
            ),
            'amount' => $amount,
            'used_amount' => $isCheque ? 0 : $amount,
            'usable_amount' => 0,
            'usable_pending' => $isCheque ? $amount : 0,
            'reference' => $split['reference'] ?? null,
            'note' => $split['note'] ?? null,
            'taken_on' => now(),
            'cheque_number' => $split['cheque_number'] ?? null,
            'cheque_bank' => $split['cheque_bank'] ?? null,
            'cheque_branch_code' => $split['cheque_branch_code'] ?? null,
            'cheque_date' => $split['cheque_date'] ?? null,
            'custom_fields' => $split['custom_fields'] ?? null,
        ]);
    }

    private function createClaim(Transaction $transaction, Invoice $invoice, float $amount): TransactionClaim
    {
        return TransactionClaim::create([
            'claim_number' => $this->numbers->next('claim'),
            'transaction_id' => $transaction->id,
            'invoice_id' => $invoice->id,
            'amount' => $amount,
            'created_by' => Auth::id(),
        ]);
    }

    /**
     * Re-derive paid_amount, balance, payment_status_id on the invoice from
     * the sum of approved/utilized claims.
     */
    public function recomputeInvoicePayment(Invoice $invoice): void
    {
        $invoice->refresh();

        $approvedSum = TransactionClaim::query()
            ->where('invoice_id', $invoice->id)
            ->whereHas('transaction', function ($q) {
                $q->whereIn('status_id', [
                    Status::idOf(Status::CTX_TRANSACTION, Status::APPROVED),
                    Status::idOf(Status::CTX_TRANSACTION, Status::UTILIZED),
                ]);
            })
            ->sum('amount');

        $netTotal = (float) $invoice->net_total;
        $paid = (float) $approvedSum;
        $balance = max(0, $netTotal - $paid);

        $paymentStatusCode = match (true) {
            $paid <= 0.001 => Status::UNPAID,
            $paid >= $netTotal - 0.001 => Status::PAID,
            default => Status::PARTIAL,
        };

        $invoice->update([
            'paid_amount' => $paid,
            'balance' => $balance,
            'payment_status_id' => Status::idOf(Status::CTX_INVOICE_PAYMENT, $paymentStatusCode),
        ]);
    }
}
