<?php

namespace Acms\Services\Update\Database;

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

/**
 * このクラスは、バージョンアップ時に「特別な更新処理」を行うためのクラスです。
 *
 * 注意点：
 * - アップデート処理の実行時には、新しいバージョンで追加されたクラスやメソッドは、まだ読み込まれていないため利用できません。
 * - そのため、このクラスでは 「オンラインアップデート機能」が追加された v2.8.0 から利用できる機能でのみ実装してください。
 *
 * 例 DB::next() は利用できません。v3.2.0 で追加されたため。
 */
class Rule
{
    /**
     * @var string
     */
    protected $fromVersion;

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

    /**
     * 例外的なアップデートを実行
     *
     * @param string $fromVersion
     * @param string $toVersion
     */
    public function update($fromVersion, $toVersion)
    {
        $this->fromVersion = $fromVersion;
        $this->toVersion = $toVersion;

        // v1.4.0以前
        if (version_compare($this->fromVersion, '1.4.0', '<')) {
            $this->update140();
        }
        // v1.4.2以前
        if (version_compare($this->fromVersion, '1.4.2', '<')) {
            $this->update142();
        }
        // v1.5.0以前
        if (version_compare($this->fromVersion, '1.5.0', '<')) {
            $this->update150();
        }
        // v2.10.0以前
        if (version_compare($this->fromVersion, '2.10.0', '<')) {
            $this->update2100();
        }
        // v3.2.18以前
        if (version_compare($this->fromVersion, '3.2.18', '<')) {
            $this->update3218();
        }
        // v3.2.25以前
        if (version_compare($this->fromVersion, '3.2.25', '<')) {
            $this->update3225();
        }

        // v3.1.49 以前 & v3.2.6 以前 のバージョンでsetupからのDBアップデートで以下の処理が実行されていないため、
        // バージョン比較をせずに実行するようにしています。
        // - update3150()
        // - update320()
        // - update321()

        // v3.1.50以前
        $this->update3150();

        // v3.2.0以前
        $this->update320();

        // v3.2.1以前
        $this->update321();

        // setup のインデックス修復で PRIMARY KEY を失った環境を復旧するため、バージョン比較をせずに実行する
        $this->fixLastAccessPrimaryKey();
        $this->fixCacheTablesPrimaryKey();
    }

    /**
     * フィールドグループのコンフィグを追加
     *
     * @param string $group
     * @param array $vals
     * @param null|int $bid
     * @param null|int $rid
     * @param null|int $mid
     */
    protected function addGroupConfig($group, $vals, $bid, $rid = null, $mid = null)
    {
        $DB = DB::singleton(dsn());
        foreach ($vals as $val) {
            $SQL = SQL::newInsert('config');
            $SQL->addInsert('config_key', $group);
            $SQL->addInsert('config_value', $val);
            $SQL->addInsert('config_sort', '0');
            if (!empty($mid)) {
                $SQL->addInsert('config_module_id', $mid);
            } elseif (!empty($rid)) {
                $SQL->addInsert('config_rule_id', $rid);
            }
            $SQL->addInsert('config_blog_id', $bid);
            $DB->query($SQL->get(dsn()), 'exec');
        }
    }

    /**
     * navigation_publish = on を追加
     *
     * @param int $bid
     * @param null|int $rid
     * @param null|int $mid
     */
    protected function addNavigationPublish($bid, $rid = null, $mid = null)
    {
        $DB = DB::singleton(dsn());
        $SQL = SQL::newSelect('config');
        $SQL->addWhereOpr('config_key', 'navigation_target');
        $SQL->addWhereOpr('config_rule_id', $rid);
        $SQL->addWhereOpr('config_module_id', $mid);
        $SQL->addWhereOpr('config_blog_id', $bid);
        $SQL->addSelect('*', 'row_amount', null, 'COUNT');
        $SQL->addSelect('config_sort', 'max_sort', null, 'MAX');
        $res = $DB->query($SQL->get(dsn()), 'row');

        if (empty($res)) {
            return;
        }

        $range = range($res['max_sort'] + 1, $res['max_sort'] + $res['row_amount']);

        foreach ($range as $sort) {
            $SQL = SQL::newInsert('config');
            $SQL->addInsert('config_key', 'navigation_publish');
            $SQL->addInsert('config_value', 'on');
            $SQL->addInsert('config_sort', $sort);
            $SQL->addInsert('config_rule_id', $rid);
            $SQL->addInsert('config_module_id', $mid);
            $SQL->addInsert('config_blog_id', $bid);
            $DB->query($SQL->get(dsn()), 'exec');
        }
    }

