<?php

namespace Autogedal\TbiBank\Test\Unit;

use Autogedal\TbiBank\Model\ApprovalAccounting;
use Autogedal\TbiBank\Model\ApprovalConfig;
use Autogedal\TbiBank\Model\ApprovedOrderEmail;
use Magento\Sales\Model\Order\Config as OrderConfig;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Event\ManagerInterface;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Sales\Api\InvoiceRepositoryInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\OrderFactory;
use Magento\Sales\Model\Order\Invoice;
use Magento\Sales\Model\Order\Payment;
use Magento\Sales\Model\Order\Status\History;
use Magento\Sales\Model\ResourceModel\Order as OrderResource;
use Magento\Sales\Model\ResourceModel\Order\Handler\State;
use Magento\Sales\Model\ResourceModel\Order\Invoice\Collection;
use Magento\Sales\Model\Service\InvoiceService;
use PHPUnit\Framework\TestCase;

class ApprovalAccountingTest extends TestCase
{
    private $connection;
    private $order;
    private $payment;
    private $invoice;
    private $invoiceService;
    private $invoiceRepository;
    private $orderRepository;
    private ApprovalAccounting $accounting;
    private array $ids = [42];

    protected function setUp(): void
    {
        $this->connection = $this->createMock(AdapterInterface::class);
        $this->connection->method('quoteIdentifier')->willReturn('`sales_order`');
        $this->connection->method('fetchCol')->willReturnCallback(function ($sql, $bind) {
            $this->assertStringContainsString('FOR UPDATE', $sql);
            $this->assertSame(['AGD-000000123'], $bind);
            return $this->ids;
        });
        $resource = $this->createMock(ResourceConnection::class);
        $resource->method('getConnection')->willReturn($this->connection);
        $resource->method('getTableName')->willReturnCallback(fn ($table) => $table);
        $this->payment = $this->createPartialMock(Payment::class, []);
        $this->payment->setMethod('fusionpay');
        $this->order = $this->createPartialMock(Order::class, [
            'getId', 'getPayment', 'getBaseTotalDue', 'canInvoice', 'addStatusHistoryComment', 'getConfig',
        ]);
        $this->order->setData([
            'entity_id' => 42, 'increment_id' => 'AGD-000000123', 'state' => 'pending_payment',
            'status' => 'pending', 'base_grand_total' => 100, 'base_total_paid' => 0, 'can_invoice' => true,
        ]);
        $this->order->method('getPayment')->willReturn($this->payment);
        $this->order->method('getId')->willReturn(42);
        $orderConfig = $this->createMock(OrderConfig::class);
        $orderConfig->method('getStateStatuses')->with('pending')->willReturn(['pending' => 'Pending', 'new' => 'Noua']);
        $this->order->method('getConfig')->willReturn($orderConfig);
        $this->order->method('getBaseTotalDue')->willReturnCallback(
            fn () => max(0, 100 - (float) $this->order->getBaseTotalPaid())
        );
        $this->order->method('canInvoice')->willReturnCallback(fn () => $this->order->getData('can_invoice'));
        $history = $this->createPartialMock(History::class, []);
        $this->order->method('addStatusHistoryComment')->willReturn($history);
        $factory = $this->createMock(OrderFactory::class);
        $factory->method('create')->willReturn($this->order);
        $orderResource = $this->createMock(OrderResource::class);
        $orderResource->method('load')->willReturnSelf();
        $this->invoice = $this->createPartialMock(Invoice::class, ['register', 'pay']);
        $this->invoice->setBaseGrandTotal(100)->setTotalQty(1);
        $this->invoice->method('register')->willReturnSelf();
        $this->invoice->method('pay')->willReturnCallback(function () {
            // Simulate native invoice payment totals and a conflicting state change by an observer.
            $this->order->setBaseTotalPaid(100)->setTotalPaid(100);
            $this->order->setState('processing')->setStatus('processing')->setIsInProcess(true);
            return $this->invoice;
        });
        $this->invoiceService = $this->createMock(InvoiceService::class);
        $this->invoiceService->method('prepareInvoice')->willReturn($this->invoice);
        $this->invoiceRepository = $this->createMock(InvoiceRepositoryInterface::class);
        $this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
        $approvalConfig = $this->createMock(ApprovalConfig::class);
        $approvalConfig->method('getApprovedStatus')->willReturn('new');
        $this->accounting = new ApprovalAccounting(
            $resource, $factory, $orderResource, $this->orderRepository,
            $this->invoiceService, $this->invoiceRepository, $approvalConfig,
            $this->createMock(ApprovedOrderEmail::class)
        );
    }

