<?php
namespace DevOwl\DeliverAnonymousAsset;

use MatthiasWeb\Utils\Activator;
use MatthiasWeb\Utils\Utils as UtilsUtils;
use WP_Filesystem_Direct;

/**
 * Use this to create your database tables and to create the instances
 * of `DeliverAnonymousAsset`.
 */
class AnonymousAssetBuilder {
    const TABLE_NAME = 'asset_seo_redirect';

    const OPTION_NAME_SERVE_HASH_SUFFIX = '-serve-hash';
    const OPTION_NAME_SERVE_NEXT_HASH_SUFFIX = '-serve-next-hash';

    const GENERATE_NEXT_HASH = 60 * 60 * 24 * 7;

    const MAX_SEO_REDIRECTS = 4;

    const COPY_EXTENSIONS = ['js', 'css'];

    private $table_name;

    private $optionNamePrefix;

    private $folder;

    /**
     * Cache of `Utils::contentDir`.
     *
     * @var string|false `false` when folder is not writable
     */
    private $contentDir;

    /**
     * The pool of collected built `DeliverAnonymousAsset` instances.
     *
     * @var DeliverAnonymousAsset[]
     */
    private $pool = [];

    /**
     * C'tor.
     *
     * @param string $table_name You can use it in conjunction with `TABLE_NAME` constant
     * @param string $optionNamePrefix
     * @param string $folder The absolute path to your original files
     */
    public function __construct($table_name, $optionNamePrefix, $folder) {
        $this->table_name = $table_name;
        $this->optionNamePrefix = $optionNamePrefix;
        $this->folder = $folder;
    }

    /**
     * Get the URL to the anonymous folder.
     */
    public function generateFolderSrc() {
        $anonymousFolder = $this->ensureAnonymousFolder(true);
        $contentDir = wp_normalize_path(constant('WP_CONTENT_DIR') . '/');
        $contentUrl = Utils::getContentUrl();
        return trailingslashit($contentUrl . substr($anonymousFolder, strlen($contentDir)));
    }

    /**
     * Create an anonymous asset. Do not forget to make it `->ready()` after you enqueued it!
     * This must be done in `wp` hook as it is the first available hook.
     *
     * @param string $handle
     * @param string $file
     * @param string $id If you pass an ID, the instance will be hold in this class pool and you can use `this::ready()`
     */
    public function build($handle, $file, $id = null) {
        $instance = new DeliverAnonymousAsset($this, $handle, $file);

        if ($id !== null) {
            $this->pool[$id] = $instance;
        }

        return $instance;
    }

    /**
     * Make a handle ready. Do not forget to `->build()` it previously!
     *
     * @param string $id
     * @param boolean $condition
     */
    public function ready($id, $condition = true) {
        if (isset($this->pool[$id]) && $condition) {
            $instance = $this->pool[$id];
            return $instance->ready();
        }

        return false;
    }

    /**
     * Get the currently used hash.
     */
    public function getHash() {
        $option = get_option($this->getOptionNamePrefix() . self::OPTION_NAME_SERVE_HASH_SUFFIX);
        $next = intval(get_option($this->getOptionNamePrefix() . self::OPTION_NAME_SERVE_NEXT_HASH_SUFFIX));
        if (empty($option) || $next === 0 || time() > $next) {
            $option = $this->updateHash();
        }

        return $option;
    }

    /**
     * Create the anonymous folder in `wp-content`. It also uses the original folder path basename (e.g. `dist` or `dev`)
     * as another subfolder, so it results in e.g. `/var/www/html/wp-content/7e9df1a92be399cb922305d5e7388ab1/dist/`.
     *
     * @param boolean|null $skipExistenceCheck
     */
    public function ensureAnonymousFolder($skipExistenceCheck = null) {
        $contentDir = $this->getContentDir();
        if (!$contentDir) {
            return false;
        }

        $hash = $this->getHash();
        $hashFolderPath = $contentDir . $hash . '/';
        $folder = $hashFolderPath . basename($this->getFolder()) . '/';

        if ($skipExistenceCheck === true) {
            return $folder;
        }

        // Already exists?
        if ($skipExistenceCheck === null && is_dir($folder)) {
            return true;
        }

        if (wp_mkdir_p($folder)) {
            // Create the anonymous files
            require_once ABSPATH . 'wp-admin/includes/file.php';

            // As we create the folder in `wp-content`, we could have wrong permission for the created
            // anonymous folder due to `wp_mkdir_p()` as it inherits the `chmod` from the parent folder
            UtilsUtils::runDirectFilesystem(function ($fs) use ($hashFolderPath, $folder) {
                /**
                 * WP_Filesystem_Direct.
                 *
                 *  @var WP_Filesystem_Direct
                 */
                $fs = $fs;
                $fs->chmod($hashFolderPath, constant('FS_CHMOD_DIR'));
                $fs->chmod($folder, constant('FS_CHMOD_DIR'));
            });

            $filesToCopy = array_filter(list_files($this->getFolder(), 1), function ($file) {
                $extension = pathinfo($file, PATHINFO_EXTENSION);
                return in_array($extension, self::COPY_EXTENSIONS, true);
            });
            foreach ($filesToCopy as $fileToCopy) {
                $filename = $folder . self::generateFilename($hash, $fileToCopy);
                file_put_contents($filename, Utils::readFileAndCorrectSourceMap($fileToCopy));

                UtilsUtils::runDirectFilesystem(function ($fs) use ($filename) {
                    /**
                     * WP_Filesystem_Direct.
                     *
                     *  @var WP_Filesystem_Direct
                     */
                    $fs = $fs;
                    $fs->chmod($filename, constant('FS_CHMOD_FILE'));
                });
            }
            return true;
        }

        return false;
    }

