<?php

namespace App\Domain\Refunds\Services;

use App\Domain\Accounting\Services\BusinessEventPoster;
use App\Domain\Applicants\Models\Applicant;
use App\Domain\Audit\Enums\ActivityCategory;
use App\Domain\Audit\Services\ActivityLogger;
use App\Domain\Billing\Models\Invoice;
use App\Domain\Identity\Models\User;
use App\Domain\Organization\Models\Branch;
use App\Domain\Payments\Enums\PaymentStatus;
use App\Domain\Payments\Models\Payment;
use App\Domain\Refunds\Enums\RefundStatus;
use App\Domain\Refunds\Models\Refund;
use App\Support\Money\Money;
use App\Support\Sequences\SequenceGenerator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

/**
 * Refunds (§31).
 *
 * An agency can only give back money it actually took, and only once. Every
 * limit here is checked against confirmed payments rather than against what a
 * form claims, and a paid refund is never edited afterwards (§70).
 */
class RefundService
{
    /** Statuses that have money spoken for, whether or not it has left yet. */
    private const COMMITTED_STATUSES = ['requested', 'reviewed', 'approved', 'processing', 'paid'];

    /** A paid refund commits what was disbursed; an open one, what was asked. */
    private const COMMITTED_AMOUNT = "CASE WHEN status = 'paid' THEN paid_amount ELSE requested_amount END";

    public function __construct(
        private readonly SequenceGenerator $sequences,
        private readonly ActivityLogger $activity,
        private readonly BusinessEventPoster $books,
    ) {}

    /**
     * @param  array<string, mixed>  $attributes
     *
     * @throws ValidationException
     */
    public function request(
        Applicant $applicant,
        Money $amount,
        string $reason,
        User $actor,
        array $attributes = [],
    ): Refund {
        if ($amount->isZero()) {
            throw ValidationException::withMessages(['requested_amount' => ['A refund must be more than zero.']]);
        }

        if (blank($reason)) {
            throw ValidationException::withMessages(['reason' => ['Say why this money is being returned.']]);
        }

        $payment = $this->resolvePayment($applicant, $attributes['payment_id'] ?? null);
        $invoice = $payment?->invoice ?? $this->resolveInvoice($applicant, $attributes['invoice_id'] ?? null);

        $ceiling = $payment !== null
            ? $this->refundableOnPayment($payment)
            : $this->refundableForApplicant($applicant, $amount->currency);

        if ($amount->isGreaterThan($ceiling)) {
            throw ValidationException::withMessages([
                'requested_amount' => [
                    $payment !== null
                        ? "Only {$ceiling->format()} of that receipt is still refundable."
                        : "Only {$ceiling->format()} has been collected and not yet refunded.",
                ],
            ]);
        }

        return DB::transaction(function () use ($applicant, $amount, $reason, $actor, $attributes, $payment, $invoice) {
            $refund = Refund::create([
                'refund_no' => $this->nextNumber($applicant),
                'company_id' => $applicant->company_id,
                'branch_id' => $actor->branch_id ?? $applicant->branch_id,
                'applicant_id' => $applicant->getKey(),
                'invoice_id' => $invoice?->getKey(),
                'payment_id' => $payment?->getKey(),
                'status' => RefundStatus::Requested->value,
                'currency' => $amount->currency,
                'requested_amount' => $amount->minorUnits,
                'reason_code' => $attributes['reason_code'] ?? null,
                'reason' => $reason,
                'requested_by' => $actor->getKey(),
                'requested_at' => now(),
                'beneficiary_name' => $attributes['beneficiary_name'] ?? null,
                'beneficiary_account' => $attributes['beneficiary_account'] ?? null,
                'notes' => $attributes['notes'] ?? null,
            ]);

            $this->activity->log(ActivityCategory::Payment, 'refund.requested', [
                'subject' => $applicant,
                'actor' => $actor,
                'title' => 'Refund requested',
                'description' => $amount->format().' · '.$reason,
                'visibility' => 'shared',
            ]);

            return $refund;
        });
    }