    public function testApprovalCreatesPaidInvoiceAndKeepsNewPendingWithZeroDue(): void
    {
        $this->connection->expects($this->once())->method('beginTransaction');
        $this->connection->expects($this->once())->method('commit');
        $this->connection->expects($this->never())->method('rollBack');
        $this->invoiceRepository->expects($this->once())->method('save')->with($this->invoice);
        $this->orderRepository->expects($this->once())->method('save')->with($this->callback(function ($order) {
            $this->assertSame('pending', $order->getState());
            $this->assertSame('new', $order->getStatus());
            $this->assertFalse($order->getIsInProcess());
            $this->assertSame(0, $order->getBaseTotalDue());
            $this->assertTrue($order->getPayment()->getAdditionalInformation(ApprovalAccounting::MARKER));
            return true;
        }));
        $this->connection->expects($this->once())->method('update')->with(
            'sales_order_grid', ['tbi_status' => 'Plata TBI acceptata', 'status' => 'new'], ['entity_id = ?' => 42]
        );
        $this->accounting->approve('AGD-000000123', 'Plata TBI acceptata');
        $this->assertSame(Invoice::CAPTURE_OFFLINE, $this->invoice->getRequestedCaptureCase());
    }

    public function testApprovalPersistsContractInOrderAndGrid(): void
    {
        $this->orderRepository->expects($this->once())->method('save')->with($this->callback(
            fn ($order) => $order->getData('tbi_contract_id') === 'CONTRACT-42'
        ));
        $this->connection->expects($this->once())->method('update')->with(
            'sales_order_grid',
            ['tbi_status' => 'Approved', 'status' => 'new', 'tbi_contract_id' => 'CONTRACT-42'],
            ['entity_id = ?' => 42]
        );
        $this->accounting->approve('AGD-000000123', 'Approved', 'CONTRACT-42');
    }

    public function testDuplicateApprovalCanBackfillContractWithoutReopeningOrInvoicing(): void
    {
        $this->payment->setAdditionalInformation(ApprovalAccounting::MARKER, true);
        $this->order->setState('complete')->setStatus('custom_facturat');
        $this->invoiceService->expects($this->never())->method('prepareInvoice');
        $this->orderRepository->expects($this->never())->method('save');
        $tables = [];
        $this->connection->expects($this->exactly(2))->method('update')->willReturnCallback(
            function ($table, $data, $where) use (&$tables) {
                $tables[] = $table;
                $this->assertSame(['tbi_contract_id' => 'CONTRACT-42'], $data);
                $this->assertSame(['entity_id = ?' => 42], $where);
                return 1;
            }
        );
        $this->connection->expects($this->once())->method('commit');
        $this->accounting->approve('AGD-000000123', 'Approved', 'CONTRACT-42');
        $this->assertSame(['sales_order', 'sales_order_grid'], $tables);
        $this->assertSame('complete', $this->order->getState());
        $this->assertSame('custom_facturat', $this->order->getStatus());
    }

    public function testDuplicateApprovalDoesNotInvoiceAgainOrReopenFulfilledOrder(): void
    {
        $this->payment->setAdditionalInformation(ApprovalAccounting::MARKER, true);
        $this->order->setState('complete')->setStatus('custom_facturat');
        $this->invoiceService->expects($this->never())->method('prepareInvoice');
        $this->orderRepository->expects($this->never())->method('save');
        $this->connection->expects($this->never())->method('update');
        $this->connection->expects($this->once())->method('commit');
        $this->accounting->approve('AGD-000000123', 'Approved');
        $this->assertSame('complete', $this->order->getState());
        $this->assertSame('custom_facturat', $this->order->getStatus());
    }

    /** @dataProvider ineligibleOrders */
    public function testApprovalNeverReopensIneligibleOrders(string $state, string $status): void
    {
        $this->order->setState($state)->setStatus($status);
        $this->invoiceService->expects($this->never())->method('prepareInvoice');
        $this->orderRepository->expects($this->never())->method('save');
        $this->connection->expects($this->once())->method('rollBack');
        $this->expectException(LocalizedException::class);
        $this->accounting->approve('AGD-000000123', 'Approved');
    }

    public static function ineligibleOrders(): array
    {
        return [['canceled', 'canceled'], ['closed', 'closed'], ['complete', 'complete'],
            ['holded', 'holded'], ['processing', 'custom_awb'], ['new', 'custom_awb']];
    }

    public function testWrongPaymentMethodIsRejectedBeforeInvoicing(): void
    {
        $this->payment->setMethod('checkmo');
        $this->invoiceService->expects($this->never())->method('prepareInvoice');
        $this->connection->expects($this->once())->method('rollBack');
        $this->expectException(LocalizedException::class);
        $this->accounting->approve('AGD-000000123', 'Approved');
    }