    /**
     * v1.4.0以前からのアップデート
     * コンフィグ画面用のconfigフィールドグループを追加
     */
    private function update140()
    {
        $DB = DB::singleton(dsn());

        // モジュールIDを探索
        $SQL = SQL::newSelect('module');
        $SQL->addWhereOpr('module_name', 'Links');
        $mods = $DB->query($SQL->get(dsn()), 'all');

        foreach ($mods as $mod) {
            $mid = $mod['module_id'];
            $bid = $mod['module_blog_id'];

            $this->addGroupConfig('@linkgroup', ['links_value', 'links_label'], $bid, null, $mid);
        }

        // ルールを探索
        $SQL = SQL::newSelect('rule');
        $rules = $DB->query($SQL->get(dsn()), 'all');

        foreach ($rules as $rule) {
            $rid = $rule['rule_id'];
            $bid = $rule['rule_blog_id'];

            $this->addGroupConfig(
                '@linkgroup',
                ['links_value', 'links_label'],
                $bid,
                $rid,
                null
            );
            $this->addGroupConfig(
                '@addtype_group',
                ['addtype_mimetype', 'addtype_extension'],
                $bid,
                $rid,
                null
            );
            $this->addGroupConfig(
                '@column_text_tag_group',
                ['column_text_tag', 'column_text_tag_label'],
                $bid,
                $rid,
                null
            );
            $this->addGroupConfig(
                '@column_image_size_group',
                ['column_image_size', 'column_image_size_label'],
                $bid,
                $rid,
                null
            );
            $this->addGroupConfig(
                '@column_map_size_group',
                ['column_map_size', 'column_map_size_label'],
                $bid,
                $rid,
                null
            );
            $this->addGroupConfig(
                '@column_youtube_size_group',
                ['column_youtube_size', 'column_youtube_size_label'],
                $bid,
                $rid,
                null
            );
            $this->addGroupConfig(
                '@column_eximage_size_group',
                ['column_eximage_size', 'column_eximage_size_label'],
                $bid,
                $rid,
                null
            );
            $this->addGroupConfig(
                '@column_add_type_group',
                ['column_add_type', 'column_add_type_label'],
                $bid,
                $rid,
                null
            );
        }
    }

    /**
     * v1.4.2以前からのアップデート
     * Api_Yahoo_* の名前変更対応
     */
    private function update142()
    {
        $DB = DB::singleton(dsn());

        $SQL = SQL::newUpdate('module');
        $SQL->addUpdate('module_name', 'Api_Yahoo_WebSearch');
        $SQL->addWhereOpr('module_name', 'Api_YahooWebSearch');
        $DB->query($SQL->get(dsn()), 'exec');

        $SQL = SQL::newUpdate('module');
        $SQL->addUpdate('module_name', 'Api_Yahoo_ImageSearch');
        $SQL->addWhereOpr('module_name', 'Api_YahooImageSearch');
        $DB->query($SQL->get(dsn()), 'exec');

        // ユーザーfulltextを生成・追加
        $SQL = SQL::newSelect('user');
        $q = $SQL->get(dsn());
        $all = $DB->query($q, 'all');

        foreach ($all as $row) {
            // user
            $user = [
                $row['user_name'],
                $row['user_code'],
                $row['user_mail'],
                $row['user_mail_mobile'],
                $row['user_url']
            ];
            $uid = $row['user_id'];
            $bid = $row['user_blog_id'];

            // meta
            $meta = [];
            $SQL = SQL::newSelect('field');
            $SQL->addSelect('field_value');
            $SQL->addWhereOpr('field_search', 'on');
            $SQL->addWhereOpr('field_uid', $uid);
            $_q = $SQL->get(dsn());
            $all2 = $DB->query($_q, 'all');

            foreach ($all2 as $_row) {
                $meta[] = $_row['field_value'];
            }

            // merge
            $user = preg_replace('@\s+@', ' ', strip_tags(implode(' ', $user)));
            $meta = preg_replace('@\s+@', ' ', strip_tags(implode(' ', $meta)));
            $fulltext = $user . "\x0d\x0a\x0a\x0d" . $meta;

            // delete
            $SQL = SQL::newDelete('fulltext');
            $SQL->addWhereOpr('fulltext_uid', $uid);
            $DB->query($SQL->get(dsn()), 'exec');

            // save
            $SQL = SQL::newInsert('fulltext');
            $SQL->addInsert('fulltext_value', $fulltext);
            $SQL->addInsert('fulltext_uid', $uid);
            $SQL->addInsert('fulltext_blog_id', $bid);
            $DB->query($SQL->get(dsn()), 'exec');
        }
    }

