<?php

namespace Appwrite\Migration\Version;

use Appwrite\Migration\Migration;
use Utopia\App;
use Utopia\CLI\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;

class V12 extends Migration
{
    /**
     * @var \PDO $pdo
     */
    private $pdo;

    public function execute(): void
    {
        global $register;
        Console::log('Migrating project: ' . $this->project->getAttribute('name') . ' (' . $this->project->getId() . ')');

        $this->pdo = $register->get('db');

        Console::info('Migrating Project Schemas');
        $this->migrateProjectSchema($this->project->getId());

        /**
         * Switch to migrated Console Project
         */
        if ($this->project->getId() === 'console') {
            $this->consoleDB->setNamespace('_console');
            $this->projectDB->setNamespace('_console');
        }

        Console::info('Migrating Permissions');
        $this->fixPermissions();
        Console::info('Migrating Collections');
        $this->migrateCustomCollections();
        $this->fixCollections();
        Console::info('Migrating Documents');
        $this->forEachDocument([$this, 'fixDocument']);
    }

    /**
     * Migrate Project Tables.
     *
     * @param string $projectId
     * @return void
     * @throws \Exception
     * @throws \PDOException
     */
    private function migrateProjectSchema(string $projectId): void
    {
        /**
         * Remove empty generated Console Project.
         */
        if ($this->consoleDB->getNamespace() === '_project_console' && $projectId === 'console') {
            $all = ['_console_bucket_1', '_console_bucket_1_perms'];
            foreach ($this->collections as $collection) {
                $all[] = "_{$projectId}_{$collection['$id']}";
                $all[] = "_{$projectId}_{$collection['$id']}_perms";
            }
            $this->pdo->prepare('DROP TABLE IF EXISTS ' . implode(', ', $all) . ';')->execute();
        } elseif ($this->projectDB->getNamespace() === '_console') {
            return;
        }

        /**
         * Rename Database Tables.
         */
        foreach ($this->collections as $collection) {
            $id = $collection['$id'];

            /**
             * Skip new tables that don't exists on old schema.
             */
            if (in_array($id, ['buckets', 'deployments', 'builds'])) {
                continue;
            }

            $this->pdo->prepare("ALTER TABLE IF EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_project_{$projectId}_{$id}` RENAME TO `_{$projectId}_{$id}`")->execute();
            $this->pdo->prepare("CREATE TABLE IF NOT EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_{$projectId}_{$id}_perms` (
                `_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
                `_type` VARCHAR(12) NOT NULL,
                `_permission` VARCHAR(255) NOT NULL,
                `_document` VARCHAR(255) NOT NULL,
                PRIMARY KEY (`_id`),
                UNIQUE INDEX `_index1` (`_type`,`_document`,`_permission`),
                INDEX `_index2` (`_permission`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;")->execute();
        }
    }

    /**
     * Migrate all Collection Structure.
     *
     * @return void
     */
    protected function fixCollections(): void
    {
        foreach ($this->collections as $collection) {
            $id = $collection['$id'];

            /**
             * Skip new tables that don't exists on old schema.
             */
            if (in_array($id, ['buckets', 'deployments', 'builds'])) {
                continue;
            }
            Console::log("- {$id}");
            switch ($id) {
                case 'sessions':
                    try {
                        /**
                         * Rename providerToken to providerAccessToken
                         */
                        $this->projectDB->renameAttribute($id, 'providerToken', 'providerAccessToken');
                    } catch (\Throwable $th) {
                        Console::warning("'providerAccessToken' from {$id}: {$th->getMessage()}");
                    }
                    try {
                        /**
                         * Create providerRefreshToken
                         */
                        $this->projectDB->createAttribute(collection: $id, id: 'providerRefreshToken', type: Database::VAR_STRING, size: 16384, signed: true, required: false, filters: ['encrypt']);
                    } catch (\Throwable $th) {
                        Console::warning("'providerRefreshToken' from {$id}: {$th->getMessage()}");
                    }
                    try {
                        /**
                         * Create providerAccessTokenExpiry
                         */
                        $this->projectDB->createAttribute(collection: $id, id: 'providerAccessTokenExpiry', type: Database::VAR_INTEGER, size: 0, required: false);
                    } catch (\Throwable $th) {
                        Console::warning("'providerAccessTokenExpiry' from {$id}: {$th->getMessage()}");
                    }
                    break;

                case 'memberships':
                    try {
                        /**
                         * Add search attribute and index to memberships.
                         */
                        $this->projectDB->createAttribute(collection: $id, id: 'search', type: Database::VAR_STRING, size: 16384, required: false);
                        $this->projectDB->createIndex(collection: $id, id: '_key_search', type: Database::INDEX_FULLTEXT, attributes: ['search']);
                    } catch (\Throwable $th) {
                        Console::warning("'search' from {$id}: {$th->getMessage()}");
                    }
                    break;

                case 'files':
                    /**
                     * Create bucket table if not exists.
                     */
                    $this->createCollection('buckets');

                    if (!$this->projectDB->findOne('buckets', [new Query('$id', Query::TYPE_EQUAL, ['default'])])) {
                        $this->projectDB->createDocument('buckets', new Document([
                            '$id' => 'default',
                            '$collection' => 'buckets',
                            'dateCreated' => \time(),
                            'dateUpdated' => \time(),
                            'name' => 'Default',
                            'permission' => 'file',
                            'maximumFileSize' => (int) App::getEnv('_APP_STORAGE_LIMIT', 0), // 10MB
                            'allowedFileExtensions' => [],
                            'enabled' => true,
                            'encryption' => true,
                            'antivirus' => true,
                            '$read' => ['role:all'],
                            '$write' => ['role:all'],
                            'search' => 'buckets Default',
                        ]));
                        $this->createCollection('files', 'bucket_1');

                        /**
                         * Migrate all files to default Bucket.
                         */
                        $nextDocument = null;
                        do {
                            $documents = $this->projectDB->find('files', limit: $this->limit, cursor: $nextDocument);
                            $count = count($documents);
                            \Co\run(function (array $documents) {
                                foreach ($documents as $document) {
                                    go(function (Document $document) {
                                        /**
                                         * Update File Path
                                         */
                                        $path = "/storage/uploads/app-{$this->project->getId()}";
                                        $new = str_replace($path, "{$path}/default", $document->getAttribute('path'));
                                        $document->setAttribute('path', $new);

                                        /**
                                         * Populate search string from Migration to 0.12.
                                         */
                                        if (empty($document->getAttribute('search'))) {
                                            $document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name'], $document));
                                        }

                                        /**
                                         * Set new values.
                                         */
                                        $document
                                            ->setAttribute('bucketId', 'default')
                                            ->setAttribute('chunksTotal', 1)
                                            ->setAttribute('chunksUploaded', 1);

                                        $this->projectDB->createDocument('bucket_1', $document);
                                    }, $document);
                                }
                            }, $documents);

                            if ($count !== $this->limit) {
                                $nextDocument = null;
                            } else {
                                $nextDocument = end($documents);
                                $nextDocument->setAttribute('$collection', 'files');
                            }
                        } while (!is_null($nextDocument));

                        /**
                         * Rename folder on volumes.
                         */
                        $path = "/storage/uploads/app-{$this->project->getId()}";

                        if (is_dir("{$path}/")) {
                            mkdir("/storage/uploads/app-{$this->project->getId()}/default");

                            foreach (new \DirectoryIterator($path) as $fileinfo) {
                                if ($fileinfo->isDir() && !$fileinfo->isDot() && $fileinfo->getFilename() !== 'default') {
                                    rename("{$path}/{$fileinfo->getFilename()}", "{$path}/default/{$fileinfo->getFilename()}");
                                }
                            }
                        }
                    }

                    break;

                case 'functions':
                    try {
                        /**
                         * Rename tag to deployment
                         */
                        $this->projectDB->renameAttribute($id, 'tag', 'deployment');
                    } catch (\Throwable $th) {
                        Console::warning("'deployment' from {$id}: {$th->getMessage()}");
                    }

                    /**
                     * Create deployments table if not exists.
                     */
                    $this->createCollection('deployments');

                    /**
                     * Create builds table if not exists.
                     */
                    $this->createCollection('builds');

                    break;

                case 'executions':
                    try {
                        /**
                         * Rename tag to deployment
                         */
                        $this->projectDB->renameAttribute($id, 'tagId', 'deploymentId');
                    } catch (\Throwable $th) {
                        Console::warning("'deploymentId' from {$id}: {$th->getMessage()}");
                    }

                    try {
                        /**
                         * Create statusCode
                         */
                        $this->projectDB->createAttribute(collection: $id, id: 'statusCode', type: Database::VAR_INTEGER, size: 0, required: false);
                    } catch (\Throwable $th) {
                        Console::warning("'statusCode' from {$id}: {$th->getMessage()}");
                    }

                    break;

                case 'teams':
                    try {
                        /**
                         * Rename tag to deployment
                         */
                        $this->projectDB->renameAttribute($id, 'sum', 'total');
                    } catch (\Throwable $th) {
                        Console::warning("'total' from {$id}: {$th->getMessage()}");
                    }

                    break;
            }
            usleep(100000);
        }
    }

    /**
     * Creates colletion from the config collection.
     *
     * @param string $id
     * @param string|null $name
     * @return void
     * @throws \Throwable
     */
    protected function createCollection(string $id, string $name = null): void
    {
        $name ??= $id;

        if (!$this->projectDB->exists(App::getEnv('_APP_DB_SCHEMA', 'appwrite'), $name)) {
            $attributes = [];
            $indexes = [];
            $collection = $this->collections[$id];

            foreach ($collection['attributes'] as $attribute) {
                $attributes[] = new Document([
                    '$id' => $attribute['$id'],
                    'type' => $attribute['type'],
                    'size' => $attribute['size'],
                    'required' => $attribute['required'],
                    'signed' => $attribute['signed'],
                    'array' => $attribute['array'],
                    'filters' => $attribute['filters'],
                ]);
            }

            foreach ($collection['indexes'] as $index) {
                $indexes[] = new Document([
                    '$id' => $index['$id'],
                    'type' => $index['type'],
                    'attributes' => $index['attributes'],
                    'lengths' => $index['lengths'],
                    'orders' => $index['orders'],
                ]);
            }

            try {
                $this->projectDB->createCollection($name, $attributes, $indexes);
            } catch (\Throwable $th) {
                throw $th;
            }
        }
    }

    /**
     * Migrates permissions to dedicated table.
     *
     * @param \Utopia\Database\Document $document 
     * @param string $internalId 
     * @return void 
     * @throws \Exception 
     * @throws \PDOException 
     */
    protected function migratePermissionsToDedicatedTable(string $collection, Document $document): void
    {
        $sql = "SELECT _read, _write FROM `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$collection}` WHERE _uid = {$this->pdo->quote($document->getid())}";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute();

        $permissions = $stmt->fetch();

        $read  = json_decode($permissions['_read'] ?? null) ?? [];
        $write = json_decode($permissions['_write'] ?? null) ?? [];

        $permissions = [];
        foreach ($read as $permission) {
            $permissions[] = "('read', '{$permission}', '{$document->getId()}')";
        }

        foreach ($write as $permission) {
            $permissions[] = "('write', '{$permission}', '{$document->getId()}')";
        }

        if (!empty($permissions)) {
            $queryPermissions = "INSERT IGNORE INTO `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$collection}_perms` (_type, _permission, _document) VALUES " . implode(', ', $permissions);
            $stmtPermissions = $this->pdo->prepare($queryPermissions);
            $stmtPermissions->execute();
        }
    }

    /**
     * Migrates all user's database collections.
     *
     * @return void
     * @throws \Exception
     */
    protected function migrateCustomCollections(): void
    {
        $nextCollection = null;

        do {
            $documents = $this->projectDB->find('collections', limit: $this->limit, cursor: $nextCollection);
            $count = count($documents);

            \Co\run(function (array $documents) {
                foreach ($documents as $document) {
                    go(function (Document $collection) {
                        $id = $collection->getId();
                        $projectId = $this->project->getId();
                        $internalId = $collection->getInternalId();

                        if ($this->projectDB->exists(App::getEnv('_APP_DB_SCHEMA', 'appwrite'), "collection_{$internalId}")) {
                            return;
                        }
                        Console::log("- {$id} ({$collection->getAttribute('name')})");

                        /**
                         * Rename user's colletion table schema
                         */
                        $this->pdo->prepare("ALTER TABLE IF EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_project_{$projectId}_collection_{$id}` RENAME TO `_{$projectId}_collection_{$internalId}`")->execute();
                        $this->pdo->prepare("CREATE TABLE IF NOT EXISTS `{$this->projectDB->getDefaultDatabase()}`.`_{$projectId}_collection_{$internalId}_perms` (
                            `_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
                            `_type` VARCHAR(12) NOT NULL,
                            `_permission` VARCHAR(255) NOT NULL,
                            `_document` VARCHAR(255) NOT NULL,
                            PRIMARY KEY (`_id`),
                            UNIQUE INDEX `_index1` (`_type`,`_document`,`_permission`),
                            INDEX `_index2` (`_permission`)
                        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;")->execute();

                        /**
                         * Update metadata table.
                         */
                        $this->pdo->prepare("UPDATE `{$this->projectDB->getDefaultDatabase()}`.`_{$projectId}__metadata`
                            SET
                                _uid = 'collection_{$internalId}',
                                name = 'collection_{$internalId}'
                            WHERE _uid = 'collection_{$id}';
                        ")->execute();


                        $nextDocument = null;

                        do {
                            $documents = $this->projectDB->find('collection_' . $internalId, limit: $this->limit, cursor: $nextDocument);
                            $count = count($documents);

                            foreach ($documents as $document) {
                                go(function (Document $document, string $internalId) {
                                    $this->migratePermissionsToDedicatedTable("collection_{$internalId}", $document);
                                }, $document, $internalId);
                            }

                            if ($count !== $this->limit) {
                                $nextDocument = null;
                            } else {
                                $nextDocument = end($documents);
                            }
                        } while (!is_null($nextDocument));

                        /**
                         * Remove _read and _write columns
                         */
                        $this->pdo->prepare("
                            ALTER TABLE `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_collection_{$internalId}`
                            DROP COLUMN _read,
                            DROP COLUMN _write
                        ")->execute();
                    }, $document);
                }
            }, $documents);

            if ($count !== $this->limit) {
                $nextCollection = null;
            } else {
                $nextCollection = end($documents);
            }
        } while (!is_null($nextCollection));
    }

    /**
     * Migrate all Permission to new System with dedicated Table.
     *
     * @return void
     * @throws \Exception
     */
    protected function fixPermissions()
    {
        foreach ($this->collections as $collection) {
            $id = $collection['$id'];

            /**
             * Skip new tables that don't exists on old schema.
             */
            if (in_array($id, ['buckets', 'deployments', 'builds'])) {
                continue;
            }
            /**
             * Check if permissions have already been migrated.
             */
            try {
                $stmtCheck = $this->pdo->prepare("SHOW COLUMNS from `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$id}` LIKE '_read'");
                $stmtCheck->execute();

                if (empty($stmtCheck->fetchAll())) {
                    continue;
                }
            } catch (\Throwable $th) {
                if ($th->getCode() === "42S02") {
                    continue;
                }
                throw $th;
            }


            Console::log("- {$collection['$id']}");
            $nextDocument = null;

            do {
                $documents = $this->projectDB->find($id, limit: $this->limit, cursor: $nextDocument);
                $count = count($documents);

                \Co\run(function (array $documents) {
                    foreach ($documents as $document) {
                        go(function (Document $document) {
                            $this->migratePermissionsToDedicatedTable($document->getCollection(), $document);
                        }, $document);
                    }
                }, $documents);

                if ($count !== $this->limit) {
                    $nextDocument = null;
                } else {
                    $nextDocument = end($documents);
                }
            } while (!is_null($nextDocument));

            /**
             * Remove _read and _write columns
             */
            $this->pdo->prepare("
                ALTER TABLE `{$this->projectDB->getDefaultDatabase()}`.`{$this->projectDB->getNamespace()}_{$id}`
                DROP COLUMN _read,
                DROP COLUMN _write
            ")->execute();
        }

        /**
         * Timeout to give MariaDB some room to breath
         */
        usleep(100000);
    }

    /**
     * Fix run on each document
     *
     * @param \Utopia\Database\Document $document 
     * @return \Utopia\Database\Document 
     */
    protected function fixDocument(Document $document)
    {
        switch ($document->getCollection()) {
            case 'projects':
                /**
                 * Bump Project version number.
                 */
                $document->setAttribute('version', '0.13.0');

                /**
                 * Populate search string from Migration to 0.12.
                 */
                if (empty($document->getAttribute('search'))) {
                    $document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name'], $document));
                }

                break;

            case 'users':
                /**
                 * Populate search string from Migration to 0.12.
                 */
                if (empty($document->getAttribute('search'))) {
                    $document->setAttribute('search', $this->buildSearchAttribute(['$id', 'email', 'name'], $document));
                }

                break;

            case 'teams':
                /**
                 * Populate search string from Migration to 0.12.
                 */
                if (empty($document->getAttribute('search'))) {
                    $document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name'], $document));
                }

                break;

            case 'functions':
                $document->setAttribute('deployment', null);

                /**
                 * Populate search string from Migration to 0.12.
                 */
                if (empty($document->getAttribute('search'))) {
                    $document->setAttribute('search', $this->buildSearchAttribute(['$id', 'name', 'runtime'], $document));
                }

                break;

            case 'executions':
                /**
                 * Populate search string from Migration to 0.12.
                 */
                if (empty($document->getAttribute('search'))) {
                    $document->setAttribute('search', $this->buildSearchAttribute(['$id', 'functionId'], $document));
                }

                break;

            case 'memberships':
                /**
                 * Populate search string.
                 */
                if (empty($document->getAttribute('search'))) {
                    $document->setAttribute('search', $this->buildSearchAttribute(['$id', 'userId'], $document));
                }

                break;

            case 'sessions':
                $document
                    ->setAttribute('providerRefreshToken', '')
                    ->setAttribute('providerAccessTokenExpiry', 0)
                    ->setAttribute('providerAccessToken', $document->getAttribute('providerToken', ''))
                    ->removeAttribute('providerToken');

                break;
        }

        return $document;
    }

    /**
     * Builds a search string for a fulltext index.
     *
     * @param array $values
     * @param Document $document
     * @return string
     */
    private function buildSearchAttribute(array $values, Document $document): string
    {
        $values = array_filter(array_map(fn (string $value) => $document->getAttribute($value) ?? '', $values));

        return implode(' ', $values);
    }
}
