<?php
/**
 * Copyright © Filoblu S.r.l. All rights reserved.
 */

namespace FiloBlu\Esb\Model\Order;

use FiloBlu\Esb\Core\Exception\OrderLockedException;
use FiloBlu\Esb\Helper\LoggerProvider;
use Magento\Framework\Lock\LockManagerInterface;
use Throwable;

use function sprintf;

/**
 * Mutua esclusione fra gli endpoint REST che fanno read-modify-write sullo stesso ordine.
 *
 * Talend chiama /V1/acknowledge/save e /V1/externalinvoice/save in parallelo sullo stesso
 * ordine e non puo' serializzarle a monte (confermato dal team Talend il 19.08.2026), quindi
 * la mutua esclusione va garantita qui. Poiche' OrderRepository::save() riscrive tutta la riga
 * sales_order a partire dall'oggetto caricato in memoria, due chiamate concorrenti si
 * sovrascrivono a vicenda: il caso osservato e' l'ack create_fulfillment che porta l'ordine a
 * sent_to_logistics e il salvataggio del documento contabile che, avendo caricato l'ordine
 * prima, lo riporta indietro a invoiced.
 *
 * Il lock e' quello di Magento: con il provider 'db' (default) e' un GET_LOCK MySQL, quindi
 * vale fra processi PHP e fra pod diversi, ed e' prefissato col nome del database, quindi non
 * collide fra installazioni. Va preso ATTORNO a load + modifica + save, non solo sul save.
 *
 * Se il lock non si ottiene la scrittura viene RIFIUTATA (OrderLockedException -> HTTP 503):
 * meglio far ritentare il chiamante che rischiare di sovrascrivere l'altra chiamata in corso.
 */
class OrderWriteLock
{
    /** @var string Prefisso del nome del lock; l'increment id completa la chiave */
    const LOCK_NAME_PREFIX = 'filoblu_esb_order_write_';

    /**
     * Secondi di attesa prima di rinunciare al lock.
     *
     * Tenuto volutamente basso: il timeout HTTP del client Talend non ci e' noto, e attendere
     * piu' di cosi' rischia di far lavorare la richiesta quando il chiamante ha gia' chiuso la
     * connessione. Meglio rispondere 503 in fretta e farsi ritentare. Da rialzare solo dopo
     * aver verificato il timeout lato Talend.
     *
     * @var int
     */
    const LOCK_TIMEOUT = 10;

    /**
     * @var LockManagerInterface
     */
    private $lockManager;

    /**
     * @var \Monolog\Logger
     */
    private $logger;

    /**
     * @param LockManagerInterface $lockManager
     * @param LoggerProvider $loggerProvider
     */
    public function __construct(
        LockManagerInterface $lockManager,
        LoggerProvider $loggerProvider
    ) {
        $this->lockManager = $lockManager;
        $this->logger = $loggerProvider->getLogger();
    }

    /**
     * Esegue $callback in mutua esclusione sull'ordine indicato.
     *
     * Se il lock non si ottiene entro il timeout NON si procede: si solleva
     * OrderLockedException, che risponde 503 al chiamante perche' ritenti. Scrivere senza lock
     * significherebbe rischiare di sovrascrivere l'altra chiamata in corso, ed e' esattamente
     * il difetto che questo lock esiste per eliminare.
     *
     * @param string $orderIncrementId
     * @param callable $callback
     * @return mixed il valore restituito da $callback
     * @throws OrderLockedException se il lock non e' ottenibile
     * @throws Throwable qualunque eccezione di $callback, dopo il rilascio del lock
     */
    public function execute($orderIncrementId, callable $callback)
    {
        $lockName = self::LOCK_NAME_PREFIX . $orderIncrementId;

        try {
            $acquired = $this->lockManager->lock($lockName, self::LOCK_TIMEOUT);
        } catch (Throwable $throwable) {
            // anche un lock backend rotto e' un motivo per fermarsi, non per scrivere alla cieca
            $this->logger->critical(sprintf(
                'OrderWriteLock: lock backend failure on order %s: %s',
                $orderIncrementId,
                $throwable->getMessage()));

            throw new OrderLockedException(
                __('Cannot acquire write lock for order %1, please retry', $orderIncrementId)
            );
        }

        if (!$acquired) {
            $this->logger->critical(sprintf(
                'OrderWriteLock: lock on order %s not acquired after %d s, refusing to write',
                $orderIncrementId,
                self::LOCK_TIMEOUT));

            throw new OrderLockedException(
                __('Cannot acquire write lock for order %1, please retry', $orderIncrementId)
            );
        }

        try {
            return $callback();
        } finally {
            try {
                $this->lockManager->unlock($lockName);
            } catch (Throwable $throwable) {
                $this->logger->critical(sprintf(
                    'OrderWriteLock: cannot release lock for order %s: %s',
                    $orderIncrementId,
                    $throwable->getMessage()));
            }
        }
    }
}
