<?php

/** *********************************************
 * LiteSpeed Web Server Cache Manager
 *
 * @author    Michael Alegre
 * @copyright 2018-2025 LiteSpeed Technologies, Inc.
 * *******************************************
 */

namespace Lsc\Wp;

use Lsc\Wp\Context\Context;

class PluginVersion
{

    /**
     * @var string
     */
    const DEFAULT_PLUGIN_PATH = '/wp-content/plugins/litespeed-cache';

    /**
     * @deprecated 1.9
     * @var string
     */
    const DOWNLOAD_DIR = '/usr/src/litespeed-wp-plugin';

    /**
     * @var string  Old conf file name.
     */
    const LSCWP_DEFAULTS_INI_FILE_NAME = 'const.default.ini';

    /**
     * @since 1.17.5
     * @var   string  New conf file name.
     */
    const LSCWP_DEFAULTS_JSON_FILE_NAME = 'const.default.json';

    /**
     * @var string
     */
    const PLUGIN_NAME = 'litespeed-cache';

    /**
     * @var string
     */
    const TRANSLATION_CHECK_FLAG_BASE = '.ls_translation_check';

    /**
     * @var string
     */
    const VER_MD5 = 'lscwp_md5';

    /**
     * @deprecated 4.1.3  Will be removed in favor of $this->shortVersions.
     * @var string[]
     */
    protected $knownVersions = [];

    /**
     * @since 4.1.3
     * @var string[]
     */
    protected $shortVersions = [];

    /**
     * @var string[]
     */
    protected $allowedVersions = array();

    /**
     * @var string
     */
    protected $currVersion = '';

    /**
     * @var string
     */
    protected $versionFile;

    /**
     * @var string
     */
    protected $activeFile;

    /**
     * @var null|PluginVersion
     */
    protected static $instance;

    /**
     *
     * @throws LSCMException  Thrown indirectly by $this->init() call.
     */
    protected function __construct()
    {
        $this->init();
    }

    /**
     *
     * @throws LSCMException  Thrown indirectly by Context::isPrivileged() call.
     * @throws LSCMException  Thrown indirectly by $this->refreshVersionList()
     *     call.
     */
    protected function init()
    {
        $this->setVersionFiles();

        if ( Context::isPrivileged() ) {
            $this->refreshVersionList();
        }
    }

    /**
     *
     * @return PluginVersion
     *
     * @throws LSCMException  Thrown indirectly by Context::getOption() call.
     */
    public static function getInstance()
    {
        if ( self::$instance == null ) {
            $className = Context::getOption()->getLscwpVerClass();

            self::$instance = new $className();
        }

        return self::$instance;
    }

    /**
     *
     * @deprecated 4.1.3  Use "$formatted = true" equivalent function
     *     $this->getShortVersions() instead. Un-formatted version of this list
     *     will no longer be available once this function is removed.
     *
     * @param bool $formatted
     *
     * @return string[]
     *
     * @throws LSCMException  Thrown indirectly by $this->setKnownVersions()
     *     call.
     */
    public function getKnownVersions( $formatted = false )
    {
        if ( empty($this->knownVersions) ) {
            $this->setKnownVersions();
        }

        if ( $formatted ) {
            $knownVers = $this->knownVersions;

            $prevVer = '';

            foreach ( $knownVers as &$ver ) {

                if ( $prevVer !== '' ) {
                    $ver1 = explode('.', $prevVer);
                    $ver2 = explode('.', $ver);

                    if ( $ver1[0] !== $ver2[0] || $ver1[1] !== $ver2[1] ) {
                        $ver = "$ver2[0].$ver2[1].x";
                    }
                }

                $prevVer = $ver;
            }

            return $knownVers;
        }

        return $this->knownVersions;
    }

    /**
     *
     * @return string[]
     *
     * @throws LSCMException  Thrown indirectly by $this->setAllowedVersions()
     *     call.
     */
    public function getAllowedVersions()
    {
        if ( empty($this->allowedVersions) ) {
            $this->setAllowedVersions();
        }

        return $this->allowedVersions;
    }

    /**
     *
     * @since 4.1.3
     *
     * @return string[]
     *
     * @throws LSCMException  Thrown indirectly by $this->setShortVersions()
     *     call.
     */
    public function getShortVersions()
    {
        if ( empty($this->shortVersions) ) {
            $this->setShortVersions();
        }

        return $this->shortVersions;
    }