    /**
     * v1.5.0以前からのアップデート
     * navigation_publish = on を追加
     */
    private function update150()
    {
        $DB = DB::singleton(dsn());

        // モジュールIDを探索
        $SQL = SQL::newSelect('module');
        $SQL->addWhereOpr('module_name', 'Navigation');
        $mods = $DB->query($SQL->get(dsn()), 'all');

        if (!empty($mods)) {
            foreach ($mods as $mod) {
                $mid = $mod['module_id'];
                $bid = $mod['module_blog_id'];

                $this->addNavigationPublish($bid, null, $mid);
            }
        }

        // ルールを探索
        $SQL = SQL::newSelect('rule');
        $rules = $DB->query($SQL->get(dsn()), 'all');

        if (!empty($mods)) {
            foreach ($rules as $rule) {
                $rid = $rule['rule_id'];
                $bid = $rule['rule_blog_id'];

                $this->addNavigationPublish($bid, $rid, null);
            }
        }

        // デフォルトを探索
        $SQL = SQL::newSelect('blog');
        $blogs = $DB->query($SQL->get(dsn()), 'all');

        if (!empty($blogs)) {
            foreach ($blogs as $blog) {
                $bid = $blog['blog_id'];

                $this->addNavigationPublish($bid, null, null);
            }
        }
    }

    /**
     * v2.10.0以前からのアップデート
     * workflowを
     */
    private function update2100()
    {
        $DB = DB::singleton(dsn());

        $startList = [];
        $SQL = SQL::newSelect('config');
        $SQL->addWhereOpr('config_key', 'workflow_start_group');
        $q = $SQL->get(dsn());
        $all = $DB->query($q, 'all');
        foreach ($all as $row) {
            $startList[$row['config_blog_id']][] = $row['config_value'];
        }

        $lastList = [];
        $SQL = SQL::newSelect('config');
        $SQL->addWhereOpr('config_key', 'workflow_last_group');
        $q = $SQL->get(dsn());
        $all2 = $DB->query($q, 'all');
        foreach ($all2 as $row) {
            $lastList[$row['config_blog_id']][] = $row['config_value'];
        }

        foreach ($startList as $bid => $items) {
            $SQL = SQL::newUpdate('workflow');
            $SQL->addUpdate('workflow_start_group', implode(',', $items));
            $SQL->addWhereOpr('workflow_blog_id', $bid);
            $SQL->addWhereOpr('workflow_category_id', null);
            $DB->query($SQL->get(dsn()), 'exec');
        }

        foreach ($lastList as $bid => $items) {
            $SQL = SQL::newUpdate('workflow');
            $SQL->addUpdate('workflow_last_group', implode(',', $items));
            $SQL->addWhereOpr('workflow_blog_id', $bid);
            $SQL->addWhereOpr('workflow_category_id', null);
            $DB->query($SQL->get(dsn()), 'exec');
        }
    }

    /**
     * v3.1.50以前からのアップデート
     * フィールドにタイプのカラムを追加
     * メディアフィールドの検索用インデックスのため
     *
     * @return void
     */
    private function update3150(): void
    {
        $sql = SQL::newUpdate('field');
        $sql->addUpdate('field_type', 'media');
        $sql->addWhereOpr('field_key', '%@media', 'LIKE');
        DB::query($sql->get(dsn()), 'exec');

        $sql = SQL::newUpdate('field');
        $sql->addUpdate('field_type', 'html');
        $sql->addWhereOpr('field_key', '%@html', 'LIKE');
        DB::query($sql->get(dsn()), 'exec');

        $sql = SQL::newUpdate('field');
        $sql->addUpdate('field_type', 'title');
        $sql->addWhereOpr('field_key', '%@title', 'LIKE');
        DB::query($sql->get(dsn()), 'exec');
    }

