<?php

/**
 * Invoice Ninja (https://invoiceninja.com).
 *
 * @link https://github.com/invoiceninja/invoiceninja source repository
 *
 * @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
 *
 * @license https://www.elastic.co/licensing/elastic-license
 */

namespace App\Services\Payment;

use stdClass;
use App\Utils\Ninja;
use App\Models\Credit;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Paymentable;
use App\Models\Activity;
use App\Exceptions\PaymentRefundFailed;
use App\Jobs\Payment\EmailRefundPayment;
use App\Repositories\ActivityRepository;
use App\Services\EDocument\Standards\France\FrancePaymentApplicationRecorder;
use App\Listeners\Payment\PaymentTransactionEventEntry;

class RefundPayment
{
    private float $total_refund = 0;

    private float $credits_used = 0;

    private bool $refund_failed = false;

    private string $refund_failed_message = '';

    public function __construct(public Payment $payment, public array $refund_data) {}

    public function run()
    {
        $this->payment = $this
                            ->calculateTotalRefund() //sets amount for the refund (needed if we are refunding multiple invoices in one payment)
                            ->updateCreditables() //return the credits first
                            ->processGatewayRefund() //process the gateway refund if needed
                            ->setStatus() //sets status of payment
                            ->updatePaymentables() //update the paymentable items
                            ->adjustInvoices()
                            ->save();

        if (array_key_exists('email_receipt', $this->refund_data) && $this->refund_data['email_receipt'] == 'true') {

            $payment_email_all_contacts = $this->payment->client->getSetting('payment_email_all_contacts');

            $this->payment
                ->client
                ->contacts()
                ->where('send_email', true)
                ->whereNotNull('email')
                ->when(!$payment_email_all_contacts, function ($query) {
                    return $query->orderBy('id', 'asc')->take(1);
                })
                ->cursor()
                ->each(function ($contact) {

                    EmailRefundPayment::dispatch($this->payment, $this->payment->company, $contact);

                });
        }

        $is_gateway_refund = ($this->refund_data['gateway_refund'] !== false || $this->refund_failed || (isset($this->refund_data['via_webhook']) && $this->refund_data['via_webhook'] !== false)) ? ctrans('texts.yes') : ctrans('texts.no');
        $notes = ctrans('texts.refunded') . " : {$this->total_refund} - " . ctrans('texts.gateway_refund') . " : " . $is_gateway_refund;

        $this->createActivity($notes);
        $this->finalize();

        return $this->payment;
    }

    private function finalize(): self
    {
        if ($this->refund_failed) {
            throw new PaymentRefundFailed($this->refund_failed_message);
        }

        return $this;
    }

    /**
     * Process the refund through the gateway.
     *
     * $response
     * [
     *  'transaction_reference' => (string),
     *  'transaction_response' => (string),
     *  'success' => (bool),
     *  'description' => (string),
     *  'code' => (string),
     *  'payment_id' => (int),
     *  'amount' => (float),
     * ];
     *
     * @return $this
     * @throws PaymentRefundFailed
     */
    private function processGatewayRefund()
    {
        $net_refund = ($this->total_refund - $this->credits_used);

        if ($this->refund_data['gateway_refund'] !== false && $net_refund > 0) {
            if ($this->payment->company_gateway) {
                $response = $this->payment->company_gateway->driver($this->payment->client)->refund($this->payment, $net_refund);

                if ($response['amount'] ?? false) {
                    $net_refund = $response['amount'];
                }

                if ($response['voided'] ?? false) {
                    //When a transaction is voided - all invoices attached to the payment need to be reversed, this
                    //block prevents the edge case where a partial refund was attempted.
                    $this->refund_data['invoices'] = $this->payment->invoices->map(function ($invoice) {
                        return [
                            'date' => now()->addSeconds($invoice->client->timezone_offset())->format('Y-m-d'),
                            'invoice_id' => $invoice->id,
                            'amount' => $invoice->pivot->amount,
                        ];
                    })->toArray();
                }

                $this->payment->refunded += $net_refund;

                if ($response['success'] == false) {
                    $this->payment->save();
                    $this->refund_failed = true;
                    $this->refund_failed_message = $response['description'] ?? '';
                }
            } else {
                $this->payment->refunded += $net_refund;
            }
            
        } else {
            $this->payment->refunded += $net_refund;
        }

        $this->payment->setRefundMeta($this->refund_data);

        return $this;
    }