    /**
     *
     * @return string
     *
     * @throws LSCMException  Thrown indirectly by $this->getAllowedVersions()
     *     call.
     */
    public function getLatestVersion()
    {
        $allowedVers = $this->getAllowedVersions();

        return (isset($allowedVers[0])) ? $allowedVers[0] : '';
    }

    /**
     *
     * @return string
     *
     * @throws LSCMException  Thrown indirectly by self::getInstance() call.
     * @throws LSCMException  Thrown indirectly by
     *     $instance->setCurrentVersion() call.
     */
    public static function getCurrentVersion()
    {
        $instance = self::getInstance();

        if ( $instance->currVersion == '' ) {
            $instance->setCurrentVersion();
        }

        return $instance->currVersion;
    }

    /**
     *
     * @since 1.9
     */
    protected function createDownloadDir()
    {
        mkdir(Context::LOCAL_PLUGIN_DIR, 0755);
    }

    protected function setVersionFiles()
    {
        $this->versionFile = Context::LOCAL_PLUGIN_DIR . '/lscwp_versions_v2';
        $this->activeFile  =
            Context::LOCAL_PLUGIN_DIR . '/lscwp_active_version';
    }

    /**
     * Temporary function for handling the active version file and versions
     * file move in the short term.
     *
     * @deprecated 1.9
     * @since 1.9
     *
     * @throws LSCMException  Thrown indirectly by Context::getLSCMDataDir()
     *     call.
     */
    protected function checkOldVersionFiles()
    {
        $dataDir       = Context::getLSCMDataDir();
        $oldActiveFile = "$dataDir/lscwp_active_version";

        if ( file_exists($oldActiveFile) ) {

            if ( !file_exists(Context::LOCAL_PLUGIN_DIR) ) {
                $this->createDownloadDir();
            }

            rename($oldActiveFile, $this->activeFile);
            unlink("$dataDir/lscwp_versions");
        }
    }

    /**
     *
     * @throws LSCMException  Thrown indirectly by $this->checkOldVersionFiles()
     *     call.
     * @throws LSCMException  Thrown indirectly by Logger::debug() call.
     * @throws LSCMException  Thrown indirectly by Logger::debug() call.
     * @throws LSCMException  Thrown indirectly by Logger::debug() call.
     */
    public function setCurrentVersion()
    {
        $this->checkOldVersionFiles();

        if ( ($activeVersion = $this->getActiveVersion()) == '' ) {
            Logger::debug('Active LSCWP version not found.');
        }
        elseif ( ! $this->hasDownloadedVersion($activeVersion) ) {
            $activeVersion = '';
            unlink($this->activeFile);
            Logger::debug('Valid LSCWP download not found.');
        }

        if ( $activeVersion == '' ) {

            try {
                $activeVersion = self::getLatestVersion();
            }
            catch ( LSCMException $e ) {
                Logger::debug($e->getMessage());
            }
        }

        $this->currVersion = $activeVersion;
    }

    /**
     *
     * @since 1.11
     *
     * @return string
     */
    private function getActiveVersion()
    {
        if (
                file_exists($this->activeFile)
                &&
                ($content = file_get_contents($this->activeFile))
        ) {
            return trim($content);
        }

        return '';
    }

    /**
     *
     * @deprecated 4.1.3  This function will be removed in favor of
     *     pre-formatted $this->setShortVersions().
     *
     * @throws LSCMException  Thrown when LSCWP version list cannot be found.
     * @throws LSCMException  Thrown when read LSCWP version list command fails.
     * @throws LSCMException  Thrown when match against LSCWP version list
     *     content fails.
     */
    protected function setKnownVersions()
    {
        if ( !file_exists($this->versionFile) ) {
            throw new LSCMException(
                'Cannot find LSCWP version list.',
                LSCMException::E_NON_FATAL
            );
        }

        if ( ($content = file_get_contents($this->versionFile)) === false ) {
            throw new LSCMException(
                'Failed to read LSCWP version list content.',
                LSCMException::E_NON_FATAL
            );
        }

        if ( preg_match('/old\s{(.*)}/sU', trim($content), $m) != 1 ) {
            throw new LSCMException(
                'Failed to get known versions from LSCWP version list content.',
                LSCMException::E_NON_FATAL
            );
        }

        $this->knownVersions = explode("\n", trim($m[1]));
    }

