<?php

namespace Acms\Services\Update\Database;

use Acms\Services\Facades\Logger;
use Acms\Services\Facades\Database as DB;

class Schema
{
    /**
     * 現在のテーブル情報
     *
     * @var array
     */
    public $currentTableInfo;

    /**
     * 新しいデータベース定義
     *
     * @var array
     */
    public $define;

    /**
     * 現在のテーブル定義
     * @var array
     */
    public $schema;

    /**
     * 新しいデータベースのインデックス情報
     * @var array
     */
    public $indexDefine;

    /**
     * 現在のインデックス情報
     * @var mixed
     */
    public $dbIndex;

    /**
     * DB接続情報
     *
     * @var Dsn
     */
    protected $dsn;

    /**
     * 新しいデータベースのリネーム情報
     *
     * @var array
     */
    protected $renameDefine;

    /**
     * 新しいデータベースのEngine変更情報
     */
    protected $engineDefine;

    /**
     * @var \Acms\Services\Update\Database\DatabaseInfo
     */
    protected $dbInfo;

    /**
     * Schema constructor.
     *
     * @param Dsn $dsn
     * @param SchemaDefinitions|null $definitions yaml 由来のスキーマ定義群。
     *                                            省略時は本体 + 拡張の Config 経由ロード
     *                                            (= SchemaDefinitions::fromConfig()) を使う。
     *                                            拡張アプリ単位のスキーマ管理では
     *                                            SchemaDefinitions::fromYaml($dir) を渡す。
     */
    public function __construct($dsn, ?SchemaDefinitions $definitions = null)
    {
        $this->dsn = $dsn;
        $this->dbInfo = new DatabaseInfo($dsn);

        $defs = $definitions ?? SchemaDefinitions::fromConfig();
        $this->define       = $defs->schema;
        $this->indexDefine  = $defs->index;
        $this->renameDefine = $defs->rename;
        $this->engineDefine = $defs->engine;

        $this->setSchema();
    }

    /**
     * 現 DB のスナップショットを再ロードする。
     *
     * yaml 由来の定義 (define / index / rename / engine) は constructor で確定させており、
     * このメソッドでは現 DB のテーブル構成 ($this->schema) と既存インデックス ($this->dbIndex)
     * のみを再取得する。
     */
    public function setSchema()
    {
        $this->schema = $this->getDatabaseDefinitionCurrent();
        $this->dbIndex = $this->getDatabaseIndexCurrent();
    }

    /**
     * 定義 (yaml) に対応するテーブルが現 DB に 1 つ以上存在するか
     *
     * 旧 setup/lib/schema.php の同名メソッドと等価。setup/install.php の Step_4 で
     * 「未初期化 (= 全テーブル未作成) なのか、一部だけ作成済みなのか」を判定するために使う。
     */
    /**
     * テーブルを削除する (存在しない場合はノーオペ)
     */
    public function dropTable(string $table): void
    {
        $this->dbInfo->dropTable($table);
        $this->reloadSchema();
    }

    /**
     * 指定カラムを削除する。
     *
     * 指定カラム数が `$this->schema[$table]` のカラム数と一致する場合は、
     * テーブル全体を DROP する (旧 setup/lib/schema.php の同名メソッドと等価)。
     *
     * @param list<string> $columns
     */
    public function dropColumns(string $table, array $columns = []): void
    {
        if (count($columns) === 0) {
            return;
        }
        if (isset($this->schema[$table]) && count($this->schema[$table]) === count($columns)) {
            $this->dropTable($table);
            return;
        }
        foreach ($columns as $column) {
            $this->dbInfo->drop($table, $column);
        }
        $this->reloadSchema();
    }

