<?php

namespace dokuwiki\plugin\extension;

use dokuwiki\Extension\PluginController;
use dokuwiki\Utf8\PhpString;
use RuntimeException;

class Extension implements \Stringable
{
    public const TYPE_PLUGIN = 'plugin';
    public const TYPE_TEMPLATE = 'template';

    /** @var string[] The types the API uses for plugin components */
    public const COMPONENT_TYPES = [
        1 => 'Syntax',
        2 => 'Admin',
        4 => 'Action',
        8 => 'Render',
        16 => 'Helper',
        32 => 'Template',
        64 => 'Remote',
        128 => 'Auth',
        256 => 'CLI',
        512 => 'CSS/JS-only',
    ];

    /** @var string[] List of plugin component file base names */
    public const COMPONENT_FILES = [
        'syntax',
        'admin',
        'action',
        'render',
        'helper',
        'remote',
        'auth',
        'cli',
    ];

    /** @var string "plugin"|"template" */
    protected string $type = self::TYPE_PLUGIN;

    /** @var string The base name of this extension */
    protected string $base;

    /** @var string The current location of this extension */
    protected string $currentDir = '';

    /** @var array The local info array of the extension */
    protected array $localInfo = [];

    /** @var array The remote info array of the extension */
    protected array $remoteInfo = [];

    /** @var Manager|null The manager for this extension */
    protected ?Manager $manager = null;

    // region Constructors

    /**
     * The main constructor is private to force the use of the factory methods
     */
    protected function __construct()
    {
    }

    /**
     * Initializes an extension from an id
     *
     * @param string $id The id of the extension
     * @return Extension
     */
    public static function createFromId($id)
    {
        $extension = new self();
        $extension->initFromId($id);
        return $extension;
    }

    protected function initFromId($id)
    {
        [$type, $base] = $this->idToTypeBase($id);
        $this->type = $type;
        $this->setBase($base);
        $this->readLocalInfo();
    }

    /**
     * Initializes an extension from a directory
     *
     * The given directory might be the one where the extension has already been installed to
     * or it might be the extracted source in some temporary directory.
     *
     * @param string $dir Where the extension code is currently located
     * @param string|null $type TYPE_PLUGIN|TYPE_TEMPLATE, null for auto-detection
     * @param string $base The base name of the extension, null for auto-detection
     * @return Extension
     */
    public static function createFromDirectory($dir, $type = null, $base = null)
    {
        $extension = new self();
        $extension->initFromDirectory($dir, $type, $base);
        return $extension;
    }

    protected function initFromDirectory($dir, $type = null, $base = null)
    {
        if (!is_dir($dir)) throw new RuntimeException('Directory not found: ' . $dir);
        $this->currentDir = fullpath($dir);

        if ($type === null || $type === self::TYPE_TEMPLATE) {
            if (
                file_exists($dir . '/template.info.txt') ||
                file_exists($dir . '/style.ini') ||
                file_exists($dir . '/main.php') ||
                file_exists($dir . '/detail.php') ||
                file_exists($dir . '/mediamanager.php')
            ) {
                $this->type = self::TYPE_TEMPLATE;
            }
        } else {
            $this->type = self::TYPE_PLUGIN;
        }

        $this->readLocalInfo();

        if ($base !== null) {
            $this->setBase($base);
        } elseif (isset($this->localInfo['base'])) {
            $this->setBase($this->localInfo['base']);
        } else {
            $this->setBase($this->getBaseFromClass($dir) ?: basename($dir));
        }
    }

    /**
     * Initializes an extension from remote data
     *
     * @param array $data The data as returned by the repository api
     * @return Extension
     */
    public static function createFromRemoteData($data)
    {
        $extension = new self();
        $extension->initFromRemoteData($data);
        return $extension;
    }

    protected function initFromRemoteData($data)
    {
        if (!isset($data['plugin'])) throw new RuntimeException('Invalid remote data');

        [$type, $base] = $this->idToTypeBase($data['plugin']);
        $this->remoteInfo = $data;
        $this->type = $type;
        $this->setBase($base);

        if ($this->isInstalled()) {
            $this->currentDir = $this->getInstallDir();
            $this->readLocalInfo();
        }
    }