    /**
     *
     * @throws LSCMException  Thrown when LSCWP version list cannot be found.
     * @throws LSCMException  Thrown when read LSCWP version list command fails.
     * @throws LSCMException  Thrown when LSCWP version list content is empty.
     */
    protected function setAllowedVersions()
    {
        if ( !file_exists($this->versionFile) ) {
            throw new LSCMException(
                'Cannot find LSCWP version list.',
                LSCMException::E_NON_FATAL
            );
        }

        if ( ($content = file_get_contents($this->versionFile)) === false ) {
            throw new LSCMException(
                'Failed to read LSCWP version list content.',
                LSCMException::E_NON_FATAL
            );
        }

        if (
                !preg_match('/allowed\s{(.*)}/sU', trim($content), $m)
                ||
                ($list = trim($m[1])) == ''
        ) {
            throw new LSCMException(
                'LSCWP version list is empty.',
                LSCMException::E_NON_FATAL
            );
        }

        $this->allowedVersions = explode("\n", $list);
    }

    /**
     *
     * @since 4.1.3
     *
     * @throws LSCMException  Thrown when LSCWP version list cannot be found.
     * @throws LSCMException  Thrown when read LSCWP version list command fails.
     * @throws LSCMException  Thrown when LSCWP version list content is empty.
     */
    protected function setShortVersions()
    {
        if ( !file_exists($this->versionFile) ) {
            throw new LSCMException(
                'Cannot find LSCWP version list.',
                LSCMException::E_NON_FATAL
            );
        }

        if ( ($content = file_get_contents($this->versionFile)) === false ) {
            throw new LSCMException(
                'Failed to read LSCWP version list content.',
                LSCMException::E_NON_FATAL
            );
        }

        if (
                !preg_match('/short\s{(.*)}/sU', trim($content), $m)
                ||
                ($list = trim($m[1])) == ''
        ) {
            throw new LSCMException(
                'LSCWP version list is empty.',
                LSCMException::E_NON_FATAL
            );
        }

        $this->shortVersions = explode("\n", $list);

    }

    /**
     *
     * @param string $version  Valid LSCWP version.
     * @param bool   $init     True when trying to set initial active version.
     *
     * @throws LSCMException  Thrown indirectly by $this->getAllowedVersions()
     *     call.
     * @throws LSCMException  Thrown indirectly by Logger::error() call.
     * @throws LSCMException  Thrown indirectly by $this->downloadVersion()
     *     call.
     * @throws LSCMException  Thrown indirectly by Logger::notice() call.
     */
    public function setActiveVersion( $version, $init = false )
    {
        $allowedVers = $this->getAllowedVersions();

        if ( in_array($version, $allowedVers) ) {
            $activeVer = $version;
        }
        else {

            try {
                $currVer = ($init) ? '' : $this->getCurrentVersion();
            }
            catch ( LSCMException $e ) {
                $currVer = '';
            }

            if ( $currVer != '' ) {
                $activeVer = $currVer;
            }
            else {
                $activeVer = $allowedVers[0];
            }

            Logger::error(
                "Version $version not in allowed list, reset active "
                    . "version to $activeVer."
            );
        }

        if ( $activeVer != $this->getActiveVersion() ) {

            if ( !$this->hasDownloadedVersion($activeVer) ) {
                $this->downloadVersion($activeVer);
            }

            $this->currVersion = $activeVer;
            file_put_contents($this->activeFile, $activeVer);
            Logger::notice("Current active LSCWP version is now $activeVer.");
        }
    }

    /**
     *
     * @param bool $isForced
     *
     * @throws LSCMException  Thrown indirectly by Logger::info() call.
     */
    protected function refreshVersionList( $isForced = false )
    {
        clearstatcache();

        if (
                $isForced
                ||
                !file_exists($this->versionFile)
                ||
                (time() - filemtime($this->versionFile)) > 86400
        ) {

            if ( !file_exists(Context::LOCAL_PLUGIN_DIR) ) {
                $this->createDownloadDir();
            }

            $url = 'https://www.litespeedtech.com/packages/lswpcache'
                . '/version_list_v2';

            $content = Util::get_url_contents($url);

            if ( empty($content) || substr($content, 0, 7) != 'allowed' ) {
                /**
                 * Try again using cli curl directly to bypass potential
                 * reCAPTCHA issues.
                 */
                $content = Util::getUrlContentsUsingExecCurl($url);

                if ( empty($content) || substr($content, 0, 7) != 'allowed' ) {
                    touch($this->versionFile);
                    return;
                }
            }

            file_put_contents($this->versionFile, $content);
            Logger::info('LSCache for WordPress version list updated');
        }
    }

