<?php

namespace Duplicator\Utils\Lock;

use Duplicator\Core\Exceptions\DupliException;
use Duplicator\Models\DynamicGlobalEntity;
use Duplicator\Utils\Logging\DupLog;
use Throwable;

/**
 * Lock utility
 */
class LockUtil
{
    const LOCK_MODE_FILE = 0;
    const LOCK_MODE_SQL  = 1;
    /** @var string DynamicGlobal key holding the detected lock mode (never expires) */
    const LOCK_MODE_DGLOBAL_KEY = 'detected_lock_mode';
    /** @var string DynamicGlobal key for manual lock mode override ('auto', 'sql', 'file') */
    const LOCK_MODE_OVERRIDE_KEY = 'override_lock_mode';

    /** @var AbstractLockEngine[] Lock engines used by the current process */
    protected static array $processLocks = [];

    /** @var bool True after the acquisition trace has been written for the current request */
    private static bool $processLocksTraced = false;

    /**
     * Return the configured process-lock setting.
     *
     * @return string One of auto, sql or file
     */
    public static function getProcessLockSetting(): string
    {
        $setting = DynamicGlobalEntity::getInstance()->getValString(self::LOCK_MODE_OVERRIDE_KEY, 'auto');

        return in_array($setting, ['sql', 'file'], true) ? $setting : 'auto';
    }

    /**
     * Return the user-facing process-lock setting label.
     *
     * @return string
     */
    public static function getProcessLockSettingLabel(): string
    {
        switch (self::getProcessLockSetting()) {
            case 'sql':
                return __('SQL', 'duplicator-pro');
            case 'file':
                return __('File', 'duplicator-pro');
            default:
                return __('Both (SQL & File)', 'duplicator-pro');
        }
    }

    /**
     * Return the detected lock mode.
     *
     * Reads the value persisted in DynamicGlobalEntity (no expiry). When unset, returns
     * the SQL-lock default WITHOUT running any test: detection only ever runs in the
     * single-threaded entry points (AsyncSetupActions), never on the backup path.
     * This value is informational. Runtime process locking is independent from
     * the detected mode and uses every engine enabled by the override setting.
     *
     * @return int Lock mode ENUM self::LOCK_MODE_FILE or self::LOCK_MODE_SQL
     */
    public static function getLockMode(): int
    {
        $override = self::getProcessLockSetting();
        if ($override === 'sql') {
            return self::LOCK_MODE_SQL;
        }
        if ($override === 'file') {
            return self::LOCK_MODE_FILE;
        }

        return DynamicGlobalEntity::getInstance()->getValInt(
            self::LOCK_MODE_DGLOBAL_KEY,
            self::LOCK_MODE_SQL
        );
    }

    /**
     * Run both lock reliability tests, persist the detected mode and return the test details.
     *
     * Reliability checks use isolated test identifiers and do not acquire the process lock.
     *
     * @return array{mode: int, sqlReliable: bool, fileReliable: bool}
     */
    public static function redetectLockMode(): array
    {
        $sqlLock  = new SqlLock();
        $fileLock = new FileLock();

        $sqlReliable  = $sqlLock->isReliable();
        $fileReliable = $fileLock->isReliable();

        if (!$sqlReliable) {
            self::logLockError($sqlLock);
        }
        if (!$fileReliable) {
            self::logLockError($fileLock);
        }

        if ($sqlReliable) {
            $detected = self::LOCK_MODE_SQL;
        } elseif ($fileReliable) {
            $detected = self::LOCK_MODE_FILE;
        } else {
            DupLog::trace("Both lock tests have issues, reporting LOCK_MODE_FILE as the diagnostic fallback");
            $detected = self::LOCK_MODE_FILE;
        }

        DynamicGlobalEntity::getInstance()->setValInt(self::LOCK_MODE_DGLOBAL_KEY, $detected, true);

        return [
            'mode'         => $detected,
            'sqlReliable'  => $sqlReliable,
            'fileReliable' => $fileReliable,
        ];
    }

    /**
     * Clear the detected lock mode, reverting getLockMode() to the SQL default.
     *
     * Should be called after install/migration when the filesystem may have changed.
     * The next detection (AsyncSetupActions) re-populates it.
     *
     * @return void
     */
    public static function resetDetectedLockType(): void
    {
        DynamicGlobalEntity::getInstance()->removeVal(self::LOCK_MODE_DGLOBAL_KEY, true);
    }

    /**
     * Lock process, failing closed when no engine is available.
     *
     * Acquires every available process-lock engine without waiting.
     * Used by the build entry points, which surface the failure to the user.
     *
     * @throws DupliException When every lock engine is unavailable
     *
     * @return bool true if lock acquired, false when another process holds a lock
     */
    public static function lockProcessOrThrow(): bool
    {
        if (self::hasAcquiredProcessLock()) {
            return true;
        }

        $errors = [];

        try {
            foreach (self::getProcessLocks() as $lock) {
                if ($lock->getStatus() === AbstractLockEngine::STATUS_ERROR) {
                    $errors[] = $lock->getLastLockError()
                        ?? get_class($lock) . ' is unavailable';
                    continue;
                }

                if ($lock->lock()) {
                    continue;
                }

                if ($lock->getStatus() === AbstractLockEngine::STATUS_BUSY) {
                    self::releaseProcessLocks();
                    return false;
                }

                $errors[] = $lock->getLastLockError()
                    ?? get_class($lock) . ' returned an unexpected lock status';
            }

            if (self::hasAcquiredProcessLock()) {
                self::traceAcquiredProcessLocks();
                return true;
            }

            throw self::createAcquireException($errors);
        } catch (Throwable $e) {
            self::releaseProcessLocks();

            if ($e instanceof DupliException) {
                throw $e;
            }

            throw self::createAcquireException([$e->getMessage()], $e);
        }
    }