    /**
     * Generate a new hash for the current served JS file.
     */
    protected function updateHash() {
        global $wpdb;

        $hash = md5(wp_generate_uuid4());
        update_option($this->getOptionNamePrefix() . self::OPTION_NAME_SERVE_HASH_SUFFIX, $hash, true);
        update_option(
            $this->getOptionNamePrefix() . self::OPTION_NAME_SERVE_NEXT_HASH_SUFFIX,
            time() + self::GENERATE_NEXT_HASH,
            true
        );

        // Save in history
        $table_name = $this->getTableName();
        $wpdb->insert($table_name, [
            'serve_hash' => $hash,
            'created' => current_time('mysql'),
        ]);

        // Read all deleted hashes so `DeliverAnonymousAsset` can delete it
        $sql =
            "SELECT serve_hash FROM $table_name WHERE id NOT IN (SELECT id FROM (SELECT id FROM $table_name ORDER BY id DESC LIMIT " .
            self::MAX_SEO_REDIRECTS .
            ') foo);';

        // phpcs:disable WordPress.DB.PreparedSQL
        $deletedHashes = $wpdb->get_col($sql);
        // phpcs:enable WordPress.DB.PreparedSQL

        // Only hold x SEO redirects (https://stackoverflow.com/a/578926/5506547)
        $sql =
            "DELETE FROM $table_name WHERE id NOT IN (SELECT id FROM (SELECT id FROM $table_name ORDER BY id DESC LIMIT " .
            self::MAX_SEO_REDIRECTS .
            ') foo);';
        // phpcs:disable WordPress.DB.PreparedSQL
        $wpdb->query($sql);
        // phpcs:enable WordPress.DB.PreparedSQL

        // Unlink the old folder
        $this->purgeHashes($this->getContentDir(), $deletedHashes);

        /**
         * The JavaScript and CSS files, which were previously anonymous, have been rotated and their hashes have been updated.
         *
         * @see https://devowl.io/knowledge-base/real-cookie-banner-javascript-files-in-wp-content/
         * @hook DevOwl/DeliverAnonymousAsset/Update/$optionNamePrefix
         * @param {string[]} $deletedHashes
         * @param {AnonymousAssetBuilder} $builder
         */
        do_action('DevOwl/DeliverAnonymousAsset/Update/' . $this->getOptionNamePrefix(), $deletedHashes, $this);

        return $hash;
    }

    /**
     * Make sure the database table is created.
     *
     * @param Activator $activator
     */
    public function dbDelta($activator) {
        $charset_collate = $activator->getCharsetCollate();
        $table_name = $this->getTableName();

        $sql = "CREATE TABLE $table_name (
            id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            serve_hash char(32) NOT NULL,
            created datetime NOT NULL,
            PRIMARY KEY  (id)
        ) $charset_collate;";
        dbDelta($sql);

        // Force to update our assets cause updates can lead to new JavaScript files
        $this->forceRecreation();
    }

    /**
     * Force recreation of asset files.
     */
    public function forceRecreation() {
        update_option($this->getOptionNamePrefix() . self::OPTION_NAME_SERVE_NEXT_HASH_SUFFIX, 0);
    }

    /**
     * Getter.
     */
    public function getTableName() {
        global $wpdb;
        return empty($this->table_name) ? $wpdb->prefix . self::TABLE_NAME : $this->table_name;
    }

    /**
     * Getter.
     */
    public function getFolder() {
        return $this->folder;
    }

    /**
     * Getter.
     */
    public function getContentDir() {
        if ($this->contentDir === null) {
            $this->contentDir = Utils::getContentDir();
        }
        return $this->contentDir;
    }

    /**
     * Getter.
     *
     * @codeCoverageIgnore
     */
    public function getOptionNamePrefix() {
        return $this->optionNamePrefix;
    }

    /**
     * Generate the filename for a given original filename.
     *
     * @param string $hash The hash to use
     * @param string $originalFilenameOrPath
     */
    public static function generateFilename($hash, $originalFilenameOrPath) {
        $basename = basename($originalFilenameOrPath);
        $extension = pathinfo($basename, PATHINFO_EXTENSION);
        return UtilsUtils::simpleHash($hash . $basename) . '.' . $extension;
    }

    /**
     * Hashes got rotated and we can delete old folders from the filesystem.
     *
     * @param string $contentDir
     * @param string[] $hashes
     */
    public static function purgeHashes($contentDir, $hashes) {
        if ($contentDir !== false) {
            UtilsUtils::runDirectFilesystem(function ($fs) use ($hashes, $contentDir) {
                foreach ($hashes as $deletedHash) {
                    $fs->rmdir($contentDir . $deletedHash, true);
                }
            });
        }
    }

    /**
     * Remove the files from filesystem. Use this function in your `uninstall.php`.
     *
     * @param string $table_name
     */
    public static function uninstall($table_name) {
        global $wpdb;

        $contentDir = Utils::getContentDir();
        if (!$contentDir) {
            return;
        }

        $sql = "SELECT serve_hash FROM $table_name";

        // phpcs:disable WordPress.DB.PreparedSQL
        $hashes = $wpdb->get_col($sql);
        // phpcs:enable WordPress.DB.PreparedSQL

        self::purgeHashes($contentDir, $hashes);
    }
}