    /**
     * Filter out any versionList versions that do not meet specific criteria.
     *
     * @deprecated 4.1.3  No longer used.
     *
     * @param string $ver  Version string.
     *
     * @return bool
     */
    protected function filterVerList( $ver )
    {
        return Util::betterVersionCompare($ver, '1.2.2', '>');
    }

    /**
     *
     * @since 1.17.5
     *
     * @param string $pluginDir  The WordPress plugin directory.
     */
    public static function tryCopyLscwpDefaultConf( $pluginDir )
    {
        foreach (
            [
                self::LSCWP_DEFAULTS_INI_FILE_NAME,
                self::LSCWP_DEFAULTS_JSON_FILE_NAME
            ]
            as
            $customConfFileName
        ) {
            $customConfFilePath =
                Context::LOCAL_PLUGIN_DIR . "/$customConfFileName";

            if ( file_exists($customConfFilePath) ) {
                copy(
                    $customConfFilePath,
                    "$pluginDir/litespeed-cache/data/$customConfFileName"
                );
            }
        }
    }

    /**
     * Checks the current installation for existing LSCWP plugin files and
     * copies them to the installation's plugins directory if not found.
     * This function should only be run as the user.
     *
     * @param string $pluginDir  The WordPress plugin directory.
     * @param string $version    The version of LSCWP to be used when copying
     *     over plugin files.
     *
     * @return bool  True when new LSCWP plugin files are used.
     *
     * @throws LSCMException  Thrown when LSCWP source package is not available
     *     for the provided version.
     * @throws LSCMException  Thrown when LSCWP plugin files could not be copied
     *     to plugin directory.
     * @throws LSCMException  Thrown indirectly by self::getInstance() call.
     * @throws LSCMException  Thrown indirectly by
     *     $instance->getCurrentVersion() call.
     * @throws LSCMException  Thrown indirectly by Logger::debug() call.
     */
    public static function prepareUserInstall( $pluginDir, $version = '' )
    {
        $lscwp_plugin = "$pluginDir/litespeed-cache/litespeed-cache.php";

        if ( file_exists($lscwp_plugin) ) {
            /**
             * Existing installation detected.
             */
            return false;
        }

        $instance = self::getInstance();

        if ( $version == '' ) {
            $version = $instance->getCurrentVersion();
        }

        if ( !$instance->hasDownloadedVersion($version) ) {
            throw new LSCMException(
                "Source Package not available for version $version.",
                LSCMException::E_NON_FATAL
            );
        }

        exec(
            '/bin/cp --preserve=mode -rf '
                . Context::LOCAL_PLUGIN_DIR
                . "/$version/"
                . self::PLUGIN_NAME
                . " $pluginDir"
        );

        if ( !file_exists($lscwp_plugin) ) {
            throw new LSCMException(
                "Failed to copy plugin files to $pluginDir.",
                LSCMException::E_NON_FATAL
            );
        }

        self::tryCopyLscwpDefaultConf($pluginDir);

        Logger::debug(
            'Copied LSCache for WordPress plugin files into plugins directory '
                . $pluginDir
        );

        return true;
    }

    /**
     *
     * @param string $version
     *
     * @return bool
     */
    protected function hasDownloadedVersion( $version )
    {
        $dir     = Context::LOCAL_PLUGIN_DIR . "/$version";
        $md5file = "$dir/" . self::VER_MD5;
        $plugin  = "$dir/" . self::PLUGIN_NAME;

        if ( !file_exists($md5file) || !is_dir($plugin) ) {
            return false;
        }

        return (file_get_contents($md5file) == Util::DirectoryMd5($plugin));
    }

