<?php

namespace Acms\Services\Database;

use DB;
use AcmsLogger;

class Replication
{
    /**
     * リストア時にステージング用テーブルへ付与する接頭辞。
     * 本番テーブル名（DB_PREFIX 付き）の前にさらに付与する（例: restore_acms_blog）。
     */
    public const STAGING_PREFIX = 'restore_';

    /**
     * 退避（バックアップ）テーブルへ付与する接頭辞。
     * 本番テーブル名（DB_PREFIX 付き）の前に付与する（例: backup_acms_blog / backup_mysite_blog）。
     * STAGING_PREFIX と対称で、DB_PREFIX に依存せず一貫した命名になる。
     */
    public const BACKUP_PREFIX = 'backup_';

    /**
     * リストア（スワップ）対象から除外し、本番の内容をそのまま保持するテーブル物理名（接頭辞なし）。
     * 対象はスワップ時に「本番からの退避」「ステージングからの昇格」の両方から外れるため、
     * 本番テーブルは無傷で残り、対応するステージングの空テーブルは dropStagingTables() で削除される。
     *
     * audit_log: リストア用SQLファイルには（他の環境固有テーブル同様）データが含まれないため、
     * 通常のスワップ対象にするとリストア先で稼働していた監査ログが失われてしまう。
     *
     * @var array<int, string>
     */
    public const PRESERVE_ON_RESTORE = ['audit_log'];

    /**
     * @var \Acms\Services\Database\Engine\Base
     */
    protected $db;

    /**
     * @var string
     */
    protected $dbName;

    /**
     * @var array
     */
    protected $dsn;

    public function __construct($dsn = null)
    {
        if (empty($dsn)) {
            $this->dsn = dsn();
        }
        $this->dbName = $this->dsn['name'];
        $this->db = DB::singleton($this->dsn);
    }

    /**
     * 全テーブルの取得
     *
     * @return array
     */
    public function getTableList()
    {
        $sql = [
            'sql' => 'SHOW TABLES FROM `' . $this->dbName . '`',
            'params' => []
        ];
        $tables = DB::query($sql, 'all');

        $list = [];
        foreach ($tables as $key => $table) {
            array_push($list, strtolower(reset($table)));
        }

        return $list;
    }

    /**
     * 全テーブルの削除
     *
     * @return void
     */
    public function dropAllTables()
    {
        $list = [];
        foreach ($this->getTableList() as $table) {
            $table = strtolower($table);
            array_push($list, '`' . $table . '`');
        }
        $tables_str = implode(', ', $list);
        $sql = [
            'sql' => 'DROP TABLE ' . $tables_str,
            'params' => [],
        ];
        $sql2 = [
            'sql' => 'DROP TABLE ' . strtoupper($tables_str),
            'params' => [],
        ];
        DB::query($sql, 'exec');
        DB::query($sql2, 'exec');
    }

    /**
     * 全テーブルのリネーム
     *
     * @return void
     */
    public function renameAllTable()
    {
        $list = [];
        foreach ($this->getTableList() as $table) {
            $table = strtolower($table);
            if (!self::isBackupTableName($table, DB_PREFIX) and strpos($table, DB_PREFIX) === 0) {
                array_push($list, '`' . $table . '` TO `' . self::backupTableName($table) . '`');
            }
        }
        $tables_str = implode(', ', $list);
        $sql = [
            'sql' => 'RENAME TABLE ' . $tables_str,
            'params' => [],
        ];

        DB::query($sql, 'exec');
    }

