<?php
/** @noinspection SqlDialectInspection */

/**
 * Created by PhpStorm.
 * User: turgutsaricam
 * Date: 07/04/16
 * Time: 22:19
 */

namespace WPCCrawler\Services;

use WPCCrawler\Environment;
use WPCCrawler\Factory;
use WPCCrawler\Migrations\DatabaseMigrator;
use WPCCrawler\Objects\Database\UrlTuple;
use WPCCrawler\Utils;

class DatabaseService {

    /** @var string Holds the version of the database */
    private $dbVersion = "5.0";

    /** @var string Holds the option name used to store db version */
    const DB_VERSION_OPTION_NAME = '_wpcc_db_version';

    /** @var string Name for the URLs table */
    const DB_TABLE_URLS = 'wpcc_urls';

    public function __construct() {
        // Make sure the DB is up-to-date. This is important, because when the plugin is updated automatically from
        // the admin panel, activation hook is not called. So, we make sure that the DB is up-to-date every time
        // this class is constructed.
        if(version_compare($this->getDbVersion(), $this->dbVersion, '<')) $this->createDbTables();

        // Create database tables when the plugin is activated
        register_activation_hook(Utils::getPluginFilePath(), function($network_wide) {
            // Check if this is a multi-site environment
            if (is_multisite() && $network_wide) {
                // Create database tables for each blog
                global $wpdb;

                // store the current blog id
                $currentBlog = $wpdb->blogid;

                // Get all blogs in the network and activate plugin on each one
                $blogIds = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs");

                foreach ($blogIds as $blogId) {
                    switch_to_blog($blogId);

                    Factory::databaseService()->createDbTables();

                    restore_current_blog();
                }

            // If this is a single blog, just create the tables.
            } else {
                Factory::databaseService()->createDbTables();
            }

        });

        // Run the activation functions/methods for the plugin when a new blog is created on multi site environment
        add_action('wpmu_new_blog', function($blogId, $userId, $domain, $path, $siteId, $meta) {

            // Do this only if the plugin is already activated
            if(is_plugin_active_for_network(sprintf('%1$s' . DIRECTORY_SEPARATOR . '%1$s.php', Environment::pluginFileName()))) {
                switch_to_blog($blogId);

                Factory::databaseService()->createDbTables();

                restore_current_blog();
            }
        }, 10, 6);
    }

    /**
     * Get a URL from the database table, as object
     *
     * @param int $urlId
     * @return null|object
     */
    public function getUrlById($urlId) {
        if(!$urlId) return null;

        global $wpdb;
        $results = $wpdb->get_results($wpdb->prepare("SELECT * FROM " . $this->getDbTableUrlsName() . " WHERE id = %d", $urlId));

        if(!empty($results)) return $results[0];
        return null;
    }

    /**
     * Get a URL from the database table by saved post ID, as object
     *
     * @param int $savedPostId
     * @return null|UrlTuple
     */
    public function getUrlByPostId($savedPostId): ?UrlTuple {
        global $wpdb;
        $results = $wpdb->get_results($wpdb->prepare("SELECT * FROM " . $this->getDbTableUrlsName() . " WHERE saved_post_id = %d", $savedPostId));

        if(!empty($results)) return new UrlTuple($results[0]);
        return null;
    }