    /**
     *
     * @param string $version
     * @param string $dir
     * @param bool   $saveMD5
     *
     * @throws LSCMException  Thrown when wget command for downloaded LSCWP
     *     version fails.
     * @throws LSCMException  Thrown when unable to unzip LSCWP zip file.
     * @throws LSCMException  Thrown when unzipped LSCWP files do not contain
     *     expected test file.
     * @throws LSCMException  Thrown indirectly by Logger::info() call.
     * @throws LSCMException  Thrown indirectly by Util::unzipFile() call.
     */
    protected function wgetPlugin( $version, $dir, $saveMD5 = false )
    {
        Logger::info("Downloading LSCache for WordPress v$version...");

        $zipFile = self::PLUGIN_NAME . ".$version.zip";

        exec(
            'wget -q --tries=1 --no-check-certificate '
                . "https://downloads.wordpress.org/plugin/$zipFile -P $dir",
            $output,
            $return_var
        );

        if ( $return_var !== 0 ) {
            throw new LSCMException(
                "Failed to download LSCWP v$version with wget exit status "
                    . "$return_var.",
                LSCMException::E_NON_FATAL
            );
        }

        $localZipFile = "$dir/$zipFile";

        $extractedZip = Util::unzipFile($localZipFile, $dir);
        unlink($localZipFile);

        if ( !$extractedZip ) {
            throw new LSCMException(
                "Unable to unzip $localZipFile",
                LSCMException::E_NON_FATAL
            );
        }

        $plugin = "$dir/" . self::PLUGIN_NAME;

        if ( !file_exists("$plugin/" . self::PLUGIN_NAME . '.php') ) {
            throw new LSCMException(
                "Test file not found. Downloaded LSCWP v$version is invalid.",
                LSCMException::E_NON_FATAL
            );
        }

        if ( $saveMD5 ) {
            file_put_contents(
                "$dir/" . self::VER_MD5,
                Util::DirectoryMd5($plugin)
            );
        }
    }

    /**
     *
     * @param string $version
     *
     * @throws LSCMException  Thrown when download dir could not be created.
     * @throws LSCMException  Thrown indirectly by $this->wgetPlugin() call.
     */
    protected function downloadVersion( $version )
    {
        $dir = Context::LOCAL_PLUGIN_DIR . "/$version";

        if ( !file_exists($dir) ) {

            if ( !mkdir($dir, 0755, true) ) {
                throw new LSCMException(
                    "Failed to create download dir $dir.",
                    LSCMException::E_NON_FATAL
                );
            }
        }
        else {
            exec("/bin/rm -rf $dir/*");
        }

        $this->wgetPlugin($version, $dir, true);
    }

    /**
     *
     * @param string $locale
     * @param string $pluginVer
     *
     * @return bool
     *
     * @throws LSCMException  Thrown indirectly by Logger::info() call.
     * @throws LSCMException  Thrown indirectly by Util::unzipFile() call.
     */
    public static function retrieveTranslation( $locale, $pluginVer )
    {
        Logger::info(
            "Downloading LSCache for WordPress $locale translation..."
        );

        $translationDir =
            Context::LOCAL_PLUGIN_DIR . "/$pluginVer/translations";

        if ( !file_exists($translationDir) ) {
            mkdir($translationDir, 0755);
        }

        touch(
            "$translationDir/" . self::TRANSLATION_CHECK_FLAG_BASE . "_$locale"
        );

        /**
         * downloads.wordpress.org looks to always return a '200 OK' status,
         * even when serving a 404 page. As such invalid downloads can only be
         * checked through user failure to unzip through WP func unzip_file()
         * as we do not assume that root has the ability to unzip.
         */
        exec(
            'wget -q --tries=1 --no-check-certificate '
                . 'https://downloads.wordpress.org/translation/plugin/'
                . "litespeed-cache/$pluginVer/$locale.zip -P $translationDir",
            $output,
            $return_var
        );

        if ( $return_var !== 0 ) {
            return false;
        }

        /**
         * The WordPress user can unzip for us if this call fails.
         */
        Util::unzipFile("$translationDir/$locale.zip", $translationDir);

        return true;
    }

    /**
     *
     * @param string $locale
     * @param string $pluginVer
     *
     * @throws LSCMException  Thrown indirectly by Logger::info() call.
     */
    public static function removeTranslationZip( $locale, $pluginVer )
    {
        Logger::info("Removing LSCache for WordPress $locale translation...");

        $zipFile = realpath(
            Context::LOCAL_PLUGIN_DIR . "/$pluginVer/translations/$locale.zip"
        );

        if (
            substr($zipFile, 0, strlen(Context::LOCAL_PLUGIN_DIR))
                ===
                Context::LOCAL_PLUGIN_DIR
        ) {
            unlink($zipFile);
        }
    }

}