    /**
     * v3.2.0以前からのアップデート
     * - カスタムユニットのフィールドデータをcolumnテーブルからフィールドテーブルに移動
     * - column_align カラムに存在する非表示を acms_column, acms_column_rev テーブルの column_status カラムのデータとして扱えるようにアップデート時にデータコンバートを行う
     *
     * @return void
     */
    private function update320(): void
    {
        $this->updateCustomUnitData();
        $this->updateUnitAlignToStatus();
    }

    /**
     * v3.2.0以前からのアップデート
     * カスタムユニットのフィールドデータをcolumnテーブルからフィールドテーブルに移動
     *
     * @return void
     */
    private function updateCustomUnitData(): void
    {
        // columnテーブルのcustomユニット情報をfieldテーブルに移動
        $sql = SQL::newSelect('column');
        $sql->addSelect('column_id');
        $sql->addSelect('column_field_6');
        $sql->addSelect('column_blog_id');
        $sql->addWhereOpr('column_type', 'custom_%', 'LIKE', 'OR');
        $sql->addWhereOpr('column_type', 'custom', '=', 'OR');
        $q = $sql->get(dsn());
        $all = DB::query($q, 'all');

        $unitIds = array_column($all, 'column_id');
        if (count($unitIds) > 0) {
            $selectFieldSql = SQL::newSelect('field');
            $selectFieldSql->addSelect('field_unit_id');
            $selectFieldSql->addWhereOpr('field_unit_id', $unitIds[0]);
            $q = $selectFieldSql->get(dsn());
            $isConverted = !!DB::query($q, 'one');
            // すでに移行済みのデータが存在する場合はスキップ
            // すべてのユニットデータが field テーブルに移行済みかどうかを確認するのは、パフォーマンス的に心配なので
            // 最初のユニットデータが field テーブルに移行済みかどうかを確認するようにしています。
            if (!$isConverted) {
                foreach ($all as $item) {
                    $id = (string) $item['column_id'];
                    $bid = (int) $item['column_blog_id'];
                    $field = function_exists('acmsDangerUnserialize') ? acmsDangerUnserialize($item['column_field_6']) : acmsUnserialize($item['column_field_6']);
                    if ($id !== '' && ($field instanceof Field)) {
                        foreach ($field->listFields() as $fd) {
                            foreach ($field->getArray($fd, true) as $i => $val) {
                                $fieldTypeValue = null;
                                if (preg_match('/@(html|media|title)$/', $fd, $match)) {
                                    $fieldTypeValue = $match[1];
                                }
                                if ($fieldType = $field->getMeta($fd, 'type')) {
                                    $fieldTypeValue = $fieldType;
                                }
                                $data = [
                                    'field_key' => $fd,
                                    'field_value' => $val,
                                    'field_type' => $fieldTypeValue,
                                    'field_sort' => $i + 1,
                                    'field_search' => $field->getMeta($fd, 'search') ? 'on' : 'off',
                                    'field_unit_id' => $id,
                                    'field_blog_id' => $bid,
                                ];
                                $insertFieldSql = SQL::newInsert('field');
                                foreach ($data as $key => $value) {
                                    $insertFieldSql->addInsert($key, $value);
                                }
                                DB::query($insertFieldSql->get(dsn()), 'exec');
                            }
                        }
                    }
                }
            }
        }
        // column_revテーブルのcustomユニット情報をfield_revテーブルに移動
        $sql = SQL::newSelect('column_rev');
        $sql->addSelect('column_id');
        $sql->addSelect('column_rev_id');
        $sql->addSelect('column_field_6');
        $sql->addSelect('column_blog_id');
        $sql->addWhereOpr('column_type', 'custom_%', 'LIKE', 'OR');
        $sql->addWhereOpr('column_type', 'custom', '=', 'OR');
        $q = $sql->get(dsn());
        $all = DB::query($q, 'all');
        $unitIds = array_column($all, 'column_id');
        if (count($unitIds) > 0) {
            $selectFieldRevSql = SQL::newSelect('field_rev');
            $selectFieldRevSql->addSelect('field_unit_id');
            $selectFieldRevSql->addWhereOpr('field_unit_id', $unitIds[0]);
            $q = $selectFieldRevSql->get(dsn());
            $isConverted = !!DB::query($q, 'one');
            // すでに移行済みのデータが存在する場合はスキップ
            // すべてのユニットデータが field_rev テーブルに移行済みかどうかを確認するのは、パフォーマンス的に心配なので
            // 最初のユニットデータが field_rev テーブルに移行済みかどうかを確認するようにしています。
            if (!$isConverted) {
                foreach ($all as $item) {
                    $id = (string) $item['column_id'];
                    $bid = (int) $item['column_blog_id'];
                    $rvid = (int) $item['column_rev_id'];
                    $field = function_exists('acmsDangerUnserialize') ? acmsDangerUnserialize($item['column_field_6']) : acmsUnserialize($item['column_field_6']);
                    if ($id !== '' && ($field instanceof Field)) {
                        foreach ($field->listFields() as $fd) {
                            foreach ($field->getArray($fd, true) as $i => $val) {
                                $fieldTypeValue = null;
                                if (preg_match('/@(html|media|title)$/', $fd, $match)) {
                                    $fieldTypeValue = $match[1];
                                }
                                if ($fieldType = $field->getMeta($fd, 'type')) {
                                    $fieldTypeValue = $fieldType;
                                }
                                $data = [
                                    'field_key' => $fd,
                                    'field_value' => $val,
                                    'field_type' => $fieldTypeValue,
                                    'field_sort' => $i + 1,
                                    'field_search' => $field->getMeta($fd, 'search') ? 'on' : 'off',
                                    'field_unit_id' => $id,
                                    'field_rev_id' => $rvid,
                                    'field_blog_id' => $bid,
                                ];
                                $insertFieldRevSql = SQL::newInsert('field_rev');
                                foreach ($data as $key => $value) {
                                    $insertFieldRevSql->addInsert($key, $value);
                                }
                                DB::query($insertFieldRevSql->get(dsn()), 'exec');
                            }
                        }
                    }
                }
            }
        }
    }

