<?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');
        }
    }

    /**
     * 配列を再帰的にフィルタリング
     *
     * @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) {
            $filtered[$key] = match (true) {
                is_array($value) => $this->filterArray($value),
                is_string($key) && array_any(
                    $sensitiveFields,
                    fn(string $sensitiveField): bool => str_contains(strtolower($key), strtolower($sensitiveField))
                ) => '***MASKED***',
                default => $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);
    }
}