    /** @dataProvider unresolvedOrders */
    public function testUnknownOrAmbiguousOrderIsRejected(array $ids): void
    {
        $this->ids = $ids;
        $this->invoiceService->expects($this->never())->method('prepareInvoice');
        $this->connection->expects($this->once())->method('rollBack');
        $this->expectException(LocalizedException::class);
        $this->accounting->approve('AGD-000000123', 'Approved');
    }

    public static function unresolvedOrders(): array
    {
        return [[[]], [[42, 43]]];
    }

    public function testInvoiceFailureRollsBackApprovalWithoutGridUpdateOrMarker(): void
    {
        $this->invoiceRepository->method('save')->willThrowException(new \RuntimeException('storage failure'));
        $this->orderRepository->expects($this->never())->method('save');
        $this->connection->expects($this->never())->method('update');
        $this->connection->expects($this->never())->method('commit');
        $this->connection->expects($this->once())->method('rollBack');
        try {
            $this->accounting->approve('AGD-000000123', 'Approved');
            $this->fail('Expected accounting failure.');
        } catch (LocalizedException $exception) {
            $this->assertStringContainsString('Retry', $exception->getMessage());
            $this->assertNull($this->payment->getAdditionalInformation(ApprovalAccounting::MARKER));
        }
    }

    public function testInvoiceMustCoverFullOutstandingAmount(): void
    {
        $this->invoice->setBaseGrandTotal(50);
        $this->invoiceRepository->expects($this->never())->method('save');
        $this->connection->expects($this->once())->method('rollBack');
        $this->expectException(LocalizedException::class);
        $this->accounting->approve('AGD-000000123', 'Approved');
    }

    public function testExistingUnpaidInvoiceNeedsReconciliationInsteadOfDuplicateInvoice(): void
    {
        $this->order->setData('can_invoice', false);
        $this->invoiceService->expects($this->never())->method('prepareInvoice');
        $this->orderRepository->expects($this->never())->method('save');
        $this->connection->expects($this->once())->method('rollBack');
        $this->expectException(LocalizedException::class);
        $this->accounting->approve('AGD-000000123', 'Approved');
    }

    public function testAlreadyPaidOrderDoesNotCreateAnotherInvoice(): void
    {
        $this->order->setBaseTotalPaid(100);
        $this->invoiceService->expects($this->never())->method('prepareInvoice');
        $this->orderRepository->expects($this->once())->method('save');
        $this->accounting->approve('AGD-000000123', 'Approved');
        $this->assertSame('pending', $this->order->getState());
        $this->assertSame('new', $this->order->getStatus());
    }

    public function testNativeInvoicePaymentClearsCodAmountAndNativeStateHandlerKeepsNewPending(): void
    {
        $payment = $this->createPartialMock(Payment::class, []);
        $invoice = $this->createPartialMock(Invoice::class, []);
        $order = $this->createPartialMock(Order::class, [
            'getId', 'getPayment', 'getInvoiceCollection', 'canInvoice', 'canUnhold', 'isCanceled',
        ]);
        $order->method('getId')->willReturn(42);
        $order->method('getPayment')->willReturn($payment);
        $order->method('canInvoice')->willReturn(false);
        $order->method('canUnhold')->willReturn(false);
        $order->method('isCanceled')->willReturn(false);
        $order->setGrandTotal(100)->setBaseGrandTotal(100)->setTotalPaid(0)->setBaseTotalPaid(0);
        $order->setState('pending')->setStatus('new')->setIsInProcess(false);
        $currency = $this->createMock(PriceCurrencyInterface::class);
        $currency->method('round')->willReturnCallback(fn ($amount) => round($amount, 2));
        (new \ReflectionProperty(Order::class, 'priceCurrency'))->setValue($order, $currency);
        $events = $this->createMock(ManagerInterface::class);
        foreach ([$payment, $invoice] as $model) {
            (new \ReflectionProperty($model, '_eventManager'))->setValue($model, $events);
        }
        $payment->setOrder($order);
        $invoice->setOrder($order)->setGrandTotal(100)->setBaseGrandTotal(100);
        $collection = $this->createPartialMock(Collection::class, ['getItems', 'getIterator']);
        $collection->method('getItems')->willReturn([$invoice]);
        $collection->method('getIterator')->willReturn(new \ArrayIterator([$invoice]));
        $order->method('getInvoiceCollection')->willReturn($collection);
        // Execute the actual Magento methods used to record payment and consumed by GetAwb.
        $invoice->pay();
        $invoice->pay();
        $this->assertSame(Invoice::STATE_PAID, $invoice->getState());
        $this->assertSame(100, $order->getTotalPaid());
        $this->assertSame(0.0, $order->getData('total_due'));
        $this->assertSame(0.0, $order->getBaseTotalDue());
        (new State())->check($order);
        $this->assertSame('pending', $order->getState());
        $this->assertSame('new', $order->getStatus());
    }
}