    /**
     * v3.2.0以前からのアップデート
     * column_align カラムに存在する非表示を acms_column, acms_column_rev テーブルの column_status カラムのデータとして扱えるようにアップデート時にデータコンバートを行う
     * 配置は center に変更する
     *
     * @return void
     */
    private function updateUnitAlignToStatus(): void
    {
        $sql = SQL::newUpdate('column');
        $sql->addUpdate('column_status', 'close');
        $sql->addUpdate('column_align', 'center');
        $sql->addWhereOpr('column_align', 'hidden');
        DB::query($sql->get(dsn()), 'exec');

        $sql = SQL::newUpdate('column_rev');
        $sql->addUpdate('column_status', 'close');
        $sql->addUpdate('column_align', 'center');
        $sql->addWhereOpr('column_align', 'hidden');
        DB::query($sql->get(dsn()), 'exec');
    }

    /**
     * v3.1.55以前からのアップデート
     *
     * 監査ログの秘匿化項目をマスク化
     *
     * @return void
     */
    private function update321(): void
    {
        $sql = SQL::newSelect('audit_log');
        $sql->addWhereOpr('audit_log_acms_post', 'Member\_%', 'LIKE');
        $sql->addWhereOpr('audit_log_req_body', null, '<>');
        $sql->addWhereOpr('audit_log_req_body', '[]', '<>');
        $sql->addWhereOpr('audit_log_req_body', '', '<>');
        $all = DB::query($sql->get(dsn()), 'all');
        if (!$all) {
            return;
        }
        foreach ($all as $row) {
            $body = json_decode($row['audit_log_req_body'], true);
            if (!$body || !is_array($body)) {
                continue;
            }
            $data = $this->filterArray($body);
            $data = json_encode($data);

            $update = SQL::newUpdate('audit_log');
            $update->addUpdate('audit_log_req_body', $data ? $data : null);
            $update->addWhereOpr('audit_log_id', $row['audit_log_id']);
            DB::query($update->get(dsn()), 'exec');
        }
    }