    /**
     * A reviewer checks the claim and proposes what the agency should keep.
     *
     * @throws ValidationException
     */
    public function review(Refund $refund, User $actor, ?int $deduction = null, ?string $note = null): Refund
    {
        if ($refund->status !== RefundStatus::Requested) {
            throw ValidationException::withMessages([
                'refund' => ["A {$refund->status->label()} refund cannot be reviewed."],
            ]);
        }

        if ($deduction !== null && $deduction > $refund->requested_amount) {
            throw ValidationException::withMessages([
                'deduction_amount' => ['The deduction cannot exceed the amount requested.'],
            ]);
        }

        return DB::transaction(function () use ($refund, $actor, $deduction, $note) {
            $refund->status = RefundStatus::Reviewed;
            $refund->reviewed_by = $actor->getKey();
            $refund->reviewed_at = now();
            $refund->review_note = $note;

            if ($deduction !== null) {
                $refund->deduction_amount = $deduction;
            }

            $refund->save();

            $this->activity->log(ActivityCategory::Payment, 'refund.reviewed', [
                'subject' => $refund->applicant,
                'actor' => $actor,
                'title' => 'Refund reviewed',
                'description' => $note ?? $refund->requested()->format(),
                'visibility' => 'internal',
            ]);

            return $refund;
        });
    }

    /**
     * @param  array<string, mixed>  $attributes
     *
     * @throws ValidationException
     */
    public function approve(Refund $refund, User $actor, array $attributes = []): Refund
    {
        if (! in_array($refund->status, [RefundStatus::Requested, RefundStatus::Reviewed], true)) {
            throw ValidationException::withMessages([
                'refund' => ["A {$refund->status->label()} refund cannot be approved."],
            ]);
        }

        // Approving your own request would make the review meaningless.
        if ($refund->requested_by === $actor->getKey()) {
            throw ValidationException::withMessages([
                'refund' => ['A refund must be approved by someone other than whoever requested it.'],
            ]);
        }

        $approved = (int) ($attributes['approved_amount'] ?? $refund->requested_amount);
        $deduction = (int) ($attributes['deduction_amount'] ?? $refund->deduction_amount);

        if ($approved <= 0) {
            throw ValidationException::withMessages([
                'approved_amount' => ['Approve an amount, or reject the request instead.'],
            ]);
        }

        if ($approved > $refund->requested_amount) {
            throw ValidationException::withMessages([
                'approved_amount' => ['You cannot approve more than was requested.'],
            ]);
        }

        if ($deduction > $approved) {
            throw ValidationException::withMessages([
                'deduction_amount' => ['The deduction cannot exceed the approved amount.'],
            ]);
        }

        // The ceiling is rechecked here: money may have been refunded in the
        // time between the request and this approval.
        $ceiling = $refund->payment !== null
            ? $this->refundableOnPayment($refund->payment, excluding: $refund)
            : $this->refundableForApplicant($refund->applicant, $refund->currency, excluding: $refund);

        $payable = Money::of($approved - $deduction, $refund->currency);

        if ($payable->isGreaterThan($ceiling)) {
            throw ValidationException::withMessages([
                'approved_amount' => ["Only {$ceiling->format()} is still refundable."],
            ]);
        }

        $limit = (int) config('mams.refunds.approval_limit');

        if ($limit > 0 && $payable->minorUnits > $limit && ! $actor->can('refund.approve')) {
            throw ValidationException::withMessages([
                'approved_amount' => [
                    'A refund above '.Money::of($limit, $refund->currency)->format().' needs a higher approval.',
                ],
            ]);
        }

        return DB::transaction(function () use ($refund, $actor, $approved, $deduction, $attributes, $payable) {
            $refund->status = RefundStatus::Approved;
            $refund->approved_amount = $approved;
            $refund->deduction_amount = $deduction;
            $refund->deduction_reason = $attributes['deduction_reason'] ?? $refund->deduction_reason;
            $refund->approved_by = $actor->getKey();
            $refund->approved_at = now();
            $refund->save();

            $this->activity->log(ActivityCategory::Payment, 'refund.approved', [
                'subject' => $refund->applicant,
                'actor' => $actor,
                'title' => 'Refund approved',
                'description' => $payable->format().' payable'.
                    ($deduction > 0 ? ' after '.Money::of($deduction, $refund->currency)->format().' deducted' : ''),
                'visibility' => 'shared',
            ]);

            return $refund;
        });
    }

