<?php

/**
 * Copyright 2024-2025 Simply.IN Sp. z o.o.
 *
 * NOTICE OF LICENSE
 *
 * Licensed under the EUPL-1.2 or later.
 * You may not use this work except in compliance with the Licence.
 *
 * Copy of the Licence is available at:
 * https://joinup.ec.europa.eu/software/page/eupl
 * It is bundled with this package in the file LICENSE.txt
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the Licence is distributed on an as is basis,
 * without warranties or conditions of any kind, either express or implied.
 * Check the Licence for the specific language governing permissions
 * and limitations under the License.
 *
 * @author   Simply.IN Sp. z o.o.
 * @copyright 2024-2025 Simply.IN Sp. z o.o.
 * @license   https://joinup.ec.europa.eu/software/page/eupl
 */

declare(strict_types=1);

namespace SimplyIn\Auth;

use Db;

if (!defined('_PS_VERSION_')) {
    exit;
}

/**
 * Cross-process mutex for the RSA registration handshake, backed by MySQL's session-scoped named
 * locks (GET_LOCK / RELEASE_LOCK).
 *
 * {@see RsaRegistrar} can be triggered from several independent, unsynchronized paths that may fire
 * close together in time (the back-office header hook, the module cron task, the explicit "Save"
 * action, and the proactive runtime throttle on checkout traffic) — each running in its own
 * PHP-FPM worker with no shared in-memory state. A plain "is it registered yet?" check is a classic
 * check-then-act race: two workers can both see "not registered" and both start the same handshake,
 * and the loser gets an "already registered" rejection from the backend even though the site is, in
 * fact, correctly registered by the winner.
 *
 * GET_LOCK is preferred over a dedicated lock table: it needs no schema change (no install/uninstall
 * SQL, no upgrade script for already-installed shops) and it releases itself automatically if the
 * holding connection dies mid-handshake, so a crashed worker can never leave a stale lock behind.
 *
 * MySQL named locks are scoped to the whole server, not to a single database — on shared hosting
 * where several unrelated PrestaShop installs' databases live on the same MySQL instance, a fixed
 * lock name would let one shop's registration block a completely unrelated shop's. {@see lockName()}
 * folds in the database name and table prefix so the lock is unique per install.
 */
final class RsaRegistrationLock implements RsaRegistrationLockInterface
{
    private const LOCK_PREFIX = 'simplyin_rsa_register_';

    /**
     * Bounded wait: long enough for a normal handshake (sub-second against a healthy backend) to
     * finish, short enough not to meaningfully add to the already-tolerated worst-case handshake
     * latency (up to ~60s across the two sequential HTTP calls, each capped at the client's 30s
     * total timeout) when this runs inline on a checkout request.
     */
    private const ACQUIRE_TIMEOUT_SECONDS = 5;

    private bool $held = false;

    /**
     * @throws \LogicException   if called while this instance already holds the lock — per the
     *                            interface's non-reentrant contract, {@see release()} first.
     * @throws \RuntimeException if the GET_LOCK query itself fails (connection/SQL error — distinct
     *                            from a normal 0/NULL "contended" result). Left for the caller to
     *                            catch: {@see RsaRegistrar::doRegister()} turns it into its regular
     *                            failed-attempt handling instead of the misleading "another process
     *                            is registering" log a plain `false` return would otherwise cause.
     *                            MySQL's own GET_LOCK() surfaces the equivalent error as SQL NULL
     *                            (distinct from the normal 0 "timed out / contended" result) — both
     *                            are treated as this same failure.
     */
    public function acquire(): bool
    {
        if ($this->held) {
            throw new \LogicException('RsaRegistrationLock::acquire() called while already held — release() first');
        }
        $result = Db::getInstance()->getValue(
            'SELECT GET_LOCK(\'' . pSQL($this->lockName()) . '\', ' . self::ACQUIRE_TIMEOUT_SECONDS . ')',
        );
        if ($result === false || $result === null) {
            throw new \RuntimeException('GET_LOCK query failed');
        }
        $this->held = (int) $result === 1;

        return $this->held;
    }

    /**
     * @throws \RuntimeException if the RELEASE_LOCK query itself fails — PrestaShop's Db::execute()
     *                            reports that as a `false` return, not an exception, so this method
     *                            converts it into one deliberately. $held is only cleared once the
     *                            query has actually succeeded, so a caller that retries release()
     *                            (e.g. RsaRegistrar::doRegister()'s `finally`, which logs and
     *                            swallows this) tries the DB call again instead of assuming the
     *                            connection released a lock the query never touched.
     */
    public function release(): void
    {
        if (!$this->held) {
            return;
        }
        if (Db::getInstance()->execute('SELECT RELEASE_LOCK(\'' . pSQL($this->lockName()) . '\')') === false) {
            throw new \RuntimeException('RELEASE_LOCK query failed');
        }
        $this->held = false;
    }

    /**
     * Single source of truth for the lock name, used by both acquire() and release() so the two can
     * never drift apart. Per-install unique (database name + table prefix — the latter covers the
     * shared-database-multiple-prefixes hosting pattern too), hashed and truncated to stay
     * comfortably under MySQL's 64-character limit for lock names.
     */
    private function lockName(): string
    {
        return self::LOCK_PREFIX . substr(md5(_DB_NAME_ . '|' . _DB_PREFIX_), 0, 16);
    }
}