    /**
     * 配列を再帰的にフィルタリング
     *
     * Why not: Services/Logger/Filter に同等のロジックがあるが、このクラスからは呼ばない。
     * クラス冒頭の注意にあるとおり、アップデート処理は「差し替え前の古いコードが読み込まれた
     * プロセス」で走る。Filter は LoggerServiceProvider がリクエスト初期に new するため、
     * ここに到達した時点で「古い Filter の定義がメモリに載っている」状態になる。ファイルが
     * 新しくなっていても既に読み込まれたクラスは差し替わらないので、Filter へ委譲すると
     * 新バージョンで追加・修正した挙動は効かず、シグネチャを変えた場合は fatal になる。
     *
     * そのため重複を承知でインライン実装のままにする。ここを共通化したくなったら、まず
     * 「アップデート実行時に旧定義がメモリに載っていても壊れないか」を確かめること。
     *
     * Why not: match / アロー関数 / array_any() / str_contains() を使わない。アップデート処理は
     * リクエスト開始時の古い index.php で走っており、読み込まれている config/polyfill.php も
     * 古い版になる。array_any() の polyfill が入ったのは 3.2.0-alpha.5 なので、3.2.0 より前から
     * アップデートすると undefined function で fatal になる。しかも update321() は setup 経由で
     * 未実行の環境を救うためバージョン比較なしで毎回走るため、3.1.x からの更新で必ず踏む。
     * match とアロー関数は構文なので polyfill では救えず、古い PHP ではパースエラーになる。
     * クラス冒頭の「v2.8.0 から利用できる機能でのみ実装する」という制約どおり、素の PHP で書く。
     *
     * @param array $data
     * @return array
     */
    private function filterArray(array $data): array
    {
        $filtered = [];

        // 機密フィールドのリスト（部分一致用）
        $sensitiveFields = [
            'password',
            'passwd',
            'pwd',
            'pass',
            'retype_pass',
            'code',
            'recovery',
            'takeover',
            'token',
            'api_key',
            'secret',
            'formUniqueToken',
            'formToken',
        ];

        foreach ($data as $key => $value) {
            // Why not: is_array() の判定を機密キー判定より先に置かない。先に配列へ分岐すると
            // `password[]=secret` のようにキー自体が機密で値が配列のケースが素通りする。
            $isSensitive = false;
            if (is_string($key)) {
                $lowerKey = strtolower($key);
                foreach ($sensitiveFields as $sensitiveField) {
                    if (strpos($lowerKey, strtolower($sensitiveField)) !== false) {
                        $isSensitive = true;
                        break;
                    }
                }
            }
            if ($isSensitive) {
                $filtered[$key] = '***MASKED***';
            } elseif (is_array($value)) {
                $filtered[$key] = $this->filterArray($value);
            } else {
                $filtered[$key] = $value;
            }
        }
        return $filtered;
    }

    /**
     * v3.2.18以前からのアップデート
     * エントリーインポートのバグにより壊れたカスタムユニットのメディアフィールドの値を正しい値に修正する
     *
     * @return void
     */
    private function update3218(): void
    {
        $fieldTable = DB_PREFIX . 'field';
        $sql = "UPDATE `{$fieldTable}` AS `media`
            INNER JOIN `{$fieldTable}` AS `base`
                ON `base`.`field_unit_id` = `media`.`field_unit_id`
                AND `base`.`field_blog_id` = `media`.`field_blog_id`
                AND `base`.`field_key` = LEFT(`media`.`field_key`, CHAR_LENGTH(`media`.`field_key`) - 6)
                AND `base`.`field_sort` = `media`.`field_sort`
            SET `base`.`field_value` = `media`.`field_value`
            WHERE `media`.`field_unit_id` IS NOT NULL
                AND `media`.`field_unit_id` <> ''
                AND `media`.`field_key` LIKE '%@media'
        ";
        DB::query($sql, 'exec');
    }

    /**
     * v3.2.25以前からのアップデート
     *
     * シーケンスで採番するテーブルのうち、ID カラム単体の一意制約を持たないテーブルの
     * PRIMARY KEY を「ID カラム単体」へ張り替える。これにより、移設時のシーケンス未同期などで
     * 同一 ID が重複採番される事故を DB レベルで防止する。
     *
     * 旧来の複合キーは db.index.yaml の定義に従って別インデックスとして自動追加されるため、
     * 本処理は updateIndexs() では実行できない PRIMARY KEY の張り替えのみを担当する。
     * schedule は 1 件が年月ごとに複数行を持つ仕様のため対象外。
     *
     * @return void
     */
    private function update3225(): void
    {
        // プレフィックス無しのテーブル名 => ID カラム名
        $targets = [
            'blog' => 'blog_id',
            'category' => 'category_id',
            'comment' => 'comment_id',
            'module' => 'module_id',
            'rule' => 'rule_id',
        ];
        $primaryKey = new PrimaryKey();
        foreach ($targets as $table => $idColumn) {
            $this->convertPrimaryKeyToSingleId($primaryKey, $table, $idColumn);
        }
    }

