<?php

namespace App\Domain\Financial\Services;

use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\PaymentProof;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Models\User;
use Illuminate\Support\Facades\DB;

/**
 * The lifecycle of a claimed transfer, from a member's screenshot to a real
 * payment in the ledger.
 *
 * Only approval creates money. A submitted proof is a request; the Payment and
 * its double-entry rows come into existence exactly once, at the moment a
 * member of staff says the transfer is on the academy's statement.
 */
class PaymentProofService
{
    /** Above this, one reviewer is not enough. */
    public const MAKER_CHECKER_THRESHOLD = 500_000; // 5,000.00 EGP in piasters

    public function __construct(
        private readonly PaymentService $payments,
        private readonly WalletService $wallets,
    ) {}

    /**
     * A member claims they transferred money against an invoice.
     */
    public function submit(array $data, User $submitter): PaymentProof
    {
        return DB::transaction(function () use ($data, $submitter) {
            $invoice = Invoice::withoutGlobalScopes()
                ->where('id', $data['invoice_id'])
                ->lockForUpdate()
                ->firstOrFail();

            // Nothing today stops a payment landing on a cancelled invoice, and
            // such payments count in getRevenue() while ParticipantBillingService
            // excludes them — so the two stop reconciling and the gap is silent.
            if (in_array($invoice->status, [InvoiceStatus::Cancelled, InvoiceStatus::Paid, InvoiceStatus::WrittenOff], true)) {
                throw new DomainException('هذه الفاتورة ' . $invoice->status->label() . ' ولا تقبل إثبات تحويل');
            }

            $amount = (int) ($data['amount_claimed'] ?? 0);

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

            // The branch is the invoice's, never the request's. A proof filed
            // with one branch against another branch's invoice would sit in the
            // wrong review queue and, on approval, hand that branch's books the
            // money — and the portal route is deliberately unscoped, so nothing
            // downstream would catch it either.
            $branchId = (int) ($invoice->branch_id ?: $this->resolveBillableBranchId($invoice));

            if (! $branchId) {
                throw new DomainException('لا يمكن تسجيل التحويل بدون فرع');
            }

            $reference = $this->normalizeReference($data['sender_reference'] ?? null);

            if ($reference === null) {
                throw new DomainException('رقم عملية التحويل مطلوب');
            }

            $this->assertReferenceIsUnused($invoice->academy_id, $data['method'] ?? 'instapay', $reference);

            if (PaymentProof::withoutGlobalScopes()
                ->where('invoice_id', $invoice->id)
                ->whereIn('status', ['pending', 'under_review'])
                ->exists()) {
                throw new DomainException('يوجد إثبات تحويل قيد المراجعة على هذه الفاتورة بالفعل');
            }

            return PaymentProof::create([
                'academy_id' => $invoice->academy_id,
                'branch_id' => $branchId,
                'invoice_id' => $invoice->id,
                'participant_id' => $invoice->billable_type === Participant::class ? $invoice->billable_id : null,
                'submitted_by' => $submitter->id,
                'amount_claimed' => $amount,
                'method' => $data['method'] ?? 'instapay',
                'sender_reference' => $reference,
                'sender_phone' => $data['sender_phone'] ?? null,
                'transferred_at' => $data['transferred_at'] ?? null,
                'proof_path' => $data['proof_path'],
                'proof_mime' => $data['proof_mime'] ?? null,
                'proof_size' => $data['proof_size'] ?? null,
                'status' => 'pending',
            ]);
        });
    }

