<?php
/**
 * Copyright © Simplyin. All rights reserved.
 */
declare(strict_types=1);

namespace Simplyin\Payment\Model;

use Magento\Framework\Exception\AlreadyExistsException;
use Psr\Log\LoggerInterface;
use Simplyin\Payment\Model\ResourceModel\OrderSync as OrderSyncResource;
use Simplyin\Payment\Model\ResourceModel\OrderSync\CollectionFactory as OrderSyncCollectionFactory;

/**
 * Writes/updates a simplyin_order_sync row for one order_id.
 *
 * Shared by the three independent writers that all do the same get-or-create
 * (the cron sync, the async post-placement queue consumer, and the
 * order-placement observer): find the newest existing row for order_id, or
 * insert a new one.
 *
 * Never downgrades a row that's already synced: a writer calling this with a
 * worse status (e.g. the cron sync's failed/skipped) must not regress a row
 * another writer already completed back into getOrdersToSync()'s re-sync
 * pool. This applies on the plain get-or-create path too, not only on
 * conflict - a writer can find an already-synced existing row directly (no
 * UNIQUE-constraint race needed) if another writer completed it moments
 * earlier. last_sync_at still updates regardless of the status guard - a
 * writer did just touch this row, even when nothing else about it changed.
 *
 * order_id also carries a UNIQUE constraint, so a race between writers can
 * make the INSERT itself lose to a concurrent one (Magento's
 * AbstractDb::save() catches the MySQL adapter's DuplicateException and
 * rethrows it as AlreadyExistsException). On that conflict, this re-reads
 * whichever row won and merges into it instead of dropping the write: same
 * status guard as above, plus a non-empty simplyin_order_id is applied only
 * if the winner's is still empty (never overwrite a completed sync with an
 * empty one).
 *
 * One re-fetch-and-update attempt on conflict, not a retry loop: a second
 * conflict in that tiny window would mean two more writers raced in the
 * recovery itself, which is pathological and is left to the caller to log.
 */
class OrderSyncRecorder
{
    private const SYNC_STATUS_SYNCED = 'synced';

    /**
     * @var OrderSyncFactory
     */
    private $orderSyncFactory;

    /**
     * @var OrderSyncResource
     */
    private $orderSyncResource;

    /**
     * @var OrderSyncCollectionFactory
     */
    private $orderSyncCollectionFactory;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @param OrderSyncFactory $orderSyncFactory
     * @param OrderSyncResource $orderSyncResource
     * @param OrderSyncCollectionFactory $orderSyncCollectionFactory
     * @param LoggerInterface $logger
     */
    public function __construct(
        OrderSyncFactory $orderSyncFactory,
        OrderSyncResource $orderSyncResource,
        OrderSyncCollectionFactory $orderSyncCollectionFactory,
        LoggerInterface $logger
    ) {
        $this->orderSyncFactory = $orderSyncFactory;
        $this->orderSyncResource = $orderSyncResource;
        $this->orderSyncCollectionFactory = $orderSyncCollectionFactory;
        $this->logger = $logger;
    }

    /**
     * Records the sync outcome for one order_id: updates the newest existing
     * row if one exists, otherwise inserts a new one. Callers are expected
     * to catch and log any propagated exception themselves - this method
     * does not swallow anything beyond the insert-conflict recovery above.
     *
     * @param int $orderId
     * @param string $status
     * @param string|null $simplyinOrderId
     * @return void
     */
    public function record(int $orderId, string $status, ?string $simplyinOrderId): void
    {
        $now = (new \DateTime())->format('Y-m-d H:i:s');

        $collection = $this->orderSyncCollectionFactory->create();
        $collection->addFieldToFilter('order_id', $orderId);
        // order_id carries a UNIQUE constraint, so concurrent writers can no
        // longer create duplicate rows going forward. Historical rows from
        // before the constraint could still have duplicates; keep taking the
        // newest by entity_id so getFirstItem() stays deterministic against
        // any of those.
        $collection->setOrder('entity_id', 'DESC');
        $existingRecord = $collection->getFirstItem();

        if ($existingRecord->getId()) {
            $orderSync = $existingRecord;
        } else {
            $orderSync = $this->orderSyncFactory->create();
            $orderSync->setOrderId($orderId);
        }

        // Never downgrade a row that's already synced: a writer calling
        // record() with a worse status (e.g. the cron sync's failed/skipped)
        // can find a row another writer already completed moments earlier
        // (no UNIQUE-constraint conflict needed for this - it's a plain
        // UPDATE on the existing row), and must not regress it back into
        // getOrdersToSync()'s re-sync pool.
        if ($orderSync->getSyncStatus() !== self::SYNC_STATUS_SYNCED) {
            $orderSync->setSyncStatus($status);
        }
        $orderSync->setLastSyncAt($now);

        if ($simplyinOrderId) {
            $orderSync->setSimplyinOrderId($simplyinOrderId);
        }

        try {
            $this->orderSyncResource->save($orderSync);
        } catch (AlreadyExistsException $e) {
            // Another writer won the UNIQUE-constraint race on order_id
            // between our SELECT above and this save(). Re-read whichever
            // row won and merge our data into it instead of dropping it.
            $this->logger->info('SimplyIN: order_id insert race lost, merging into winning row', [
                'order_id' => $orderId,
                'status' => $status,
                'simplyin_order_id' => $simplyinOrderId
            ]);
            $this->recoverFromInsertConflict($orderId, $status, $simplyinOrderId, $now);
        }
    }

    /**
     * Applies this writer's data to the row that won an order_id insert race.
     *
     * @param int $orderId
     * @param string $status
     * @param string|null $simplyinOrderId
     * @param string $now
     * @return void
     */
    private function recoverFromInsertConflict(int $orderId, string $status, ?string $simplyinOrderId, string $now): void
    {
        $collection = $this->orderSyncCollectionFactory->create();
        $collection->addFieldToFilter('order_id', $orderId);
        $collection->setOrder('entity_id', 'DESC');
        $winner = $collection->getFirstItem();

        if (!$winner->getId()) {
            // The constraint violation proves a row exists; if it's gone by
            // the time we re-read, there's nothing to merge into.
            return;
        }

        if ($simplyinOrderId && !$winner->getSimplyinOrderId()) {
            $winner->setSimplyinOrderId($simplyinOrderId);
        }

        // Never downgrade a winner that's already synced - only the status
        // is guarded; last_sync_at still reflects that a writer touched this
        // row just now, same as every other call site's behavior.
        if ($winner->getSyncStatus() !== self::SYNC_STATUS_SYNCED) {
            $winner->setSyncStatus($status);
        }
        $winner->setLastSyncAt($now);

        $this->orderSyncResource->save($winner);
    }
}