    /**
     * Create the payment activity.
     *
     * @param  string $notes
     * @return $this
     */
    private function createActivity($notes)
    {
        $fields = new stdClass();
        $activity_repo = new ActivityRepository();

        $fields->payment_id = $this->payment->id;
        $fields->user_id = $this->payment->user_id;
        $fields->company_id = $this->payment->company_id;
        $fields->activity_type_id = Activity::REFUNDED_PAYMENT;
        $fields->client_id = $this->payment->client_id;
        // $fields->credit_id // TODO
        $fields->notes = $notes;

        if (isset($this->refund_data['invoices'])) {
            foreach ($this->refund_data['invoices'] as $invoice) {
                $fields->invoice_id = $invoice['invoice_id'];
                $activity_repo->save($fields, $this->payment, Ninja::eventVars(auth()->user() ? auth()->user()->id : null));
            }
        } else {
            $activity_repo->save($fields, $this->payment, Ninja::eventVars(auth()->user() ? auth()->user()->id : null));
        }

        return $this;
    }

    /**
     * Determine the amount of refund.
     *
     * @return $this
     */
    private function calculateTotalRefund()
    {
        if (array_key_exists('invoices', $this->refund_data) && count($this->refund_data['invoices']) > 0) {
            $this->total_refund = collect($this->refund_data['invoices'])->sum('amount');
        } else {
            $this->total_refund = $this->refund_data['amount'];
        }

        return $this;
    }

    /**
     * Set the payment status.
     */
    private function setStatus()
    {
        if ($this->total_refund == $this->payment->amount || \App\Utils\BcMath::equal($this->payment->amount, $this->payment->refunded)) {
            $this->payment->status_id = Payment::STATUS_REFUNDED;
        } else {
            $this->payment->status_id = Payment::STATUS_PARTIALLY_REFUNDED;
        }

        return $this;
    }

    /**
     * Update the paymentable records.
     *
     * @return $this
     */
    private function updatePaymentables()
    {
        if (isset($this->refund_data['invoices']) && count($this->refund_data['invoices']) > 0) {
            $this->payment->invoices->each(function ($paymentable_invoice) {
                collect($this->refund_data['invoices'])->each(function ($refunded_invoice) use ($paymentable_invoice) {
                    if ($refunded_invoice['invoice_id'] == $paymentable_invoice->id) {
                        $paymentable_invoice->pivot->refunded += $refunded_invoice['amount'];
                        $paymentable_invoice->pivot->save();
                    }
                });
            });
        }

        return $this;
    }

    /**
     * If credits have been bundled in this payment, we
     * need to reverse these.
     *
     * @return $this
     */
    private function updateCreditables()
    {

        if ($this->payment->credits()->exists()) {

            $amount_to_refund = $this->total_refund;

            //Adjust credits first!!!
            foreach ($this->payment->credits as $paymentable_credit) {
                $available_credit = $paymentable_credit->pivot->amount - $paymentable_credit->pivot->refunded;

                if ($available_credit > $amount_to_refund) {
                    $paymentable_credit->pivot->refunded += $amount_to_refund;
                    $paymentable_credit->pivot->save();

                    $paymentable_credit->service()
                                       ->setStatus(Credit::STATUS_SENT)
                                       ->adjustBalance($amount_to_refund)
                                       ->updatePaidToDate($amount_to_refund * -1)
                                       ->save();

                    // Restore the client's credit_balance when credit is refunded
                    // This prevents the double-spend bug where credit.balance is restored
                    // but client.credit_balance is not, causing negative balance on re-application
                    if (!$paymentable_credit->is_deleted) {
                        $this->payment->client->fresh()
                            ->service()
                            ->adjustCreditBalance($amount_to_refund)
                            ->save();
                    }

                    $this->credits_used += $amount_to_refund;
                    $amount_to_refund = 0;

                } else {
                    $paymentable_credit->pivot->refunded += $available_credit;
                    $paymentable_credit->pivot->save();

                    $paymentable_credit->service()
                                       ->setStatus(Credit::STATUS_SENT)
                                       ->adjustBalance($available_credit)
                                       ->updatePaidToDate($available_credit * -1)
                                       ->save();

                    // Restore the client's credit_balance when credit is refunded
                    // This prevents the double-spend bug where credit.balance is restored
                    // but client.credit_balance is not, causing negative balance on re-application
                    if (!$paymentable_credit->is_deleted) {
                        $this->payment->client->fresh()
                            ->service()
                            ->adjustCreditBalance($available_credit)
                            ->save();
                    }

                    $this->credits_used += $available_credit;
                    $amount_to_refund -= $available_credit;

                }

                if ($amount_to_refund == 0) {
                    break;
                }
            }
        }

        return $this;
    }