    /**
     * 指定テーブルの PRIMARY KEY を ID カラム単体へ張り替える（冪等・重複ガード付き）。
     *
     * すでに ID カラム単体になっていれば何もしない。重複 ID が残っているテーブルは、
     * PRIMARY KEY 追加が失敗してアップグレードを止めてしまわないよう、スキップして警告ログを残す。
     *
     * @param PrimaryKey $primaryKey
     * @param string $table プレフィックス無しのテーブル名
     * @param string $idColumn ID カラム名
     * @return void
     */
    private function convertPrimaryKeyToSingleId(PrimaryKey $primaryKey, string $table, string $idColumn): void
    {
        $prefixedTable = DB_PREFIX . $table;

        // 現在の PRIMARY KEY 構成カラムを取得する
        $indexRows = DB::query("SHOW INDEX FROM `{$prefixedTable}`", 'all');
        if (!is_array($indexRows)) {
            $this->safeLog('warning', 'インデックス情報を取得できないため PRIMARY KEY の単体化をスキップしました', [
                'table' => $prefixedTable,
            ]);
            return;
        }
        $primaryKeyColumns = $primaryKey->extractPrimaryKeyColumns($indexRows);

        // すでに ID カラム単体になっていれば何もしない（冪等）
        if ($primaryKey->isAlreadyConverted($primaryKeyColumns, $idColumn)) {
            return;
        }

        // 重複 ID が残っている場合は ALTER が失敗するため、スキップして警告（アップグレードは止めない）
        $duplicated = DB::query($primaryKey->buildDuplicateCheckSql($prefixedTable, $idColumn), 'all');
        if (is_array($duplicated) && count($duplicated) > 0) {
            $this->safeLog('warning', '重複する ID が存在するため PRIMARY KEY の単体化をスキップしました。データを修正のうえ再度アップデートしてください', [
                'table' => $prefixedTable,
                'column' => $idColumn,
            ]);
            return;
        }

        // PRIMARY KEY を ID カラム単体へ張り替える
        $result = DB::query(
            $primaryKey->buildConvertSql($prefixedTable, $idColumn, $primaryKeyColumns !== []),
            'exec'
        );
        if ($result === false) {
            $this->safeLog('warning', 'PRIMARY KEY の単体化に失敗しました', [
                'table' => $prefixedTable,
                'column' => $idColumn,
            ]);
            return;
        }
        $this->safeLog('info', 'PRIMARY KEY を ID カラム単体へ変更しました', [
            'table' => $prefixedTable,
            'column' => $idColumn,
        ]);
    }

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

    /**
     * last_access テーブルの PRIMARY KEY を復元
     *
     * last_access への書き込みは INSERT ... ON DUPLICATE KEY UPDATE で行われており、
     * 「1ユーザー1行」の維持を PRIMARY KEY (last_access_uid) に依存している。
     * PRIMARY KEY を失った環境では POST リクエストごとに同一ユーザーの行が増え続け、
     * last_access を結合するユーザー一覧に同一ユーザーが重複表示される。
     * 重複行をユーザーごとに最新の1行へ整理したうえで PRIMARY KEY を張り直す。
     *
     * @return void
     */
    private function fixLastAccessPrimaryKey(): void
    {
        $this->restoreLastAccessPrimaryKey('last_access');
    }

    /**
     * last_access テーブルの PRIMARY KEY を、欠落しているときだけ復元する。
     * 重複行をユーザーごとに最新の1行へ整理したうえで PRIMARY KEY (last_access_uid) を張り直す。
     *
     * @param string $logicalTable プレフィックスを含まない論理テーブル名（SQL ビルダー用）
     * @return void
     */
    protected function restoreLastAccessPrimaryKey(string $logicalTable): void
    {
        $table = DB_PREFIX . $logicalTable;
        $primaryKey = new PrimaryKey();

        $indexRows = DB::query("SHOW INDEX FROM `{$table}`", 'all');
        if (!is_array($indexRows)) {
            $this->safeLog('warning', 'インデックス情報を取得できないため last_access の PRIMARY KEY 復元をスキップしました', [
                'table' => $table,
            ]);
            return;
        }
        if ($primaryKey->extractPrimaryKeyColumns($indexRows) !== []) {
            return;
        }

        $sql = SQL::newSelect($logicalTable);
        $rows = DB::query($sql->get(dsn()), 'all');
        $latestRows = self::pickLatestLastAccessRows(is_array($rows) ? $rows : []);

        $sql = SQL::newDelete($logicalTable);
        DB::query($sql->get(dsn()), 'exec');
        foreach ($latestRows as $row) {
            $sql = SQL::newInsert($logicalTable);
            $sql->addInsert('last_access_uid', $row['last_access_uid']);
            $sql->addInsert('last_access_datetime', $row['last_access_datetime']);
            $sql->addInsert('last_access_ip', $row['last_access_ip']);
            $sql->addInsert('last_access_ua', $row['last_access_ua']);
            DB::query($sql->get(dsn()), 'exec');
        }

        $result = DB::query($primaryKey->buildConvertSql($table, 'last_access_uid', false), 'exec');
        if ($result === false) {
            $this->safeLog('warning', 'last_access の PRIMARY KEY 復元に失敗しました', [
                'table' => $table,
            ]);
            return;
        }
        $this->safeLog('info', 'last_access の PRIMARY KEY を復元しました', [
            'table' => $table,
        ]);
    }