    // endregion

    // region Getters

    /**
     * @param bool $wrap If true, the id is wrapped in backticks
     * @return string The extension id (same as base but prefixed with "template:" for templates)
     */
    public function getId($wrap = false)
    {
        if ($this->type === self::TYPE_TEMPLATE) {
            $id = self::TYPE_TEMPLATE . ':' . $this->base;
        } else {
            $id = $this->base;
        }
        if ($wrap) $id = "`$id`";
        return $id;
    }

    /**
     * Get the base name of this extension
     *
     * @return string
     */
    public function getBase()
    {
        return $this->base;
    }

    /**
     * Get the type of the extension
     *
     * @return string "plugin"|"template"
     */
    public function getType()
    {
        return $this->type;
    }

    /**
     * The current directory of the extension
     *
     * @return string|null
     */
    public function getCurrentDir()
    {
        // recheck that the current currentDir is still valid
        if ($this->currentDir && !is_dir($this->currentDir)) {
            $this->currentDir = '';
        }

        // if the extension is installed, then the currentDir is the install dir!
        if (!$this->currentDir && $this->isInstalled()) {
            $this->currentDir = $this->getInstallDir();
        }

        return $this->currentDir;
    }

    /**
     * Get the directory where this extension should be installed in
     *
     * Note: this does not mean that the extension is actually installed there
     *
     * @return string
     */
    public function getInstallDir()
    {
        if ($this->isTemplate()) {
            $dir = dirname(tpl_incdir()) . '/' . $this->base;
        } else {
            $dir = DOKU_PLUGIN . $this->base;
        }

        return fullpath($dir);
    }