    /** @throws ValidationException */
    public function reject(Refund $refund, string $reason, User $actor): Refund
    {
        if ($refund->status->isClosed()) {
            throw ValidationException::withMessages([
                'refund' => ["A {$refund->status->label()} refund cannot be rejected."],
            ]);
        }

        if (blank($reason)) {
            throw ValidationException::withMessages(['reason' => ['Say why the refund was refused.']]);
        }

        return DB::transaction(function () use ($refund, $reason, $actor) {
            $refund->status = RefundStatus::Rejected;
            $refund->rejection_reason = $reason;
            $refund->save();

            $this->activity->log(ActivityCategory::Payment, 'refund.rejected', [
                'subject' => $refund->applicant,
                'actor' => $actor,
                'title' => 'Refund rejected',
                'description' => $reason,
                'visibility' => 'shared',
            ]);

            return $refund;
        });
    }

    /** @throws ValidationException */
    public function cancel(Refund $refund, string $reason, User $actor): Refund
    {
        if ($refund->status->isClosed()) {
            throw ValidationException::withMessages([
                'refund' => ["A {$refund->status->label()} refund cannot be cancelled."],
            ]);
        }

        return DB::transaction(function () use ($refund, $reason, $actor) {
            $refund->status = RefundStatus::Cancelled;
            $refund->rejection_reason = $reason;
            $refund->save();

            $this->activity->log(ActivityCategory::Payment, 'refund.cancelled', [
                'subject' => $refund->applicant,
                'actor' => $actor,
                'title' => 'Refund cancelled',
                'description' => $reason,
                'visibility' => 'internal',
            ]);

            return $refund;
        });
    }

    /**
     * Accounts disburses the approved amount (§31).
     *
     * @param  array<string, mixed>  $attributes
     *
     * @throws ValidationException
     */
    public function pay(Refund $refund, User $actor, array $attributes): Refund
    {
        if (! in_array($refund->status, [RefundStatus::Approved, RefundStatus::Processing], true)) {
            throw ValidationException::withMessages([
                'refund' => ["A {$refund->status->label()} refund cannot be paid out."],
            ]);
        }

        $payable = $refund->payable();

        if ($payable->isZero()) {
            throw ValidationException::withMessages([
                'refund' => ['The deduction leaves nothing to pay out.'],
            ]);
        }

        if (blank($attributes['payout_method'] ?? null)) {
            throw ValidationException::withMessages([
                'payout_method' => ['Record how the money was returned.'],
            ]);
        }

        if (blank($attributes['beneficiary_name'] ?? $refund->beneficiary_name)) {
            throw ValidationException::withMessages([
                'beneficiary_name' => ['Record who received the money.'],
            ]);
        }

        $reference = $attributes['transaction_reference'] ?? null;

        // Same rule as collection: a reference is one real movement of money.
        if (filled($reference) && Refund::where('company_id', $refund->company_id)
            ->where('transaction_reference', $reference)
            ->whereKeyNot($refund->getKey())
            ->exists()) {
            throw ValidationException::withMessages([
                'transaction_reference' => ['This reference has already been recorded.'],
            ]);
        }

        return DB::transaction(function () use ($refund, $actor, $attributes, $payable, $reference) {
            $refund->status = RefundStatus::Paid;
            $refund->paid_amount = $payable->minorUnits;
            $refund->payout_method = $attributes['payout_method'];
            $refund->beneficiary_name = $attributes['beneficiary_name'] ?? $refund->beneficiary_name;
            $refund->beneficiary_account = $attributes['beneficiary_account'] ?? $refund->beneficiary_account;
            $refund->transaction_reference = $reference;
            $refund->paid_on = $attributes['paid_on'] ?? now()->toDateString();
            $refund->paid_by = $actor->getKey();
            $refund->save();

            if ($refund->invoice_id !== null) {
                $invoice = Invoice::whereKey($refund->invoice_id)->lockForUpdate()->first();
                $invoice->refunded_amount = $invoice->refunded_amount + $payable->minorUnits;
                $invoice->save();
            }

            $this->books->refundPaid($refund, $actor);

            $this->activity->log(ActivityCategory::Payment, 'refund.paid', [
                'subject' => $refund->applicant,
                'actor' => $actor,
                'title' => 'Refund paid',
                'description' => $payable->format()." · {$refund->refund_no}",
                'visibility' => 'shared',
            ]);

            return $refund;
        });
    }