    public function existsDefinedTable(): bool
    {
        $now = $this->listUp($this->schema);
        $def = $this->listUp($this->define);

        if (count($now) === 0) {
            return false;
        }
        foreach ($now as $table) {
            if (in_array($table, $def, true)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 現在のDBと定義を比較して，差分のテーブル名を配列で返す
     *
     * @return array
     */
    public function compareTables()
    {
        $now_tbs = $this->listUp($this->schema);
        $def_tbs = $this->listUp($this->define);

        $haystack = [];
        foreach ($def_tbs as $tb) {
            if (array_search($tb, $now_tbs, true) === false) {
                $haystack[] = $tb;
            }
        }
        return $haystack;
    }

    /**
     * カラム定義の違いを走査
     *
     * @param string $table
     *
     * @return mixed
     */
    public function compareColumns($table)
    {
        $now = null;
        $def = null;
        $addRam = [];
        $changeRam = [];

        if (isset($this->schema[$table])) {
            $now = $this->schema[$table];
        }
        if (isset($this->define[$table])) {
            $def = $this->define[$table];
        }

        if (empty($def)) {
            return [
                'add' => [],
                'change' => [],
            ];
        }

        $defineFields = $this->listUp($def);

        foreach ($defineFields as $key) {
            /**
             * ALTER TABLE ADD
             * is not exists living list
             */
            if (empty($now[$key])) {
                $addRam[] = $key;
                continue;
            }

            /**
             * ALTER TABLE CHANGE
             * is not equal column
             *   *
             * EXCEPT INDEX KEY
             */
            unset($now[$key]['key']);
            unset($def[$key]['key']);

            if ($now[$key] != $def[$key]) {
                $changeRam[] = $key;
                continue;
            }
        }

        return [
            'add' => $addRam,
            'change' => $changeRam,
        ];
    }

    /**
     * インデックス定義の違いを走査
     *
     * @param string $table
     *
     * @return mixed
     */
    public function compareIndex($table)
    {
        $currentIndex = [];
        $updateIndex = [];
        $resultIndex = [];
        if (isset($this->dbIndex[$table])) {
            $currentIndex = $this->dbIndex[$table];
        }
        if (isset($this->indexDefine[$table])) {
            $updateIndex = $this->indexDefine[$table];
        }
        foreach ($updateIndex as $new) {
            foreach ($currentIndex as $old) {
                $key = "/KEY\s+$old(\s|\()/i";
                if (preg_match($key, $new)) {
                    continue 2;
                }
                if ($old === 'PRIMARY' && preg_match('/PRIMARY/i', $new)) {
                    continue 2;
                }
            }
            $resultIndex[] = $new;
        }
        return $resultIndex;
    }

    /**
     * system table を更新できるか確認
     *
     * @return bool
     */
    public function checkAlterSystemTablePermission()
    {
        $DB = DB::singleton($this->dsn);
        $q = "ALTER TABLE `" . DB_PREFIX . "sequence` CHANGE `sequence_system_version` `sequence_system_version` VARCHAR(32) NOT NULL";
        $sql = [
            'sql' => $q,
            'params' => [],
        ];
        $res = $DB->query($sql, 'exec');

        return $res;
    }

    /**
     * テーブルを作成する
     *
     * @param string[] $tables
     * @param array|null $idx
     */
    public function createTables($tables, $idx = null)
    {
        $this->dbInfo->createTables($tables, $idx, $this->define);
        $this->reloadSchema();
    }

    /**
     * 名前に変更のあったフィールドを解決する
     */
    public function resolveRenames()
    {
        if (empty($this->renameDefine)) {
            return;
        }

        foreach ($this->renameDefine as $table => $field) {
            if (empty($table) || empty($field)) {
                continue;
            }
            $rename = $this->listUp($this->schema[$table]);

            foreach ($field as $k => $v) {
                if (in_array($k, $rename, true)) {
                    $val = $this->define[$table][$v];
                    $this->dbInfo->rename($table, $k, $val, $v);
                }
            }
        }

        $this->reloadSchema();
    }

    /**
     * テーブルのEngineを解決する
     */
    public function resolveEngines()
    {
        if (empty($this->engineDefine)) {
            return;
        }
        foreach ($this->engineDefine as $table => $engine) {
            if (empty($table) || empty($engine)) {
                continue;
            }
            $this->dbInfo->changeEngine($table, $engine);
        }
    }

    /**
     * compareColumns走査済みのすべてのカラムを追加・変更する
     *
     * @param string $table
     * @param array $add
     * @param array $change
     */
    public function resolveColumns($table, $add, $change)
    {
        $def = null;
        if (isset($this->define[$table])) {
            $def = $this->define[$table];
        }

        $list = $this->listUp($def);

        /**
         * ADD
         */
        if (!empty($add)) {
            foreach ($add as $key) {
                $after = array_slice($list, 0, (array_search($key, $list, true)));
                $after = end($after);
                $this->dbInfo->add($table, $key, $def[$key], $after);
            }
        }

        /**
         * CHANGE
         */
        if (!empty($change)) {
            foreach ($change as $key) {
                $this->dbInfo->change($table, $key, $def[$key]);
            }
        }

        $this->reloadSchema();
    }

    /**
     * 定義外の未使用カラムを走査
     */
    public function unusedColumns($table)
    {
        $now = null;
        $def = null;

        if (isset($this->schema[$table])) {
            $now = $this->schema[$table];
        }
        if (isset($this->define[$table])) {
            $def = $this->define[$table];
        }

        $columns = $this->listUp($now);
        $unused = [];
        foreach ($columns as $key) {
            /**
             * ALTER TABLE DROP ( TEMP )
             * is not exists defined list
             */
            if (empty($def[$key])) {
                $unused[] = $key;
                continue;
            }
        }
        return $unused;
    }

    /**
     * 指定されたテーブルのインデックスをすべて削除する
     *
     * @param string $table
     */
    public function clearIndex($table)
    {
        $index = $this->dbInfo->showIndex($table);
        if (empty($index)) {
            return;
        }

        $ary = [];
        foreach ($index as $idx) {
            $name = $idx['Key_name'];
            $ary[$name] = $name;
        }

        foreach ($ary as $key) {
            $DB = DB::singleton($this->dsn);
            $sql = [
                'sql' => "ALTER TABLE `$table` DROP INDEX `$key`",
                'params' => []
            ];
            $DB->query($sql, 'exec');
        }
    }

    /**
     * インデックスを作成する
     *
     * @param $table
     * @param $res
     */
    public function makeIndex($table, $res)
    {
        if (empty($res)) {
            return;
        }
        $DB = DB::singleton($this->dsn);
        foreach (self::buildAddIndexQueries($table, $res) as $query) {
            // DB::query が連想配列を受け付けるのは v3.2 以降のため、旧バージョンからの
            // アップデートでも動くよう文字列 SQL で渡す（識別子のみでユーザー入力を含まない）
            if ($DB->query($query, 'exec') === false) {
                // 既存データの重複などで作成に失敗してもアップデート自体は続行する
                $this->safeLogWarning('インデックスの作成に失敗しました', [
                    'query' => $query,
                ]);
            }
        }
    }

    /**
     * ログ出力を防御的に行う
     *
     * アップデート処理はアップグレード元（旧バージョン）のアプリ文脈で実行されることがあり、
     * 後発で追加された Logger サービスがコンテナに未登録の場合がある。ログ出力の失敗が
     * アップグレード自体を止めないよう、クラス存在確認と例外捕捉で保護する。
     *
     * @param string $message
     * @param array $context
     * @return void
     */
    protected function safeLogWarning($message, $context = [])
    {
        if (!class_exists(Logger::class)) {
            return;
        }
        try {
            Logger::warning($message, $context);
        } catch (\Throwable) {
            // ログ出力に失敗してもアップデートは継続する（意図的に握りつぶす）
        }
    }

    /**
     * インデックス定義から ALTER 文を組み立てる
     *
     * PRIMARY KEY 定義も対象に含める。setup のインデックス修復などで
     * PRIMARY KEY が失われた環境を、アップデート時に復元できるようにするため。
     * 呼び出し側（compareIndex）が既存の PRIMARY KEY を持つテーブルを除外している前提。
     *
     * @param string $table
     * @param array $definitions
     * @return string[]
     */
    public static function buildAddIndexQueries($table, $definitions)
    {
        $queries = [];
        foreach ($definitions as $definition) {
            $queries[] = "ALTER TABLE `$table` ADD $definition";
        }
        return $queries;
    }

    /**
     * 配列のキーを返す・空配列は除かれる
     *
     * @param  $ary
     * @return array
     */
    public function listUp($ary)
    {
        if (empty($ary)) {
            return [];
        }

        return array_merge(array_diff(array_keys($ary), ['']));
    }

    /**
     * reload schema
     */
    protected function reloadSchema()
    {
        $this->schema = $this->getDatabaseDefinitionCurrent();
    }

    /**
     * 現在のデータベース定義を取得
     *
     * @return array
     */
    protected function getDatabaseDefinitionCurrent()
    {
        $tables = $this->dbInfo->getTables();
        if (!is_array($tables)) {
            return [];
        }

        $def = [];
        foreach ($tables as $table) {
            $columns = $this->dbInfo->getColumns($table);
            $def[$table] = $columns;
        }
        return $def;
    }

    /**
     * 現在のデータベースインデックス定義を取得
     *
     * @return array
     */
    protected function getDatabaseIndexCurrent()
    {
        $tables = $this->dbInfo->getTables();
        if (!is_array($tables)) {
            return [];
        }

        $def = [];
        foreach ($tables as $table) {
            $columns = $this->dbInfo->getIndex($table);
            $def[$table] = $columns;
        }
        return $def;
    }
}