    /**
     * Get the display name of the extension
     *
     * @return string
     */
    public function getDisplayName()
    {
        return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType()));
    }

    /**
     * Get the author name of the extension
     *
     * @return string Returns an empty string if the author info is missing
     */
    public function getAuthor()
    {
        return $this->getTag('author');
    }

    /**
     * Get the email of the author of the extension if there is any
     *
     * @return string Returns an empty string if the email info is missing
     */
    public function getEmail()
    {
        // email is only in the local data
        return $this->localInfo['email'] ?? '';
    }

    /**
     * Get the email id, i.e. the md5sum of the email
     *
     * @return string Empty string if no email is available
     */
    public function getEmailID()
    {
        if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid'];
        if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']);
        return '';
    }

    /**
     * Get the description of the extension
     *
     * @return string Empty string if no description is available
     */
    public function getDescription()
    {
        return $this->getTag(['desc', 'description']);
    }

    /**
     * Get the URL of the extension, usually a page on dokuwiki.org
     *
     * @return string
     */
    public function getURL()
    {
        return $this->getTag(
            'url',
            'https://www.dokuwiki.org/' .
            ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase()
        );
    }

    /**
     * Get the version of the extension that is actually installed
     *
     * Returns an empty string if the version is not available
     *
     * @return string
     */
    public function getInstalledVersion()
    {
        return $this->localInfo['date'] ?? '';
    }

    /**
     * Get the types of components this extension provides
     *
     * @return array int -> type
     */
    public function getComponentTypes()
    {
        // for installed extensions we can check the files
        if ($this->isInstalled()) {
            if ($this->isTemplate()) {
                return ['Template'];
            } else {
                $types = [];
                foreach (self::COMPONENT_TYPES as $type) {
                    $check = strtolower($type);
                    if (
                        file_exists($this->getInstallDir() . '/' . $check . '.php') ||
                        is_dir($this->getInstallDir() . '/' . $check)
                    ) {
                        $types[] = $type;
                    }
                }
                return $types;
            }
        }
        // still, here? use the remote info
        return $this->getTag('types', []);
    }

    /**
     * Get a list of extension ids this extension depends on
     *
     * @return string[]
     */
    public function getDependencyList()
    {
        return $this->getTag('depends', []);
    }

    /**
     * Get a list of extensions that are currently installed, enabled and depend on this extension
     *
     * @return Extension[]
     */
    public function getDependants()
    {
        $local = new Local();
        $extensions = $local->getExtensions();
        $dependants = [];
        foreach ($extensions as $extension) {
            if (
                in_array($this->getId(), $extension->getDependencyList()) &&
                $extension->isEnabled()
            ) {
                $dependants[$extension->getId()] = $extension;
            }
        }
        return $dependants;
    }

    /**
     * Return the minimum PHP version required by the extension
     *
     * Empty if not set
     *
     * @return string
     */
    public function getMinimumPHPVersion()
    {
        return $this->getTag('phpmin', '');
    }

    /**
     * Return the minimum PHP version supported by the extension
     *
     * @return string
     */
    public function getMaximumPHPVersion()
    {
        return $this->getTag('phpmax', '');
    }

    /**
     * Is this extension a template?
     *
     * @return bool false if it is a plugin
     */
    public function isTemplate()
    {
        return $this->type === self::TYPE_TEMPLATE;
    }

    /**
     * Is the extension installed locally?
     *
     * @return bool
     */
    public function isInstalled()
    {
        return is_dir($this->getInstallDir());
    }

    /**
     * Is the extension under git control?
     *
     * @return bool
     */
    public function isGitControlled()
    {
        if (!$this->isInstalled()) return false;
        return file_exists($this->getInstallDir() . '/.git');
    }

    /**
     * If the extension is bundled
     *
     * @return bool If the extension is bundled
     */
    public function isBundled()
    {
        $this->loadRemoteInfo();
        return $this->remoteInfo['bundled'] ?? in_array(
            $this->getId(),
            [
                'authad',
                'authldap',
                'authpdo',
                'authplain',
                'acl',
                'config',
                'extension',
                'info',
                'popularity',
                'revert',
                'safefnrecode',
                'styling',
                'testing',
                'usermanager',
                'logviewer',
                'template:dokuwiki'
            ]
        );
    }

    /**
     * Is the extension protected against any modification (disable/uninstall)
     *
     * @return bool if the extension is protected
     */
    public function isProtected()
    {
        // never allow deinstalling the current auth plugin:
        global $conf;
        if ($this->getId() == $conf['authtype']) return true;

        // disallow current template to be uninstalled
        if ($this->isTemplate() && ($this->getBase() === $conf['template'])) return true;

        /** @var PluginController $plugin_controller */
        global $plugin_controller;
        $cascade = $plugin_controller->getCascade();
        return ($cascade['protected'][$this->getId()] ?? false);
    }

    /**
     * Is the extension installed in the correct directory?
     *
     * @return bool
     */
    public function isInWrongFolder()
    {
        if (!$this->isInstalled()) return false;
        return $this->getInstallDir() != $this->currentDir;
    }

    /**
     * Is the extension enabled?
     *
     * @return bool
     */
    public function isEnabled()
    {
        global $conf;
        if ($this->isTemplate()) {
            return ($conf['template'] == $this->getBase());
        }

        /* @var PluginController $plugin_controller */
        global $plugin_controller;
        return $plugin_controller->isEnabled($this->base);
    }

    /**
     * Has the download URL changed since the last download?
     *
     * @return bool
     */
    public function hasChangedURL()
    {
        $last = $this->getManager()->getDownloadURL();
        if (!$last) return false;
        $url = $this->getDownloadURL();
        if (!$url) return false;
        return $last !== $url;
    }

    /**
     * Is an update available for this extension?
     *
     * @return bool
     */
    public function isUpdateAvailable()
    {
        if ($this->isBundled()) return false; // bundled extensions are never updated
        $self = $this->getInstalledVersion();
        $remote = $this->getLastUpdate();
        return $self < $remote;
    }

    // endregion

    // region Remote Info

    /**
     * Get the date of the last available update
     *
     * @return string yyyy-mm-dd
     */
    public function getLastUpdate()
    {
        return $this->getRemoteTag('lastupdate');
    }

    /**
     * Get a list of tags this extension is tagged with at dokuwiki.org
     *
     * @return string[]
     */
    public function getTags()
    {
        return $this->getRemoteTag('tags', []);
    }

    /**
     * Get the popularity of the extension
     *
     * This is a float between 0 and 1
     *
     * @return float
     */
    public function getPopularity()
    {
        return (float)$this->getRemoteTag('popularity', 0);
    }

    /**
     * Get the text of the update message if there is any
     *
     * @return string
     */
    public function getUpdateMessage()
    {
        return $this->getRemoteTag('updatemessage');
    }

    /**
     * Get the text of the security warning if there is any
     *
     * @return string
     */
    public function getSecurityWarning()
    {
        return $this->getRemoteTag('securitywarning');
    }

    /**
     * Get the text of the security issue if there is any
     *
     * @return string
     */
    public function getSecurityIssue()
    {
        return $this->getRemoteTag('securityissue');
    }

    /**
     * Get the URL of the screenshot of the extension if there is any
     *
     * @return string
     */
    public function getScreenshotURL()
    {
        return $this->getRemoteTag('screenshoturl');
    }

    /**
     * Get the URL of the thumbnail of the extension if there is any
     *
     * @return string
     */
    public function getThumbnailURL()
    {
        return $this->getRemoteTag('thumbnailurl');
    }

    /**
     * Get the download URL of the extension if there is any
     *
     * @return string
     */
    public function getDownloadURL()
    {
        return $this->getRemoteTag('downloadurl');
    }

    /**
     * Get the bug tracker URL of the extension if there is any
     *
     * @return string
     */
    public function getBugtrackerURL()
    {
        return $this->getRemoteTag('bugtracker');
    }

    /**
     * Get the URL of the source repository if there is any
     *
     * @return string
     */
    public function getSourcerepoURL()
    {
        return $this->getRemoteTag('sourcerepo');
    }

    /**
     * Get the donation URL of the extension if there is any
     *
     * @return string
     */
    public function getDonationURL()
    {
        return $this->getRemoteTag('donationurl');
    }

    /**
     * Get a list of extensions that are similar to this one
     *
     * @return string[]
     */
    public function getSimilarList()
    {
        return $this->getRemoteTag('similar', []);
    }

    /**
     * Get a list of extensions that are marked as conflicting with this one
     *
     * @return string[]
     */
    public function getConflictList()
    {
        return $this->getRemoteTag('conflicts', []);
    }

    /**
     * Get a list of DokuWiki versions this plugin is marked as compatible with
     *
     * @return string[][] date -> version
     */
    public function getCompatibleVersions()
    {
        return $this->getRemoteTag('compatible', []);
    }

    // endregion

    // region Actions

    /**
     * Install or update the extension
     *
     * @throws Exception
     */
    public function installOrUpdate()
    {
        $installer = new Installer(true);
        $installer->installExtension($this);
    }

    /**
     * Uninstall the extension
     * @throws Exception
     */
    public function uninstall()
    {
        $installer = new Installer(true);
        $installer->uninstall($this);
    }

    /**
     * Toggle the extension between enabled and disabled
     * @return void
     * @throws Exception
     */
    public function toggle()
    {
        if ($this->isEnabled()) {
            $this->disable();
        } else {
            $this->enable();
        }
    }

    /**
     * Enable the extension
     *
     * @throws Exception
     */
    public function enable()
    {
        (new Installer())->enable($this);
    }

    /**
     * Disable the extension
     *
     * @throws Exception
     */
    public function disable()
    {
        (new Installer())->disable($this);
    }

    // endregion

    // region Meta Data Management

    /**
     * Access the Manager for this extension
     *
     * @return Manager
     */
    public function getManager()
    {
        if (!$this->manager instanceof Manager) {
            $this->manager = new Manager($this);
        }
        return $this->manager;
    }

    /**
     * Reads the info file of the extension if available and fills the localInfo array
     */
    protected function readLocalInfo()
    {
        if (!$this->getCurrentDir()) return;
        $file = $this->currentDir . '/' . $this->type . '.info.txt';
        if (!is_readable($file)) return;
        $this->localInfo = confToHash($file, true);
        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
    }

    /**
     * Try to determine the extension's base name from a plugin class name
     *
     * We use this as a fallback for old plugins without an info file
     *
     * @param string $dir The directory where the extension is located
     * @return string
     */
    protected function getBaseFromClass($dir)
    {
        foreach (Extension::COMPONENT_FILES as $type) {
            $file = $dir . '/' . $type . '.php';
            if (!is_readable($file)) continue;
            $class = $this->getClassNameFromFile($file);
            if ($class === null) continue;
            if (preg_match('/' . $type . '_plugin_(\w+)/', $class, $matches)) {
                return $matches[1];
            }
        }
        return '';
    }

    /**
     * Fetches the remote info from the repository
     *
     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
     */
    protected function loadRemoteInfo()
    {
        if ($this->remoteInfo) return;
        $remote = Repository::getInstance();
        try {
            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
        } catch (Exception) {
            $this->remoteInfo = [];
        }
    }

    /**
     * Read information from either local or remote info
     *
     * Always prefers local info over remote info. Giving multiple keys is useful when the
     * key has been renamed in the past or if local and remote keys might differ.
     *
     * @param string|string[] $tag one or multiple keys to check
     * @param mixed $default
     * @return mixed
     */
    protected function getTag($tag, $default = '')
    {
        foreach ((array)$tag as $t) {
            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
        }

        return $this->getRemoteTag($tag, $default);
    }

    /**
     * Read information from remote info
     *
     * @param string|string[] $tag one or mutiple keys to check
     * @param mixed $default
     * @return mixed
     */
    protected function getRemoteTag($tag, $default = '')
    {
        $this->loadRemoteInfo();
        foreach ((array)$tag as $t) {
            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
        }
        return $default;
    }

    // endregion

    // region utilities

    /**
     * Set the base name of this extension
     *
     * Validates the base name to be a bare extension name (alphanumeric, underscores, hyphens, and dots)
     *
     * @param string $base
     * @throws RuntimeException if the base name is not a bare extension name
     */
    protected function setBase($base)
    {
        if (!preg_match('/^[a-z0-9][a-z0-9._-]*$/i', (string)$base)) {
            throw new RuntimeException('Invalid extension base name: ' . $base);
        }
        $this->base = $base;
    }

    /**
     * Convert an extension id to a type and base
     *
     * @param string $id
     * @return array [type, base]
     */
    protected function idToTypeBase($id)
    {
        [$type, $base] = sexplode(':', $id, 2);
        if ($base === null) {
            $base = $type;
            $type = self::TYPE_PLUGIN;
        } elseif ($type === self::TYPE_TEMPLATE) {
            $type = self::TYPE_TEMPLATE;
        } else {
            throw new RuntimeException('Invalid extension id: ' . $id);
        }

        return [$type, $base];
    }

    /**
     * Extract the class name from a file
     *
     * @param string $filePath
     * @return string|null
     */
    protected function getClassNameFromFile($filePath)
    {
        $code = file_get_contents($filePath);
        $tokens = token_get_all($code);

        for ($i = 0, $count = count($tokens); $i < $count; $i++) {
            if (is_array($tokens[$i]) && $tokens[$i][0] === T_CLASS) {
                // Skip whitespace/comments after T_CLASS
                $j = $i + 1;
                while (
                    isset($tokens[$j]) &&
                    is_array($tokens[$j]) &&
                    in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)
                ) {
                    $j++;
                }

                // The next token should be the class name
                if (isset($tokens[$j]) && is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
                    return $tokens[$j][1]; // Return class name
                }
            }
        }

        return null; // No class found
    }

    /**
     * @return string
     */
    public function __toString(): string
    {
        return $this->getId();
    }

    // endregion
}