    /**
     * cache_data / cache_tag テーブルの PRIMARY KEY を復元
     *
     * これらのキャッシュテーブルへの書き込みは INSERT ... ON DUPLICATE KEY UPDATE（DB キャッシュ
     * ドライバ。page/config/field/module は既定でこのドライバ）で行われ、「1キー1行」の維持を
     * PRIMARY KEY に依存している。3.2.24 以前の setup「インデックス修復」は PRIMARY を含む全
     * インデックスを削除する一方で PRIMARY を張り直さなかったため、実行歴のある環境では PRIMARY
     * KEY を失い、以後の書き込みで重複行が蓄積してアップデート時の ADD PRIMARY KEY が失敗する。
     * last_access と異なり履歴を保持する必要のない純粋なキャッシュのため、重複解消は TRUNCATE で
     * 十分（内容はアクセス時に自動再生成される）。
     *
     * @return void
     */
    private function fixCacheTablesPrimaryKey(): void
    {
        $this->restoreCacheTablePrimaryKey(DB_PREFIX . 'cache_data', ['cache_data_key']);
        $this->restoreCacheTablePrimaryKey(DB_PREFIX . 'cache_tag', ['cache_tag_name', 'cache_tag_key']);
    }

    /**
     * キャッシュテーブルの PRIMARY KEY を、欠落しているときだけ復元する。
     *
     * @param string $table プレフィックス済みテーブル名
     * @param list<string> $primaryKeyColumns 復元する PRIMARY KEY のカラム（複合キーは宣言順）
     * @return void
     */
    protected function restoreCacheTablePrimaryKey(string $table, array $primaryKeyColumns): void
    {
        $primaryKey = new PrimaryKey();

        $indexRows = DB::query("SHOW INDEX FROM `{$table}`", 'all');
        if (!is_array($indexRows)) {
            $this->safeLog('warning', 'インデックス情報を取得できないためキャッシュテーブルの PRIMARY KEY 復元をスキップしました', [
                'table' => $table,
            ]);
            return;
        }
        if ($primaryKey->extractPrimaryKeyColumns($indexRows) !== []) {
            return;
        }

        // 純粋なキャッシュのため、重複解消は TRUNCATE で十分（内容はアクセス時に自動再生成される）
        DB::query("TRUNCATE TABLE `{$table}`", 'exec');

        $result = DB::query($primaryKey->buildCompositeConvertSql($table, $primaryKeyColumns, false), 'exec');
        if ($result === false) {
            $this->safeLog('warning', 'キャッシュテーブルの PRIMARY KEY 復元に失敗しました', [
                'table' => $table,
            ]);
            return;
        }
        $this->safeLog('info', 'キャッシュテーブルの PRIMARY KEY を復元しました', [
            'table' => $table,
        ]);
    }

    /**
     * last_access の行一覧からユーザーごとに最新の1行を選ぶ
     *
     * @param array<int, array<string, mixed>> $rows
     * @return array<int, array<string, mixed>>
     */
    public static function pickLatestLastAccessRows(array $rows): array
    {
        $latestRows = [];
        foreach ($rows as $row) {
            $userId = (string) ($row['last_access_uid'] ?? '');
            if ($userId === '') {
                continue;
            }
            if (
                !isset($latestRows[$userId])
                || strcmp((string) $row['last_access_datetime'], (string) $latestRows[$userId]['last_access_datetime']) > 0
            ) {
                $latestRows[$userId] = $row;
            }
        }
        return array_values($latestRows);
    }
}