    /**
     * Lock process
     *
     * Boolean variant for the worker path: engine failures are intercepted
     * at the build entry points, so here any failure only means "not acquired".
     *
     * @return bool true if lock acquired
     */
    public static function lockProcess(): bool
    {
        try {
            return self::lockProcessOrThrow();
        } catch (DupliException $e) {
            DupLog::traceError('Process lock unavailable: ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Return process-lock engines in acquisition order.
     *
     * @return AbstractLockEngine[] Lock engines
     */
    private static function getProcessLocks(): array
    {
        if (self::$processLocks !== []) {
            return self::$processLocks;
        }

        $override = self::getProcessLockSetting();

        if ($override === 'sql') {
            self::$processLocks = [new SqlLock()];
        } elseif ($override === 'file') {
            self::$processLocks = [new FileLock()];
        } else {
            self::$processLocks = [
                new SqlLock(),
                new FileLock(),
            ];
        }

        return self::$processLocks;
    }

    /**
     * Unlock process
     *
     * Idempotent: no-op returning true when the current request does not hold the lock.
     *
     * @return bool true if lock released or not held
     */
    public static function unlockProcess(): bool
    {
        return self::releaseProcessLocks();
    }

    /**
     * Return the current process-lock state for diagnostics.
     *
     * @return array{acquired: array<string, string>, errors: array<string, string>}
     */
    public static function getProcessLockInfo(): array
    {
        $result = [
            'acquired' => [],
            'errors'   => [],
        ];

        foreach (self::$processLocks as $lock) {
            if ($lock->isLocked()) {
                $result['acquired'][$lock->getLockType()] = $lock->getLockLabel();
            } elseif ($lock->getStatus() === AbstractLockEngine::STATUS_ERROR) {
                $result['errors'][$lock->getLockLabel()] = $lock->getLastLockError()
                    ?? $lock->getLockLabel() . ' lock is unavailable';
            }
        }

        return $result;
    }

    /**
     * @return bool True when at least one process lock is held
     *
     * @phpstan-impure
     */
    private static function hasAcquiredProcessLock(): bool
    {
        foreach (self::$processLocks as $lock) {
            if ($lock->isLocked()) {
                return true;
            }
        }

        return false;
    }

    /**
     * Trace the process locks acquired by the current request.
     *
     * @return void
     */
    private static function traceAcquiredProcessLocks(): void
    {
        if (self::$processLocksTraced) {
            return;
        }

        $labels = [];

        foreach (self::$processLocks as $lock) {
            if ($lock->isLocked()) {
                $labels[$lock->getLockType()] = $lock->getLockLabel();
            } elseif ($lock->getStatus() === AbstractLockEngine::STATUS_ERROR) {
                self::logLockError($lock);
            }
        }

        DupLog::trace('Process locks acquired: ' . implode(', ', $labels));
        self::$processLocksTraced = true;
    }

    /**
     * Release every acquired process lock in reverse order.
     *
     * @return bool True when every acquired lock was released
     */
    private static function releaseProcessLocks(): bool
    {
        $locks    = array_reverse(self::$processLocks);
        $released = true;

        foreach ($locks as $lock) {
            if (!$lock->isLocked()) {
                continue;
            }

            try {
                if (!$lock->unlock()) {
                    self::logLockError($lock);
                    $released = false;
                }
            } catch (Throwable $e) {
                DupLog::trace('Cannot release process lock: ' . $e->getMessage());
                $released = false;
            }
        }

        return $released;
    }

    /**
     * Create the fail-closed exception used when no lock engine is available.
     *
     * @param string[]       $errors   Lock engine errors
     * @param Throwable|null $previous Previous throwable
     *
     * @return DupliException
     */
    private static function createAcquireException(array $errors, ?Throwable $previous = null): DupliException
    {
        return new DupliException(
            'Cannot acquire process lock: ' . implode('; ', $errors),
            DupliException::CODE_LOCK_ACQUIRE_FAILED,
            __(
                'The backup cannot start because no process-locking method is available.
                Check the backup log for details or contact your hosting provider.',
                'duplicator-pro'
            ),
            $previous
        );
    }

    /**
     * Log an operational lock error when present.
     *
     * @param AbstractLockEngine $lock Lock engine
     *
     * @return void
     */
    private static function logLockError(AbstractLockEngine $lock): void
    {
        $error = $lock->getLastLockError();
        if ($error !== null) {
            DupLog::trace($error);
        }
    }
}