    /**
     * 一時テーブルの削除
     *
     * @return void
     */
    public function dropCashTable()
    {
        $list = [];
        foreach ($this->getTableList() as $table) {
            $table = strtolower($table);
            if (self::isBackupTableName($table, DB_PREFIX)) {
                array_push($list, '`' . $table . '`');
            }
        }
        $tables_str = implode(', ', $list);
        if (!empty($tables_str)) {
            $sql = [
                'sql' => 'DROP TABLE ' . $tables_str,
                'params' => [],
            ];
            $sql2 = [
                'sql' => 'DROP TABLE ' . strtoupper($tables_str),
                'params' => [],
            ];
            try {
                DB::query($sql, 'exec');
            } catch (\Exception $e) {
                AcmsLogger::notice($e->getMessage());
            }
            try {
                DB::query($sql2, 'exec');
            } catch (\Exception $e) {
                AcmsLogger::notice($e->getMessage());
            }
        }
    }

    /**
     * 退避（バックアップ）テーブル名を組み立てる。
     * 本番テーブル名（DB_PREFIX 付き）の前に BACKUP_PREFIX を付与する（例: acms_blog -> backup_acms_blog）。
     *
     * @param string $liveTable
     * @return string
     */
    private static function backupTableName(string $liveTable): string
    {
        return self::BACKUP_PREFIX . $liveTable;
    }

    /**
     * 自インストールの退避（バックアップ）テーブルかどうかを判定する。
     * 退避名は backup_<DB接頭辞>... になるため、その接頭辞で始まるものだけを対象とする。
     * （旧バージョンの二重接頭辞 backup_acms_<本番名> も、デフォルト接頭辞環境では
     * backup_acms_ で始まるため同じ判定で拾える。）別接頭辞インストールの退避は対象外。
     *
     * @param string $table
     * @param string $dbPrefix
     * @return bool
     */
    private static function isBackupTableName(string $table, string $dbPrefix): bool
    {
        return strpos($table, self::BACKUP_PREFIX . $dbPrefix) === 0;
    }

    /**
     * 本番の内容を保持するため、リストアのスワップ対象から除外するテーブルかどうかを判定する。
     *
     * @param string $table DB_PREFIX 付きのテーブル名
     * @param string $dbPrefix
     * @return bool
     */
    private static function isPreservedOnRestore(string $table, string $dbPrefix): bool
    {
        foreach (self::PRESERVE_ON_RESTORE as $name) {
            if ($table === $dbPrefix . $name) {
                return true;
            }
        }
        return false;
    }

    /**
     * ステージングテーブルを本番テーブルとアトミックに入れ替える RENAME 文を組み立てる。
     *
     * 現在の本番テーブル（DB_PREFIX 付き）を backup_acms_* へ退避し、ステージング
     * （restore_acms_*）を本番名へ昇格する組を、単一の RENAME TABLE 文にまとめる。
     * 退避→昇格の順に並べることで、本番名が一旦解放されてから昇格される。
     * 単一文にすることで、テーブルが存在しない時間帯（本番閲覧で 500 になる窓）を最小化する。
     *
     * @param array<int, string> $tables 現在の全テーブル名
     * @param string $dbPrefix DB のテーブル接頭辞（例: acms_）
     * @return string
     * @throws \RuntimeException ステージングテーブルが存在しない場合
     */
    public static function buildSwapSql(array $tables, string $dbPrefix): string
    {
        $stagingPrefix = self::STAGING_PREFIX . $dbPrefix;
        $live = [];
        $staging = [];
        foreach ($tables as $table) {
            $table = strtolower($table);
            if (strpos($table, $stagingPrefix) === 0) {
                $staging[] = $table;
            } elseif (!self::isBackupTableName($table, $dbPrefix) && strpos($table, $dbPrefix) === 0) {
                $live[] = $table;
            }
        }

        if (count($staging) === 0) {
            throw new \RuntimeException('リストア対象のステージングテーブルが見つかりませんでした。');
        }

        $pairs = [];
        // 現在の本番テーブルを退避（backup_<DB接頭辞>*）。PRESERVE_ON_RESTORE 対象は退避せず本番のまま残す。
        foreach ($live as $table) {
            if (self::isPreservedOnRestore($table, $dbPrefix)) {
                continue;
            }
            $pairs[] = '`' . $table . '` TO `' . self::backupTableName($table) . '`';
        }
        // ステージングを本番名へ昇格。PRESERVE_ON_RESTORE 対象は昇格させず、空のステージングのまま
        // dropStagingTables() のクリーンアップに委ねる。
        foreach ($staging as $table) {
            $target = substr($table, strlen(self::STAGING_PREFIX));
            if (self::isPreservedOnRestore($target, $dbPrefix)) {
                continue;
            }
            $pairs[] = '`' . $table . '` TO `' . $target . '`';
        }

        return 'RENAME TABLE ' . implode(', ', $pairs);
    }

