<?php

namespace Duplicator\Utils;

use Duplicator\Core\UniqueId;
use Duplicator\Models\DynamicGlobalEntity;
use Duplicator\Utils\Logging\DupLog;
use Exception;
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 false|resource */
    protected static $lockingFile = false;

    /** @var bool True while the current request holds the process lock */
    protected static $processLockAcquired = false;

    /**
     * Get unique site hash (8-char hex)
     *
     * Used for both SQL lock names and file lock paths to ensure
     * per-site uniqueness on shared servers.
     *
     * @return string 8-character hex hash
     */
    protected static function getSiteHash(): string
    {
        static $hash = null;
        if ($hash === null) {
            $hash = UniqueId::getInstance()->getShortId();
        }

        return $hash;
    }

    /**
     * Get site-specific SQL lock name
     *
     * GET_LOCK() is server-wide in MySQL, so a hardcoded name would cause
     * different WordPress sites on the same MySQL server to block each other.
     *
     * @return string Lock name unique to this WordPress installation
     */
    protected static function getSqlLockName(): string
    {
        return 'dupli_lock_' . self::getSiteHash();
    }

    /**
     * Get lock file path with unique hash based on site identifier
     *
     * This ensures the lock file is in a writable directory (backup storage)
     * and uses a unique name per WordPress installation
     *
     * @return string Lock file path
     */
    protected static function getLockFilePath(): string
    {
        static $lockFilePath = null;

        if ($lockFilePath === null) {
            $lockFilePath = DUPLICATOR_SSDIR_PATH . '/building_lock_' . self::getSiteHash() . '.tmp';
        }

        return $lockFilePath;
    }

    /**
     * Test if file locking is reliable on this filesystem
     *
     * Detects NFS and other distributed filesystems where flock() may not work properly.
     * On local filesystems (ext4, XFS, NTFS), flock() works correctly.
     * On NFS/CIFS, flock() may report success but not actually provide mutual exclusion.
     *
     * @return bool True if file locking is reliable
     */
    protected static function isFileLockReliable(): bool
    {
        $fp1 = false;
        $fp2 = false;

        try {
            // Ensure backup storage directory exists
            if (!wp_mkdir_p(DUPLICATOR_SSDIR_PATH)) {
                throw new Exception("Cannot create backup storage directory: " . DUPLICATOR_SSDIR_PATH);
            }

            // Use the actual lock file for testing (same file we'll use for real locks)
            $testFile = self::getLockFilePath();

            // Open first handle
            $fp1 = fopen($testFile, 'c+');
            if ($fp1 === false) {
                $error = error_get_last();
                throw new Exception("Cannot open test file: " . ($error['message'] ?? 'unknown error'));
            }

            // Write test data
            fwrite($fp1, 'test_' . time());
            fflush($fp1);

            // Acquire exclusive lock
            if (!flock($fp1, LOCK_EX | LOCK_NB)) {
                throw new Exception("Cannot acquire initial lock");
            }

            // CRITICAL TEST: Try to open same file from another handle
            // On reliable local filesystems, the second lock should FAIL
            // On NFS/CIFS, it might SUCCEED (indicating unreliable locking)
            $fp2 = fopen($testFile, 'r+');
            if ($fp2 !== false) {
                // Try to acquire exclusive lock on second handle
                $lockAcquired2 = flock($fp2, LOCK_EX | LOCK_NB);

                // If BOTH handles acquired exclusive lock → NFS/unreliable!
                if ($lockAcquired2) {
                    throw new Exception("File lock test: UNRELIABLE (both handles got exclusive lock - likely NFS/CIFS)");
                }
            }

            DupLog::trace("File lock test: Lock is reliable");
            $reliable = true;
        } catch (Throwable $e) {
            DupLog::trace("File lock test: ERROR - " . $e->getMessage());
            $reliable = false;
        } finally {
            // Guaranteed cleanup of all resources
            if ($fp2 !== false) {
                $lockAcquired2 ??= false;
                if ($lockAcquired2) {
                    flock($fp2, LOCK_UN);
                }
                fclose($fp2);
            }

            if ($fp1 !== false) {
                flock($fp1, LOCK_UN);
                fclose($fp1);
            }

            // Note: We don't delete the lock file - it will be reused for actual locking
        }

        return $reliable;
    }

    /**
     * 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. The
     * result is memoized per-request so lockProcess() and unlockProcess() always agree.
     *
     * @return int Lock mode ENUM self::LOCK_MODE_FILE or self::LOCK_MODE_SQL
     */
    public static function getLockMode(): int
    {
        $override = DynamicGlobalEntity::getInstance()->getValString(self::LOCK_MODE_OVERRIDE_KEY, 'auto');
        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.
     *
     * Must not be called while a build worker may hold the process lock: the file test
     * acquires the real lock file and a held lock reads as a false "unreliable".
     *
     * @return array{mode: int, sqlReliable: bool, fileReliable: bool}
     */
    public static function redetectLockMode(): array
    {
        $sqlReliable  = self::isSqlLockReliable();
        $fileReliable = self::isFileLockReliable();

        if ($sqlReliable) {
            $detected = self::LOCK_MODE_SQL;
        } elseif ($fileReliable) {
            $detected = self::LOCK_MODE_FILE;
        } else {
            DupLog::trace("Both locks have issues, defaulting to LOCK_MODE_FILE as last resort");
            $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);
    }

    /**
     * Test if SQL locking works on this database
     *
     * @return bool True if SQL lock can be acquired and verified
     */
    protected static function isSqlLockReliable(): bool
    {
        DupLog::trace("Testing SQL lock reliability...");

        $testName = 'dupli_test_' . self::getSiteHash();

        if (!self::getSqlLock($testName)) {
            return false;
        }

        $works = self::checkSqlLock($testName);
        self::releaseSqlLock($testName);

        return $works;
    }

    /**
     * Lock process
     *
     * Acquires a lock using the detected lock mode.
     * Returns false if the lock cannot be acquired.
     *
     * @return bool true if lock acquired
     */
    public static function lockProcess(): bool
    {
        switch (self::getLockMode()) {
            case self::LOCK_MODE_SQL:
                $acquired = self::getSqlLock(self::getSqlLockName());
                break;
            case self::LOCK_MODE_FILE:
            default:
                $acquired = self::getFileLock();
        }

        self::$processLockAcquired = $acquired;

        return $acquired;
    }

    /**
     * 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
    {
        if (!self::$processLockAcquired) {
            return true;
        }
        self::$processLockAcquired = false;

        switch (self::getLockMode()) {
            case self::LOCK_MODE_SQL:
                return self::releaseSqlLock(self::getSqlLockName());
            case self::LOCK_MODE_FILE:
            default:
                return self::releaseFileLock();
        }
    }

    /**
     * Get file lock
     *
     * Attempts to acquire an exclusive file lock.
     *
     * @return bool True if file lock acquired
     */
    protected static function getFileLock(): bool
    {
        $lockFilePath = self::getLockFilePath();

        if (
            self::$lockingFile === false &&
            (self::$lockingFile = fopen($lockFilePath, 'c+')) === false
        ) {
            $error = error_get_last();
            DupLog::trace(
                "Problem opening lock file at {$lockFilePath}: " .
                ($error['message'] ?? 'unknown error')
            );
            return false;
        }

        $acquired_lock = flock(self::$lockingFile, LOCK_EX | LOCK_NB);

        if (!$acquired_lock) {
            DupLog::trace("File lock denied: {$lockFilePath} (another process is running)");
        }

        return $acquired_lock;
    }

    /**
     * Release file lock
     *
     * @return bool True if file lock released
     */
    protected static function releaseFileLock(): bool
    {
        if (self::$lockingFile === false) {
            return true;
        }

        $success = true;
        if (!flock(self::$lockingFile, LOCK_UN)) {
            DupLog::trace("File lock can't release");
            $success = false;
        }

        if (fclose(self::$lockingFile) === false) {
            DupLog::trace("Can't close file lock file");
        }

        self::$lockingFile = false;

        return $success;
    }

    /**
     * Acquire an SQL lock
     *
     * @see releaseSqlLock()
     *
     * @param string $lock_name The lock name. Empty string uses the site-specific name.
     *
     * @return bool Returns true if an SQL lock request was successful
     */
    protected static function getSqlLock(string $lock_name = ''): bool
    {
        global $wpdb;

        if ($lock_name === '') {
            $lock_name = self::getSqlLockName();
        }

        $query_string = $wpdb->prepare("SELECT GET_LOCK(%s, 0)", $lock_name);
        $ret_val      = $wpdb->get_var($query_string);

        if ($ret_val == 0) {
            return false;
        } elseif ($ret_val == null) {
            DupLog::trace("Error retrieving mysql lock {$lock_name}");
            return false;
        }

        return true;
    }

    /**
     * Return true if SQL lock is held
     *
     * @param string $lock_name The lock name. Empty string uses the site-specific name.
     *
     * @return bool
     */
    protected static function checkSqlLock(string $lock_name = ''): bool
    {
        global $wpdb;

        if ($lock_name === '') {
            $lock_name = self::getSqlLockName();
        }

        $query_string = $wpdb->prepare("SELECT IS_USED_LOCK(%s)", $lock_name);
        $ret_val      = $wpdb->get_var($query_string);

        return $ret_val > 0;
    }

    /**
     * Release the SQL lock
     *
     * @see getSqlLock()
     *
     * @param string $lock_name The lock name. Empty string uses the site-specific name.
     *
     * @return bool
     */
    protected static function releaseSqlLock(string $lock_name = ''): bool
    {
        global $wpdb;

        if ($lock_name === '') {
            $lock_name = self::getSqlLockName();
        }

        $query_string = $wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name);
        $ret_val      = $wpdb->get_var($query_string);

        if ($ret_val == 0) {
            DupLog::trace("Failed releasing sql lock {$lock_name} because it wasn't established by this thread");
            return false;
        } elseif ($ret_val == null) {
            DupLog::trace("Tried to release sql lock {$lock_name} but it didn't exist");
            return false;
        }

        return true;
    }
}