    /**
     * Approve a proof and turn it into a real payment.
     *
     * The concurrency control is the conditional UPDATE, not a disabled button:
     * two reviewers opening the queue at the same moment both see an enabled
     * Approve, and the second one's UPDATE matches zero rows.
     *
     * @param  int  $verifiedAmount  What the reviewer read off the statement,
     *                               never what the payer typed.
     */
    public function approve(PaymentProof $proof, int $verifiedAmount, User $reviewer, ?string $notes = null, ?string $ip = null): Payment
    {
        if ($verifiedAmount <= 0) {
            throw new DomainException('المبلغ المعتمد يجب أن يكون أكبر من صفر');
        }

        // Taking money is not something one person should be able to do to
        // themselves. Above the threshold the submitter and the reviewer must
        // differ even if the same person holds both permissions.
        if ((int) $proof->submitted_by === (int) $reviewer->id) {
            throw new DomainException('لا يمكن اعتماد إثبات قمت بتقديمه بنفسك');
        }

        return DB::transaction(function () use ($proof, $verifiedAmount, $reviewer, $notes, $ip) {
            $claimed = PaymentProof::withoutGlobalScopes()
                ->where('id', $proof->id)
                ->whereIn('status', ['pending', 'under_review'])
                ->update([
                    'status' => 'under_review',
                    'reviewed_by' => $reviewer->id,
                    'reviewed_at' => now(),
                    'review_notes' => $notes,
                    'review_ip' => $ip,
                    'updated_at' => now(),
                ]);

            if ($claimed === 0) {
                throw new InvalidStatusTransitionException('راجع زميل آخر هذا الإثبات بالفعل');
            }

            $fresh = PaymentProof::withoutGlobalScopes()->findOrFail($proof->id);

            $invoice = Invoice::withoutGlobalScopes()
                ->where('id', $fresh->invoice_id)
                ->lockForUpdate()
                ->firstOrFail();

            $due = (int) $invoice->due_amount;

            if ($due <= 0) {
                throw new DomainException('لم يعد على هذه الفاتورة أي مستحق');
            }

            // Cap at what is owed and put the excess in the member's wallet.
            // InvoiceStatus::Overpaid exists but nothing consumes it, and it
            // drives due_amount negative — after which getCollectionRate() and
            // ParticipantBillingService start summing negative numbers.
            $applied = min($verifiedAmount, $due);
            $excess = $verifiedAmount - $applied;

            $payment = $this->payments->recordPayment([
                'academy_id' => $fresh->academy_id,
                // The invoice decides where the money lands, not the proof row:
                // proofs filed before submit() started deriving the branch can
                // carry the submitter's branch instead of the invoice's, and
                // approving one must not book the payment in the wrong branch.
                'branch_id' => $invoice->branch_id ?: $fresh->branch_id,
                'invoice_id' => $invoice->id,
                'amount' => $applied,
                'method' => $fresh->method,
                'direction' => 'inbound',
                'currency' => $invoice->currency ?: 'EGP',
                'payment_date' => now()->toDateString(),
                'notes' => 'إثبات تحويل #' . $fresh->uuid,
                'metadata' => [
                    'source' => 'portal_proof',
                    'proof_uuid' => $fresh->uuid,
                    'sender_reference' => $fresh->sender_reference,
                ],
            ], $reviewer);

            if ($excess > 0 && $fresh->participant_id) {
                $participant = Participant::withoutGlobalScopes()->find($fresh->participant_id);

                if ($participant) {
                    $wallet = $this->wallets->getOrCreateWallet($participant, $fresh->academy_id);

                    $this->wallets->deposit(
                        $wallet,
                        $excess,
                        'رصيد زائد من إثبات تحويل #' . $fresh->uuid,
                        $payment,
                        $reviewer
                    );
                }
            }

            PaymentProof::withoutGlobalScopes()
                ->where('id', $fresh->id)
                ->update([
                    'status' => 'approved',
                    'amount_approved' => $verifiedAmount,
                    'payment_id' => $payment->id,
                    'updated_at' => now(),
                ]);

            return $payment;
        });
    }

    public function reject(PaymentProof $proof, string $reason, User $reviewer, ?string $notes = null, ?string $ip = null): void
    {
        $rejected = PaymentProof::withoutGlobalScopes()
            ->where('id', $proof->id)
            ->whereIn('status', ['pending', 'under_review'])
            ->update([
                'status' => 'rejected',
                'rejection_reason' => $reason,
                'reviewed_by' => $reviewer->id,
                'reviewed_at' => now(),
                'review_notes' => $notes,
                'review_ip' => $ip,
                'updated_at' => now(),
            ]);

        if ($rejected === 0) {
            throw new InvalidStatusTransitionException('راجع زميل آخر هذا الإثبات بالفعل');
        }
    }

    /**
     * Reversing an approval made in error.
     *
     * Through RefundService, always. A "negative proof" would leave the
     * original payment confirmed, so the participant would read as having paid
     * twice while the money went out once.
     */
    public function reversalIsARefund(): string
    {
        return 'استخدم شاشة الاسترداد — لا يُلغى إثبات معتمد بإنشاء إثبات سالب';
    }

    /**
     * Whether this approval needs a second reviewer before the money moves.
     *
     * The facts live on the proof row rather than in audit logs on purpose:
     * Auditable::createAuditLog() takes the user from auth() at boot and
     * silently writes nothing when it cannot resolve an academy, so an audit
     * trail is not a safe place to keep who approved what.
     */
    public function needsSecondReviewer(PaymentProof $proof): bool
    {
        return (int) $proof->amount_claimed >= self::MAKER_CHECKER_THRESHOLD;
    }

    public function countersign(PaymentProof $proof, User $reviewer): void
    {
        if ((int) $proof->reviewed_by === (int) $reviewer->id) {
            throw new DomainException('التوقيع الثاني يجب أن يكون من شخص آخر');
        }

        PaymentProof::withoutGlobalScopes()
            ->where('id', $proof->id)
            ->whereNull('second_reviewed_by')
            ->update([
                'second_reviewed_by' => $reviewer->id,
                'second_reviewed_at' => now(),
                'updated_at' => now(),
            ]);
    }

    /**
     * The branch of the member an invoice was raised for.
     *
     * Only used when the invoice itself carries no branch — rows written before
     * the backfill, or from a queue. Read without the branch scope because we
     * are asking which branch the member belongs to, and the answer cannot
     * depend on who is looking.
     */
    private function resolveBillableBranchId(Invoice $invoice): ?int
    {
        if ($invoice->billable_type !== Participant::class || ! $invoice->billable_id) {
            return null;
        }

        return Participant::withoutGlobalScopes()->find($invoice->billable_id)?->branch_id;
    }

    /** Trim and upper-case, so 'ab-12 ' and 'AB-12' cannot both be submitted. */
    private function normalizeReference(?string $reference): ?string
    {
        $reference = strtoupper(trim((string) $reference));

        return $reference === '' ? null : substr($reference, 0, 64);
    }

    private function assertReferenceIsUnused(int $academyId, string $method, string $reference): void
    {
        $used = PaymentProof::withoutGlobalScopes()
            ->where('academy_id', $academyId)
            ->where('method', $method)
            ->where('sender_reference', $reference)
            ->where('status', '!=', 'rejected')
            ->exists();

        if ($used) {
            throw new DomainException('رقم العملية هذا مُستخدم بالفعل في إثبات آخر');
        }
    }
}