    /**
     * ステージング用テーブルを削除する DROP 文を組み立てる。
     *
     * @param array<int, string> $tables 現在の全テーブル名
     * @param string $dbPrefix DB のテーブル接頭辞（例: acms_）
     * @return string 対象が無ければ空文字
     */
    public static function buildDropStagingSql(array $tables, string $dbPrefix): string
    {
        $stagingPrefix = self::STAGING_PREFIX . $dbPrefix;
        $list = [];
        foreach ($tables as $table) {
            $table = strtolower($table);
            if (strpos($table, $stagingPrefix) === 0) {
                $list[] = '`' . $table . '`';
            }
        }
        if (count($list) === 0) {
            return '';
        }
        return 'DROP TABLE ' . implode(', ', $list);
    }

    /**
     * ステージングテーブルを本番テーブルとアトミックに入れ替える。
     *
     * @return void
     * @throws \RuntimeException 入れ替えに失敗した場合
     */
    public function swapStagingTables()
    {
        $sql = self::buildSwapSql($this->getTableList(), DB_PREFIX);
        // exec モードは失敗時に例外ではなく false を返す（本番の debug=false 環境）。
        // 最重要のコミットポイントのため戻り値を検査し、失敗を確実に例外化して
        // 呼び出し側でステージングを破棄させる（本番テーブルは RENAME 失敗時も無傷）。
        if (DB::query(['sql' => $sql, 'params' => []], 'exec') === false) {
            throw new \RuntimeException('ステージングテーブルの入れ替えに失敗しました。 ' . implode(' ', DB::errorInfo()));
        }
    }

    /**
     * ステージング用テーブル（restore_acms_*）を削除する。
     *
     * @return void
     */
    public function dropStagingTables()
    {
        $sql = self::buildDropStagingSql($this->getTableList(), DB_PREFIX);
        if ($sql === '') {
            return;
        }
        try {
            DB::query(['sql' => $sql, 'params' => []], 'exec');
        } catch (\Exception $e) {
            AcmsLogger::notice($e->getMessage());
        }
    }

    /**
     * テーブル作成クエリの組み立て
     *
     * @return string
     */
    public function buildCreateTableSql()
    {
        $master = '';
        $list = [];
        foreach ($this->getTableList() as $table) {
            $table = strtolower($table);
            if (!self::isBackupTableName($table, DB_PREFIX) and strpos($table, DB_PREFIX) === 0) {
                array_push($list, $table);
            }
        }

        foreach ($list as $key => $row) {
            $sql = [
                'sql' => 'SHOW CREATE TABLE `' . $row . '`',
                'params' => [],
            ];
            $create = DB::query($sql, 'all');
            foreach ($create as $createRow) {
                $create_sql = $createRow['Create Table'];
                $create_sql = str_replace(["\r\n", "\n", "\r"], '', $create_sql);
                $master .= $create_sql . ';' . PHP_EOL;
            }
        }

        return $master;
    }