    /**
     * What is still refundable against one receipt: its amount, less every
     * refund already paid or in flight against it.
     */
    private function refundableOnPayment(Payment $payment, ?Refund $excluding = null): Money
    {
        if ($payment->status !== PaymentStatus::Confirmed) {
            throw ValidationException::withMessages([
                'payment_id' => ['Only a confirmed payment can be refunded.'],
            ]);
        }

        $committed = (int) Refund::where('payment_id', $payment->getKey())
            ->when($excluding, fn ($query) => $query->whereKeyNot($excluding->getKey()))
            ->whereIn('status', self::COMMITTED_STATUSES)
            ->sum(DB::raw(self::COMMITTED_AMOUNT));

        return Money::of(max(0, $payment->amount - $committed), $payment->currency);
    }

    /**
     * What is refundable across the whole file: every confirmed payment in this
     * currency, less refunds already paid or in flight.
     */
    private function refundableForApplicant(Applicant $applicant, string $currency, ?Refund $excluding = null): Money
    {
        // A reversed payment leaves the confirmed set on its own, and the
        // reversal record itself is not money the agency is holding.
        $collected = (int) Payment::where('applicant_id', $applicant->getKey())
            ->where('currency', $currency)
            ->where('status', PaymentStatus::Confirmed->value)
            ->whereNull('reverses_payment_id')
            ->sum('amount');

        $committed = (int) Refund::where('applicant_id', $applicant->getKey())
            ->where('currency', $currency)
            ->when($excluding, fn ($query) => $query->whereKeyNot($excluding->getKey()))
            ->whereIn('status', self::COMMITTED_STATUSES)
            ->sum(DB::raw(self::COMMITTED_AMOUNT));

        return Money::of(max(0, $collected - $committed), $currency);
    }

    private function resolvePayment(Applicant $applicant, ?string $uuid): ?Payment
    {
        if ($uuid === null) {
            return null;
        }

        $payment = Payment::with('invoice')
            ->where('uuid', $uuid)
            ->where('applicant_id', $applicant->getKey())
            ->first();

        if ($payment === null) {
            throw ValidationException::withMessages([
                'payment_id' => ['That receipt does not belong to this applicant.'],
            ]);
        }

        return $payment;
    }

    private function resolveInvoice(Applicant $applicant, ?string $uuid): ?Invoice
    {
        if ($uuid === null) {
            return null;
        }

        $invoice = Invoice::where('uuid', $uuid)
            ->where('applicant_id', $applicant->getKey())
            ->first();

        if ($invoice === null) {
            throw ValidationException::withMessages([
                'invoice_id' => ['That invoice does not belong to this applicant.'],
            ]);
        }

        return $invoice;
    }

    private function nextNumber(Applicant $applicant): string
    {
        $branchCode = $applicant->branch_id !== null
            ? (Branch::whereKey($applicant->branch_id)->value('code') ?? 'HO')
            : 'HO';

        return $this->sequences->nextUnique(
            'refund',
            'REF',
            [$branchCode, now()->year],
            6,
            fn (string $no) => Refund::where('company_id', $applicant->company_id)
                ->where('refund_no', $no)
                ->exists(),
        );
    }
}