    /**
     * Reverse the payments made on invoices.
     *
     * @return $this
     */
    private function adjustInvoices()
    {
        if (isset($this->refund_data['invoices']) && count($this->refund_data['invoices']) > 0) {
            foreach ($this->refund_data['invoices'] as $refunded_invoice) {
                $invoice = Invoice::withTrashed()->find($refunded_invoice['invoice_id']);

                if ($invoice->status_id == Invoice::STATUS_REVERSED) {
                    $_credit = Credit::withTrashed()->where('invoice_id', $invoice->id)->first();
                    $_credit->client->service()->adjustCreditBalance($_credit->balance * -1)->save();
                    $_credit->paid_to_date += $_credit->balance;
                    $_credit->balance = 0;
                    $_credit->status_id = Credit::STATUS_APPLIED;
                    $_credit->save();
                    continue;
                }

                if ($invoice->trashed()) {
                    $invoice->restore();
                }

                $invoice->service()
                        ->updateBalance($refunded_invoice['amount'])
                        ->updatePaidToDate($refunded_invoice['amount'] * -1)
                        ->save();

                $invoice->ledger()
                        ->updateInvoiceBalance(abs($refunded_invoice['amount']), "Refund of payment # {$this->payment->number}")
                        ->save();

                if ($invoice->amount == $invoice->balance) {
                    $invoice->service()->setStatus(Invoice::STATUS_SENT);
                } else {
                    $invoice->service()->setStatus(Invoice::STATUS_PARTIAL);
                }

                //26-10-2022 - disable autobill to prevent future billings;
                $invoice->auto_bill_enabled = false;

                $invoice->saveQuietly();

                //08-08-2023
                $client = $invoice->client
                                  ->service()
                                  ->updateBalanceAndPaidToDate($refunded_invoice['amount'], -1 * $refunded_invoice['amount'])
                                  ->save();

                try {
                    $invoice->loadMissing(['client.country', 'client.company']);

                    if ($invoice->client->reportableFrTransaction()) {
                        $paymentable = Paymentable::withTrashed()
                            ->where('payment_id', $this->payment->id)
                            ->where('paymentable_id', $invoice->id)
                            ->where('paymentable_type', 'invoices')
                            ->latest('id')
                            ->first();

                        app(FrancePaymentApplicationRecorder::class)->recordMovement(
                            payment: $this->payment,
                            invoice: $invoice,
                            paymentable: $paymentable,
                            movementAmount: -1 * $refunded_invoice['amount'],
                            movementDate: $this->refund_data['date'] ?? now()->toDateString(),
                            movementType: FrancePaymentApplicationRecorder::MOVEMENT_REFUNDED,
                        );
                    }
                } catch (\Throwable $exception) {
                    report($exception);
                }

                if ($invoice->is_deleted) {
                    $invoice->delete();
                }

            }

            PaymentTransactionEventEntry::dispatch($this->payment, array_column($this->refund_data['invoices'], 'invoice_id'), $this->payment->company->db, $this->total_refund, false);

        } else {
            //if we are refunding and no payments have been tagged, then we need to decrement the client->paid_to_date by the total refund amount.

            $client = $this->payment->client->fresh();

            if ($client->trashed()) {
                $client->restore();
            }

            $client->service()->updatePaidToDate(-1 * $this->total_refund)->save();
        }

        return $this;
    }

    /**
     * Saves the payment.
     *
     * @return Payment $payment
     */
    private function save()
    {
        $this->payment->save();

        return $this->payment;
    }
}