    /**
     * データ投入sqlの組み立て
     *
     * @param string $table
     * @param resource $handle
     *
     * @return void
     */
    public function buildInsertSql($table, &$handle)
    {
        if (self::isBackupTableName($table, DB_PREFIX)) {
            return;
        }
        $db = DB::singleton(dsn());
        $columnsList = [];
        $columnsType = [];

        $columns = $db->query([
            'sql' => 'SHOW COLUMNS FROM `' . $table . '`',
            'params' => []
        ], 'all');
        foreach ($columns as $row) {
            $name = $row['Field'];
            array_push($columnsList, $name);
            $columnsType[$name] = $row['Type'];
        }
        $q = [
            'sql' => "SELECT * FROM `$table`",
            'params' => [],
        ];
        $all = $db->query($q, 'all', false);

        foreach ($all as $row) {
            $masterQuery = 'INSERT INTO `' . $table . '` (`' . implode('`, `', $columnsList) . '`) VALUES ';
            $masterQuery .= '(';
            $j = 0;
            foreach ($columnsType as $name => $type) {
                $type = strtolower($type);
                if ($j !== 0) {
                    $masterQuery .= ', ';
                }
                $value = $row[$name];
                if ($value === null) {
                    $masterQuery .= 'NULL';
                } else {
                    if (preg_match('/(blob|binary|point|geometry)/', $type) || false === detectEncode($value)) {
                        $value = 'X\'' . bin2hex($value) . '\'';
                    } else {
                        $value = DB::quote($value);
                    }
                    $masterQuery .= $value;
                }
                $j++;
            }
            $masterQuery .= ');' . PHP_EOL;
            $masterQuery = preg_replace('/' . DB_PREFIX . '/', 'DB_PREFIX_STR_', $masterQuery);
            if ('UTF-8' !== DB_CHARSET) {
                if (!is_string($masterQuery)) {
                    return;
                }
                $val = mb_convert_encoding($masterQuery, "UTF-8", DB_CHARSET);
                if ($val === false) {
                    throw new \RuntimeException('mb_convert_encoding failed. ' . DB_CHARSET . ' -> UTF-8');
                }
                if ($masterQuery === mb_convert_encoding($val, DB_CHARSET, 'UTF-8')) {
                    $masterQuery = $val;
                }
            }
            if ($masterQuery) {
                fwrite($handle, $masterQuery);
            }
        }
    }

    /**
     * ドメインの書き換え
     *
     * @param string $new_domain
     * @param string $name
     *
     * @return void
     * @throws \RuntimeException 書き換えに失敗した場合
     */
    public function rewriteDomain($new_domain, $name)
    {
        $sql = [
            'sql' => 'UPDATE `' . $name . '` SET blog_domain=' . DB::quote($new_domain),
            'params' => [],
        ];
        // exec モードは失敗時に false を返す。入れ替え直前の更新失敗を見逃さないよう例外化する。
        if (DB::query($sql, 'exec') === false) {
            throw new \RuntimeException('リストア対象のドメイン書き換えに失敗しました。 ' . implode(' ', DB::errorInfo()));
        }
    }

    /**
     * @throws \RuntimeException
     */
    public function authorityValidation()
    {
        $table = 'TEMP_' . date('yMd_His');
        $new_table = 'R_' . $table;

        if (!DB::query(['sql' => 'CREATE TABLE `' . $table . '` (test VARCHAR(1))', 'params' => []], 'exec')) {
            throw new \RuntimeException('CREATE TABLE権限がありません。 ' . implode(' ', DB::errorInfo()));
        }

        if (!DB::query(['sql' => 'RENAME TABLE `' . $table . '` TO `' . $new_table . '`', 'params' => []], 'exec')) {
            throw new \RuntimeException('RENAME TABLEする権限がありません。 ' . implode(' ', DB::errorInfo()));
        }

        if (!DB::query(['sql' => 'DROP TABLE `' . $new_table . '`', 'params' => []], 'exec')) {
            throw new \RuntimeException('DROP TABLEする権限がありません。 ' . implode(' ', DB::errorInfo()));
        }

        if (!DB::query(['sql' => 'SHOW TABLES FROM `' . DB_NAME . '`', 'params' => []], 'exec')) {
            throw new \RuntimeException('SHOW TABLESする権限がありません。 ' . implode(' ', DB::errorInfo()));
        }
    }
}