    /**
     * Get a URL from the database table by site ID and URL
     *
     * @param int $siteId
     * @param string $url
     * @return null|UrlTuple
     */
    public function getUrlBySiteIdAndUrl($siteId, $url): ?UrlTuple {
        global $wpdb;
        $results = $wpdb->get_results($wpdb->prepare("SELECT * FROM " . $this->getDbTableUrlsName() . " WHERE post_id = %d
            AND (url = %s OR url = %s) LIMIT 1", $siteId, trailingslashit($url), rtrim($url, "/")));

        if(!empty($results)) return new UrlTuple($results[0]);
        return null;
    }

    /**
     * Add a URL to the urls table
     *
     * @param int         $postId       ID of the custom post type (site)
     * @param string      $url          The URL
     * @param string|null $thumbnailUrl Thumbnail URL for the post
     * @param int         $categoryId   Category ID in which the URL will be added when crawled
     * @param bool        $isSaved
     * @return int|false ID of the inserted row
     */
    public function addUrl($postId, $url, $thumbnailUrl, $categoryId, $isSaved = false) {
        global $wpdb;

        // Check if this URL is already added
        $findQuery = "SELECT id FROM " . $this->getDbTableUrlsName() . " WHERE post_id = %d AND (url = %s OR url = %s)";
        $count = $wpdb->query($wpdb->prepare($findQuery, [$postId, trailingslashit($url), rtrim($url, "/")]));

        // If the URL is added, do not insert it again.
        if($count && $count > 0) return false;

        $wpdb->insert(
            $this->getDbTableUrlsName(),
            [
                'post_id'       =>  $postId,
                'url'           =>  $url,
                'thumbnail_url' =>  $thumbnailUrl,
                'category_id'   =>  $categoryId,
                'is_saved'      =>  $isSaved,
                'created_at'    =>  current_time('mysql'),
                'updated_at'    =>  current_time('mysql'),
            ]
        );

        return $wpdb->insert_id;
    }

    /**
     * Delete a URL from the URLs table
     *
     * @param int $urlId ID of the URL
     * @return false|int False on fail, 1 on success
     */
    public function deleteUrl($urlId) {
        global $wpdb;

        return $wpdb->delete(
            $this->getDbTableUrlsName(),
            [
                'id'    =>  $urlId
            ]
        );
    }

    /**
     * Update a URL's saved status
     *
     * @param int      $urlId       ID of the URL
     * @param bool     $saved       Whether the URL is saved as a post or not.
     * @param int|null $savedPostId ID of the saved post
     * @param int      $updateCount Number of times the post of this URL is updated
     * @param bool     $isLocked    Whether this tuple is locked or not.
     * @return false|int
     */
    public function updateUrlSavedStatus($urlId, $saved, $savedPostId = null, $updateCount = 0, $isLocked = false) {
        global $wpdb;

        return $wpdb->update(
            $this->getDbTableUrlsName(),
            [
                'is_saved'      =>  $saved,
                'saved_post_id' =>  $savedPostId,
                'updated_at'    =>  current_time('mysql'),
                'update_count'  =>  $updateCount,
                'is_locked'     =>  $isLocked
            ],
            [
                'id'        =>  $urlId
            ]
        );
    }

    /**
     * Update a URL's saved post ID
     *
     * @param int      $urlId       ID of the URL
     * @param int|null $savedPostId ID of the saved post
     * @return false|int
     */
    public function updateUrlSavedPostId($urlId, $savedPostId) {
        global $wpdb;

        return $wpdb->update(
            $this->getDbTableUrlsName(),
            [
                'saved_post_id' =>  $savedPostId,
                'updated_at'    =>  current_time('mysql'),
            ],
            [
                'id'        =>  $urlId
            ]
        );
    }

    /**
     * Update a URL's post saved at (saved_at) value as current time or null if $savedPostId is not valid
     *
     * @param int         $urlId       ID of the URL
     * @param int|null    $savedPostId ID of the saved post
     * @param string|null $savedAt     A date string formatted in MySQL date format. If null or empty, current time
     *                                 will be used.
     * @return false|int
     */
    public function updateUrlPostSavedAt($urlId, $savedPostId, $savedAt = null) {
        global $wpdb;

        return $wpdb->update(
            $this->getDbTableUrlsName(),
            [
                'saved_post_id' =>  $savedPostId,
                'saved_at'      =>  $savedPostId ? ($savedAt ? $savedAt : current_time('mysql')) : null,
            ],
            [
                'id'        =>  $urlId
            ]
        );
    }

    /**
     * Update a URL's post saved at (saved_at) value
     *
     * @param int|null    $savedPostId ID of the saved post
     * @param string|null $savedAt     A date string formatted in MySQL date format. If null or empty, current time
     *                                 will be used.
     * @return false|int
     */
    public function updateUrlPostSavedAtByPostId($savedPostId, $savedAt = null) {
        global $wpdb;

        return $wpdb->update(
            $this->getDbTableUrlsName(),
            [
                'saved_at' => $savedPostId ? ($savedAt ? $savedAt : current_time('mysql')) : null,
            ],
            [
                'saved_post_id' => $savedPostId
            ]
        );
    }

    /**
     * Update a URL's recrawled status by making sure is_saved is true
     *
     * @param int  $urlId       ID of the URL
     * @param int  $updateCount How many times this URL's post was recrawled
     * @param bool $isLocked    Whether this tuple is locked or not.
     * @return false|int
     */
    public function updateUrlRecrawledStatus($urlId, $updateCount, $isLocked = false) {
        global $wpdb;

        return $wpdb->update(
            $this->getDbTableUrlsName(),
            [
                'update_count'  =>  $updateCount,
                'updated_at'    =>  current_time('mysql'),
                'is_locked'     =>  $isLocked,
                'recrawled_at'  =>  current_time('mysql'),
                'is_saved'      =>  true,
            ],
            [
                'id'        =>  $urlId
            ]
        );
    }

    /**
     * Delete all URLs which belong to a specific site
     *
     * @param int $siteId ID of the site
     * @return false|int
     */
    public function deleteUrlsBySiteId($siteId) {
        global $wpdb;
        return $wpdb->delete($this->getDbTableUrlsName(), ['post_id' => $siteId], ['%d']);
    }

    /**
     * Delete all URLs which belong to a specific site by saved status
     *
     * @param int $siteId ID of the site
     * @param bool $isSaved True if you want to delete saved URLs, false otherwise
     * @return false|int
     */
    public function deleteUrlsBySiteIdAndSavedStatus($siteId, $isSaved) {
        global $wpdb;
        return $wpdb->delete($this->getDbTableUrlsName(), ['post_id' => $siteId, 'is_saved' => $isSaved ? 1 : 0], ['%d', '%d']);
    }

    /**
     * Delete all the URLs that belong to the updated posts that were created by a specific site
     *
     * @param int $siteId ID of the site
     * @return false|int
     * @since 1.14.0
     */
    public function deleteUpdatedUrlsBySiteId(int $siteId) {
        global $wpdb;
        $query = <<<SQL
            DELETE FROM {$this->getDbTableUrlsName()} 
            WHERE post_id = %d 
              AND saved_post_id IS NOT NULL 
              AND is_saved = TRUE
              AND update_count > 0
        SQL;

        return $wpdb->query($wpdb->prepare($query, $siteId));
    }

    /**
     * Delete all the URLs that belong to the deleted posts that were created by a specific site
     *
     * @param int $siteId ID of the site
     * @return false|int
     * @since 1.14.0
     */
    public function deleteDeletedUrlsBySiteId(int $siteId) {
        global $wpdb;
        $query = <<<SQL
            DELETE FROM {$this->getDbTableUrlsName()} 
            WHERE post_id = %d 
              AND saved_post_id IS NULL 
              AND deleted_at IS NOT NULL
        SQL;

        return $wpdb->query($wpdb->prepare($query, $siteId));
    }

    /**
     * Delete all the URLs that are categorized as "other"
     *
     * @param int $siteId ID of the site whose "other" URLs will be deleted
     * @return false|int
     * @since 1.14.0
     */
    public function deleteOtherUrlsBySiteId(int $siteId) {
        global $wpdb;

        $tableName = $this->getDbTableUrlsName();
        $query = <<<SQL
            DELETE FROM {$tableName}
            WHERE post_id = {$siteId}
              AND id NOT IN (
                -- IDs of the URLs in queue
                SELECT id 
                FROM {$tableName}
                WHERE post_id = {$siteId} 
                  AND saved_post_id IS NULL
                  AND is_saved = FALSE
                
                UNION
                
                -- IDs of the saved URLs
                SELECT id 
                FROM {$tableName}
                WHERE post_id = {$siteId} 
                  AND saved_post_id IS NOT NULL
                  AND is_saved = TRUE
                
                UNION
                
                -- IDs of the deleted URLs
                SELECT id 
                FROM {$tableName}
                WHERE post_id = {$siteId} 
                  AND saved_post_id IS NULL
                  AND deleted_at IS NOT NULL
            )
        SQL;

        return $wpdb->query($query);
    }

    /**
     * Make is_locked columns false for all URLs in the database.
     *
     * @return false|int
     */
    public function unlockAllUrls() {
        global $wpdb;
        $query = "UPDATE {$this->getDbTableUrlsName()} SET is_locked = FALSE WHERE is_locked = TRUE";
        return $wpdb->query($query);
    }

    /**
     * Sets delete_at column's value to now and sets saved_post_id to null for the URL found by the supplied post ID.
     * Also removes the lock on the URL.
     *
     * @param int|int[] $postId             ID of the post with whose URL will be set deleted. You can also supply an
     *                                      array of post IDs.
     * @return false|int
     */
    public function setUrlDeleted($postId) {
        if(!$postId) return 0;

        // Prepare
        $preparedIdsStr = '';

        if(is_array($postId)) {
            $preparedIds = [];
            foreach($postId as $id) $preparedIds[] = esc_sql((string) $id);

            // Remove empty values.
            $preparedIds = array_filter($preparedIds);
            if(!$preparedIds) return 0;

            // Get comma-separated string. We'll use this in query's IN clause.
            $preparedIdsStr = implode(", ", $preparedIds);

        } else {
            $preparedIdsStr = $postId;
        }

        // Make sure the string is not empty
        if(!$preparedIdsStr) return 0;

        global $wpdb;

        // Final query
        $query = "UPDATE {$this->getDbTableUrlsName()}
            SET saved_post_id = NULL, is_locked = FALSE, updated_at = %s, deleted_at = %s
            WHERE saved_post_id IN ({$preparedIdsStr})
        ";

        $now = current_time('mysql');
        return $wpdb->query($wpdb->prepare($query, $now, $now));
    }

    /**
     * Get old post IDs for a site.
     *
     * @param int $siteId             ID of the site whose posts should be found
     * @param int $olderThanInMinutes At least how many minutes old the post of a URL should be.
     *                                In other words, <b>now - olderThanInMin < postCreatedAt</b> will be searched for.
     * @param int $limit              How many posts at maximum can be returned
     * @return int[]
     */
    public function getOldPostIdsForSite($siteId, $olderThanInMinutes = 0, $limit = 0) {
        global $wpdb;

        // Prepare query parts
        $now = current_time('mysql');

        $olderThanInMinutes = (int) $olderThanInMinutes;
        $olderThanPart = $olderThanInMinutes && $olderThanInMinutes > 0 ?
            " AND saved_at < DATE_SUB('{$now}', INTERVAL {$olderThanInMinutes} MINUTE) " : "";

        $limit = (int) $limit;
        $limitPart = $limit && $limit > 0 ? " LIMIT {$limit} " : "";

        // Final query
        $query = "SELECT saved_post_id FROM {$this->getDbTableUrlsName()}
            WHERE saved_post_id IS NOT NULL
                AND post_id = %d
                AND is_locked = FALSE
                AND deleted_at IS NULL
            {$olderThanPart}
            ORDER BY saved_at ASC
            {$limitPart}
        ";

        $results = $wpdb->get_results($wpdb->prepare($query, $siteId));

        $ids = [];
        foreach($results as $res) $ids[] = $res->saved_post_id;

        return $ids;
    }

    /*
     *
     */

    public function getDbTableUrlsName(): string {
        global $wpdb;
        return $wpdb->prefix . static::DB_TABLE_URLS;
    }

    /**
     * @return string Current version of database in the site
     */
    public function getDbVersion() {
        return get_option(static::DB_VERSION_OPTION_NAME, "0.0");
    }

    /**
     * Create the database tables required for the plugin
     */
    public function createDbTables(): void {
        global $wpdb;

        $charsetCollate = $wpdb->get_charset_collate();

        $currentDbVersion = $this->getDbVersion();
        $tableUrls = $this->getDbTableUrlsName();

        /**
         * Query creating URLs table. This table will be used to store URLs to be crawled and already crawled. A way to
         * keep the track of the URLs. This way, we won't be adding a post URL more than once.
         *
         * IE11 allows max of 2083 chars for a URL. However, to be on the safe side, let's define URL field as 2560 chars.
         * @see http://stackoverflow.com/a/417184/2883487
         */
        $sql = "CREATE TABLE {$tableUrls} (
            id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            post_id bigint(20) NOT NULL,
            url varchar(2560) NOT NULL,
            thumbnail_url varchar(2560) DEFAULT NULL,
            category_id mediumint(9) NOT NULL,
            is_saved boolean NOT NULL DEFAULT FALSE,
            saved_post_id bigint(20) UNSIGNED,
            created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
            updated_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
            PRIMARY KEY  (id)
        ) $charsetCollate;";

        require_once(str_replace("/", DIRECTORY_SEPARATOR, (trailingslashit(ABSPATH) . Environment::adminDirName() . '/includes/upgrade.php')));
        dbDelta($sql);

        /**
         * Updates for DB version 2.0
         */
        if(version_compare($currentDbVersion, '2.0', '<')) {
            // created_at and updated_at can now be null. This is important to successfully modify the columns of the URLs
            // table. Otherwise, MySQL will tell you that the values of created_at and updated_at are invalid, when you
            // try to alter the table.

            // Adds saved_at column to store the time the post is created
            // Adds recrawled_at column to store the last time the post is recrawled
            // Adds update_count column to store how many times a post is updated.
            // Adds is_locked column to store a status. E.g. the tuple will be locked when it is being crawled/recrawled etc.
            $wpdb->query("ALTER TABLE {$tableUrls}
                CHANGE created_at created_at DATETIME NULL DEFAULT NULL,
                CHANGE updated_at updated_at DATETIME NULL DEFAULT NULL,
                ADD COLUMN saved_at DATETIME NULL DEFAULT NULL AFTER updated_at,
                ADD COLUMN recrawled_at DATETIME NULL DEFAULT NULL AFTER saved_at,
                ADD COLUMN update_count INT UNSIGNED NOT NULL DEFAULT '0' AFTER saved_post_id,
                ADD COLUMN is_locked TINYINT NOT NULL DEFAULT '0' AFTER update_count;");

            // Initialize saved_at columns by getting post_date values from wp_posts table
            $wpdb->query("UPDATE {$tableUrls}
                INNER JOIN {$wpdb->posts} on {$tableUrls}.saved_post_id = {$wpdb->posts}.ID
                SET {$tableUrls}.saved_at = {$wpdb->posts}.post_date WHERE {$tableUrls}.saved_post_id IS NOT NULL;");
        }

        /**
         * Updates for DB version 3.0
         */
        if(version_compare($currentDbVersion, '3.0', '<')) {
            // Adds deleted_at column to store the time the post is deleted
            $wpdb->query("ALTER TABLE {$tableUrls}
                ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL AFTER recrawled_at;");
        }

        // Apply all other migrations.
        (new DatabaseMigrator($this))->migrate();

        /**
         * Fires after all database tables are created and upgrades are made.
         *
         * @param string $currentDbVersion Current database version, such as '1.0', '2.4', etc. You can use
         *                                 version_compare function to compare the current version with another version,
         *                                 and decide if you need to make upgrades or create tables.
         * @since 1.6.3
         */
        do_action('wpcc/database/after_create', $currentDbVersion);

        // Store the version in the database to handle upgrades properly.
        update_option(static::DB_VERSION_OPTION_NAME, $this->dbVersion);
    }
}