<?php

namespace Acms\Services\Blog;

use Acms\Services\Facades\Database;
use SQL;

class BlogRepository
{
    /**
     * blog 削除時に blog_id 系カラムで参照される関連テーブル一覧。
     *
     * `ablogcms/php/config/schema/db.schema.yaml` を走査し、blog_id 系カラムを持つテーブルを列挙したもの。
     * キーがテーブル名、値がそのテーブルにおける blog_id カラム名。スキーマ追加時はこの定数にも追記する。
     *
     * `_rev` 系テーブルおよび命名規則 `{table_name}_blog_id` を満たさないテーブル
     * (`role_blog.blog_id` など) も含めるため、カラム名を明示的に保持している。
     *
     * 以下のテーブルは意図的に除外している:
     *   - sequence:        全システム単一行で「現在採番されている最大 blog_id」を管理するため、
     *                      個別ブログ削除と紐づくレコードではない
     *   - workflow /
     *     workflow_usergroup: {@see \deleteWorkflow()} 経由でセットで削除されるため二重削除を避ける
     *   - media / media_tag: a-blog cms ではメディアは `media_id` 単独で参照され、所有 blog が
     *                      削除されても他 blog のエントリ / カスタムフィールド / ブロックエディタ
     *                      から参照可能な設計。blog 削除に伴って消すと他 blog でリンク切れを起こす
     *   - audit_log:       監査証跡は「削除されたブログ上で行われた操作」も後から検証できる必要が
     *                      あるため、blog 削除でカスケードしない
     *
     * @var array<string, string>
     */
    private const RELATED_TABLES = [
        'alias' => 'alias_blog_id',
        'app' => 'app_blog_id',
        'approval' => 'approval_blog_id',
        'approval_notification' => 'notification_blog_id',
        'cache_reserve' => 'cache_reserve_blog_id',
        'category' => 'category_blog_id',
        'column' => 'column_blog_id',
        'column_rev' => 'column_blog_id',
        'comment' => 'comment_blog_id',
        'config' => 'config_blog_id',
        'config_set' => 'config_set_blog_id',
        'dashboard' => 'dashboard_blog_id',
        'entry' => 'entry_blog_id',
        'entry_rev' => 'entry_blog_id',
        'entry_sub_category' => 'entry_sub_category_blog_id',
        'entry_sub_category_rev' => 'entry_sub_category_blog_id',
        'field' => 'field_blog_id',
        'field_rev' => 'field_blog_id',
        'form' => 'form_blog_id',
        'fulltext' => 'fulltext_blog_id',
        'geo' => 'geo_blog_id',
        'geo_rev' => 'geo_blog_id',
        'layout_grid' => 'layout_grid_blog_id',
        'log_form' => 'log_form_blog_id',
        'module' => 'module_blog_id',
        'role_blog' => 'blog_id',
        'rule' => 'rule_blog_id',
        'schedule' => 'schedule_blog_id',
        'shop_address' => 'address_blog_id',
        'shop_cart' => 'cart_blog_id',
        'tag' => 'tag_blog_id',
        'tag_rev' => 'tag_blog_id',
        'user' => 'user_blog_id',
        'webhook' => 'webhook_blog_id',
    ];

    /**
     * blog_id のシーケンスから次の値を採番
     */
    public function nextBlogId(): int
    {
        return (int) Database::query(SQL::nextval('blog_id', dsn()), 'seq');
    }

    /**
     * 指定ブログの nested set 境界 (left, right) を取得
     *
     * @return array{left: int, right: int}|null
     */
    public function findBoundary(int $blogId): ?array
    {
        $sql = SQL::newSelect('blog');
        $sql->setSelect('blog_left');
        $sql->addSelect('blog_right');
        $sql->addWhereOpr('blog_id', $blogId);
        $sql->setLimit(1);

        $row = Database::query($sql->get(dsn()), 'row');
        if (!$row) {
            return null;
        }
        return [
            'left' => (int) $row['blog_left'],
            'right' => (int) $row['blog_right'],
        ];
    }

    /**
     * 指定親ブログ配下で最後の子（最大 blog_right の行）を返す。
     * 新規追加時の挿入位置 / 親ブログ変更時の末尾追加位置の計算に利用する。
     *
     * @return array{right: int, sort: int}|null
     */
    public function findLastChildInParent(int $parentBlogId): ?array
    {
        $sql = SQL::newSelect('blog');
        $sql->addSelect('blog_right');
        $sql->addSelect('blog_sort');
        $sql->addWhereOpr('blog_parent', $parentBlogId);
        $sql->setOrder('blog_right', 'DESC');
        $sql->setLimit(1);

        $row = Database::query($sql->get(dsn()), 'row');
        if (!is_array($row) || count($row) === 0) {
            return null;
        }
        return [
            'right' => (int) $row['blog_right'],
            'sort' => (int) $row['blog_sort'],
        ];
    }

    /**
     * 指定ブログの行を取得する
     *
     * @return array<string, mixed>|null
     */
    public function findBlogById(int $blogId): ?array
    {
        $sql = SQL::newSelect('blog');
        $sql->addWhereOpr('blog_id', $blogId);
        $row = Database::query($sql->get(dsn()), 'row');
        if (!is_array($row) || count($row) === 0) {
            return null;
        }
        return $row;
    }

    /**
     * 指定ドメイン内に同じコードを持つブログが存在するか
     *
     * カテゴリーコードがブログコードと衝突しないかの検証等で利用する。
     */
    public function existsByDomainAndCode(string $domain, string $code): bool
    {
        $sql = SQL::newSelect('blog');
        $sql->setSelect('blog_id');
        $sql->addWhereOpr('blog_domain', $domain);
        $sql->addWhereOpr('blog_code', $code);
        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * ブログを新規 INSERT
     *
     * @param array<string, mixed> $columns カラム名 => 値
     */
    public function insert(array $columns): void
    {
        $sql = SQL::newInsert('blog');
        foreach ($columns as $column => $value) {
            $sql->addInsert($column, $value);
        }
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 指定カラムで対象ブログを UPDATE
     *
     * @param array<string, mixed> $columns カラム名 => 値
     */
    public function update(int $blogId, array $columns): void
    {
        $sql = SQL::newUpdate('blog');
        foreach ($columns as $column => $value) {
            $sql->addUpdate($column, $value);
        }
        $sql->addWhereOpr('blog_id', $blogId);
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 指定ブログの子孫ブログのうち、status が $statusIn に含まれる行を $status に一括書き換えする
     *
     * 対象ブログが存在しない場合は何もしない。
     *
     * @param array<int, string> $statusIn 置換対象として扱う status 値
     */
    public function updateDescendantsStatus(int $blogId, string $status, array $statusIn): void
    {
        $boundary = $this->findBoundary($blogId);
        if ($boundary === null) {
            return;
        }
        $sql = SQL::newUpdate('blog');
        $sql->setUpdate('blog_status', $status);
        $sql->addWhereIn('blog_status', $statusIn);
        $sql->addWhereOpr('blog_left', $boundary['left'], '>');
        $sql->addWhereOpr('blog_right', $boundary['right'], '<');
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 指定 ID 群の blog_status を一括更新
     *
     * @param array<int|string> $blogIds
     */
    public function updateStatusBulk(array $blogIds, string $status): void
    {
        if (count($blogIds) === 0) {
            return;
        }
        $sql = SQL::newUpdate('blog');
        $sql->setUpdate('blog_status', $status);
        $sql->addWhereIn('blog_id', $blogIds);
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 指定 ID 群の blog_config_set_id を一括更新
     *
     * @param array<int|string> $blogIds
     */
    public function updateConfigSetBulk(array $blogIds, ?int $configSetId): void
    {
        if (count($blogIds) === 0) {
            return;
        }
        $sql = SQL::newUpdate('blog');
        $sql->setUpdate('blog_config_set_id', $configSetId);
        $sql->addWhereIn('blog_id', $blogIds);
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * blog_maintenance_mode を更新する
     */
    public function updateMaintenanceMode(int $blogId, string $mode): void
    {
        $sql = SQL::newUpdate('blog');
        $sql->setUpdate('blog_maintenance_mode', $mode);
        $sql->addWhereOpr('blog_id', $blogId);
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * Blog 集約を物理削除する。
     *
     * 集約に属する以下の永続状態をまとめて整合的に削除する:
     *   - blog 行そのもの
     *   - 関連テーブル群（alias / category / entry / field / ... の blog_id を持つ行）
     *   - ワークフロー定義
     *   - nested set ツリーの再配置（兄弟 sort / 祖先・末尾の left,right）
     *
     * キャッシュ無効化はインフラ関心事のため、本メソッドの責務には含めない。
     * 呼び出し側で必要に応じて Cache::flush() を行うこと。
     *
     * 対象 blog_id が存在しない場合は何もしない。
     */
    public function delete(int $blogId): void
    {
        // 削除前に nested set / sort / parent を保持（realign に必要）
        $row = $this->findBlogById($blogId);
        if ($row === null) {
            return;
        }

        // 1. blog 行を物理削除
        $deleteBlog = SQL::newDelete('blog');
        $deleteBlog->addWhereOpr('blog_id', $blogId);
        Database::query($deleteBlog->get(dsn()), 'exec');

        // 2. 関連テーブルの blog_id 行を一括削除
        $this->deleteRelatedRecords($blogId);

        // 3. ワークフロー定義を削除（既存のグローバル関数を利用）
        \deleteWorkflow($blogId);

        // 4. nested set ツリーを再配置
        $this->realignTreeAfterRemoval($row);
    }

    /**
     * blog に紐づく関連テーブルのレコードをまとめて削除する
     */
    private function deleteRelatedRecords(int $blogId): void
    {
        foreach (self::RELATED_TABLES as $table => $blogIdColumn) {
            $sql = SQL::newDelete($table);
            $sql->addWhereOpr($blogIdColumn, $blogId);
            Database::query($sql->get(dsn()), 'exec');
        }
    }

    /**
     * 指定ブログの子孫に、指定ステータスのブログが 1 件以上存在するか
     *
     * 対象ブログが存在しない場合は false を返す。
     *
     * @param array<int, string> $statuses
     */
    public function hasDescendantWithStatusIn(int $blogId, array $statuses): bool
    {
        $boundary = $this->findBoundary($blogId);
        if ($boundary === null) {
            return false;
        }
        $sql = SQL::newSelect('blog');
        $sql->setSelect('blog_id');
        $sql->addWhereIn('blog_status', $statuses);
        $sql->addWhereOpr('blog_left', $boundary['left'], '>');
        $sql->addWhereOpr('blog_right', $boundary['right'], '<');
        $sql->setLimit(1);
        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * 挿入時に nested set の空きを確保する（指定 left より大きい値を 2 シフト）
     */
    public function shiftLeftRightAfter(int $left): void
    {
        $shiftLeft = SQL::newUpdate('blog');
        $shiftLeft->addUpdate('blog_left', SQL::newOpr('blog_left', 2, '+'));
        $shiftLeft->addWhereOpr('blog_left', $left, '>');
        Database::query($shiftLeft->get(dsn()), 'exec');

        $shiftRight = SQL::newUpdate('blog');
        $shiftRight->addUpdate('blog_right', SQL::newOpr('blog_right', 2, '+'));
        $shiftRight->addWhereOpr('blog_right', $left, '>=');
        Database::query($shiftRight->get(dsn()), 'exec');
    }

    /**
     * ブログ削除に伴う nested set ツリーの詰め直し
     *
     * - left/right が削除位置より後の行はギャップ 2 ぶん -2
     * - 削除ブログの内側（祖先 - 子孫境界内）は -1
     * - 同一親内の sort が削除位置より大きい兄弟は -1
     *
     * @param array<string, mixed> $blog 削除済みブログの行（blog_left/blog_right/blog_sort/blog_parent を含む）
     */
    private function realignTreeAfterRemoval(array $blog): void
    {
        $left = (int) $blog['blog_left'];
        $right = (int) $blog['blog_right'];
        $sort = (int) $blog['blog_sort'];
        $parentBlogId = (int) $blog['blog_parent'];

        $sql = SQL::newUpdate('blog');

        $leftCase = SQL::newCase();
        $leftCase->add(SQL::newOpr('blog_left', $right, '>'), SQL::newOpr('blog_left', 2, '-'));
        $leftCase->add(SQL::newOpr('blog_left', $left, '>'), SQL::newOpr('blog_left', 1, '-'));
        $leftCase->setElse(SQL::newField('blog_left'));
        $sql->addUpdate('blog_left', $leftCase);

        $rightCase = SQL::newCase();
        $rightCase->add(SQL::newOpr('blog_right', $right, '>'), SQL::newOpr('blog_right', 2, '-'));
        $rightCase->add(SQL::newOpr('blog_right', $left, '>'), SQL::newOpr('blog_right', 1, '-'));
        $rightCase->setElse(SQL::newField('blog_right'));
        $sql->addUpdate('blog_right', $rightCase);

        $sortCase = SQL::newCase();
        $sortWhere = SQL::newWhere();
        $sortWhere->addWhereOpr('blog_parent', $parentBlogId);
        $sortWhere->addWhereOpr('blog_sort', $sort, '>');
        $sortCase->add($sortWhere, SQL::newOpr('blog_sort', 1, '-'));
        $sortCase->setElse(SQL::newField('blog_sort'));
        $sql->addUpdate('blog_sort', $sortCase);

        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 同一親内での並び順を $toSort に変更し、関連する left/right/sort を再配置する
     *
     * 必要な nested set / sort / parent はメソッド内で SELECT するため、呼び出し側は
     * 「対象ブログ ID」と「移動先 sort」のみ渡せばよい。親ブログは対象ブログから自動解決する。
     *
     * @return bool 成功時 true、無効な対象 / 同位置 / 範囲外などは false
     */
    public function reorderSibling(int $blogId, int $toSort): bool
    {
        if ($blogId <= 0 || $toSort < 1) {
            return false;
        }

        // 移動元の left/right/sort/parent
        $fromSelect = SQL::newSelect('blog');
        $fromSelect->addSelect('blog_left');
        $fromSelect->addSelect('blog_right');
        $fromSelect->addSelect('blog_sort');
        $fromSelect->addSelect('blog_parent');
        $fromSelect->addWhereOpr('blog_id', $blogId);
        $fromRow = Database::query($fromSelect->get(dsn()), 'row');
        if (!$fromRow) {
            return false;
        }
        $fromLeft = (int) $fromRow['blog_left'];
        $fromRight = (int) $fromRow['blog_right'];
        $fromSort = (int) $fromRow['blog_sort'];
        $parentBlogId = (int) $fromRow['blog_parent'];

        // 同一親内の最大 sort
        $maxSortSelect = SQL::newSelect('blog');
        $maxSortSelect->setSelect('blog_sort');
        $maxSortSelect->addWhereOpr('blog_parent', $parentBlogId);
        $maxSortSelect->setOrder('blog_sort', 'DESC');
        $maxSortSelect->setLimit(1);
        $max = (int) Database::query($maxSortSelect->get(dsn()), 'one');
        if ($max <= 1 || $max < $toSort) {
            return false;
        }

        // 移動先位置 (sort=$toSort) の sibling から left/right
        $toSelect = SQL::newSelect('blog');
        $toSelect->addSelect('blog_left');
        $toSelect->addSelect('blog_right');
        $toSelect->addWhereOpr('blog_parent', $parentBlogId);
        $toSelect->addWhereOpr('blog_sort', $toSort);
        $toSelect->setLimit(1);
        $toRow = Database::query($toSelect->get(dsn()), 'row');
        if (!$toRow) {
            return false;
        }
        $toLeft = (int) $toRow['blog_left'];
        $toRight = (int) $toRow['blog_right'];

        $gap = ($fromRight - $fromLeft) + 1;

        $sql = SQL::newUpdate('blog');

        if ($fromRight > $toRight) {
            // 上方向
            $delta = $fromLeft - $toLeft;

            $leftCase = SQL::newCase();
            $leftCase->add(
                SQL::newOprBw('blog_left', $fromLeft, $fromRight),
                SQL::newOpr('blog_left', $delta, '-')
            );
            $leftWhere = SQL::newWhere();
            $leftWhere->addWhereOpr('blog_left', $fromLeft, '<');
            $leftWhere->addWhereOpr('blog_left', $toLeft, '>=');
            $leftCase->add($leftWhere, SQL::newOpr('blog_left', $gap, '+'));
            $leftCase->setElse(SQL::newField('blog_left'));
            $sql->addUpdate('blog_left', $leftCase);

            $rightCase = SQL::newCase();
            $rightCase->add(
                SQL::newOprBw('blog_right', $fromLeft, $fromRight),
                SQL::newOpr('blog_right', $delta, '-')
            );
            $rightWhere = SQL::newWhere();
            $rightWhere->addWhereOpr('blog_right', $fromLeft, '<');
            $rightWhere->addWhereOpr('blog_right', $toLeft, '>=');
            $rightCase->add($rightWhere, SQL::newOpr('blog_right', $gap, '+'));
            $rightCase->setElse(SQL::newField('blog_right'));
            $sql->addUpdate('blog_right', $rightCase);

            $sortCase = SQL::newCase();
            $selfSortWhere = SQL::newWhere();
            $selfSortWhere->addWhereOpr('blog_sort', $fromSort);
            $selfSortWhere->addWhereOpr('blog_parent', $parentBlogId);
            $sortCase->add($selfSortWhere, $toSort);
            $shiftSortWhere = SQL::newWhere();
            $shiftSortWhere->addWhereOpr('blog_sort', $toSort, '>=');
            $shiftSortWhere->addWhereOpr('blog_sort', $fromSort, '<');
            $shiftSortWhere->addWhereOpr('blog_parent', $parentBlogId);
            $sortCase->add($shiftSortWhere, SQL::newOpr('blog_sort', 1, '+'));
            $sortCase->setElse(SQL::newField('blog_sort'));
            $sql->addUpdate('blog_sort', $sortCase);
        } else {
            // 下方向
            $delta = $toRight - $fromRight;

            $leftCase = SQL::newCase();
            $leftCase->add(
                SQL::newOprBw('blog_left', $fromLeft, $fromRight),
                SQL::newOpr('blog_left', $delta, '+')
            );
            $leftWhere = SQL::newWhere();
            $leftWhere->addWhereOpr('blog_left', $fromRight, '>');
            $leftWhere->addWhereOpr('blog_left', $toRight, '<=');
            $leftCase->add($leftWhere, SQL::newOpr('blog_left', $gap, '-'));
            $leftCase->setElse(SQL::newField('blog_left'));
            $sql->addUpdate('blog_left', $leftCase);

            $rightCase = SQL::newCase();
            $rightCase->add(
                SQL::newOprBw('blog_right', $fromLeft, $fromRight),
                SQL::newOpr('blog_right', $delta, '+')
            );
            $rightWhere = SQL::newWhere();
            $rightWhere->addWhereOpr('blog_right', $fromRight, '>');
            $rightWhere->addWhereOpr('blog_right', $toRight, '<=');
            $rightCase->add($rightWhere, SQL::newOpr('blog_right', $gap, '-'));
            $rightCase->setElse(SQL::newField('blog_right'));
            $sql->addUpdate('blog_right', $rightCase);

            $sortCase = SQL::newCase();
            $selfSortWhere = SQL::newWhere();
            $selfSortWhere->addWhereOpr('blog_sort', $fromSort);
            $selfSortWhere->addWhereOpr('blog_parent', $parentBlogId);
            $sortCase->add($selfSortWhere, $toSort);
            $shiftSortWhere = SQL::newWhere();
            $shiftSortWhere->addWhereOpr('blog_sort', $fromSort, '>');
            $shiftSortWhere->addWhereOpr('blog_sort', $toSort, '<=');
            $shiftSortWhere->addWhereOpr('blog_parent', $parentBlogId);
            $sortCase->add($shiftSortWhere, SQL::newOpr('blog_sort', 1, '-'));
            $sortCase->setElse(SQL::newField('blog_sort'));
            $sql->addUpdate('blog_sort', $sortCase);
        }
        Database::query($sql->get(dsn()), 'exec');

        return true;
    }

    /**
     * ブログの親を付け替え、ツリーの left/right/sort を再配置する
     *
     * 必要な nested set / parent / sort はメソッド内で SELECT するため、呼び出し側は
     * 「対象ブログ ID」と「移動先親ブログ ID」のみ渡せばよい。
     *
     * 以下の場合は false を返す:
     *   - 対象ブログが存在しない
     *   - すでに同じ親（no-op）
     *   - 移動先親が自分自身、もしくは自身の子孫
     */
    public function changeParent(int $blogId, int $toParentBlogId): bool
    {
        if ($blogId <= 0 || $toParentBlogId <= 0) {
            return false;
        }
        if ($toParentBlogId === $blogId) {
            return false;
        }

        // 自身の boundary / parent / sort
        $fromSelect = SQL::newSelect('blog');
        $fromSelect->addSelect('blog_left');
        $fromSelect->addSelect('blog_right');
        $fromSelect->addSelect('blog_parent');
        $fromSelect->addSelect('blog_sort');
        $fromSelect->addWhereOpr('blog_id', $blogId);
        $fromRow = Database::query($fromSelect->get(dsn()), 'row');
        if (!$fromRow) {
            return false;
        }
        $fromLeft = (int) $fromRow['blog_left'];
        $fromRight = (int) $fromRow['blog_right'];
        $fromParentId = (int) $fromRow['blog_parent'];
        $fromSort = (int) $fromRow['blog_sort'];

        if ($toParentBlogId === $fromParentId) {
            return false;
        }

        // 移動先親の boundary
        $toSelect = SQL::newSelect('blog');
        $toSelect->addSelect('blog_left');
        $toSelect->addSelect('blog_right');
        $toSelect->addWhereOpr('blog_id', $toParentBlogId);
        $toRow = Database::query($toSelect->get(dsn()), 'row');
        if (!$toRow) {
            return false;
        }
        $toLeft = (int) $toRow['blog_left'];
        $toRight = (int) $toRow['blog_right'];

        // 新しい親が自身の子孫
        if ($toLeft > $fromLeft && $toRight < $fromRight) {
            return false;
        }

        // 移動先 sort: 既存子の最大 +1
        $toLastChild = $this->findLastChildInParent($toParentBlogId);
        $toSort = $toLastChild === null ? 1 : $toLastChild['sort'] + 1;

        $gap = ($fromRight - $fromLeft) + 1;

        $alignSql = SQL::newUpdate('blog');

        if ($fromRight > $toRight) {
            // 上方向への移動
            $delta = $fromLeft - $toRight;

            $leftCase = SQL::newCase();
            $leftCase->add(
                SQL::newOprBw('blog_left', $fromLeft, $fromRight),
                SQL::newOpr('blog_left', $delta, '-')
            );
            $leftWhere = SQL::newWhere();
            $leftWhere->addWhereOpr('blog_left', $toRight, '>=');
            $leftWhere->addWhereOpr('blog_left', $fromLeft, '<');
            $leftCase->add($leftWhere, SQL::newOpr('blog_left', $gap, '+'));
            $leftCase->setElse(SQL::newField('blog_left'));
            $alignSql->addUpdate('blog_left', $leftCase);

            $rightCase = SQL::newCase();
            $rightCase->add(
                SQL::newOprBw('blog_right', $fromLeft, $fromRight),
                SQL::newOpr('blog_right', $delta, '-')
            );
            $rightWhere = SQL::newWhere();
            $rightWhere->addWhereOpr('blog_right', $toRight, '>=');
            $rightWhere->addWhereOpr('blog_right', $fromLeft, '<');
            $rightCase->add($rightWhere, SQL::newOpr('blog_right', $gap, '+'));
            $rightCase->setElse(SQL::newField('blog_right'));
            $alignSql->addUpdate('blog_right', $rightCase);
        } else {
            // 下方向への移動
            $delta = $toRight - $fromRight - 1;

            $leftCase = SQL::newCase();
            $leftCase->add(
                SQL::newOprBw('blog_left', $fromLeft, $fromRight),
                SQL::newOpr('blog_left', $delta, '+')
            );
            $leftWhere = SQL::newWhere();
            $leftWhere->addWhereOpr('blog_left', $fromRight, '>');
            $leftWhere->addWhereOpr('blog_left', $toRight, '<');
            $leftCase->add($leftWhere, SQL::newOpr('blog_left', $gap, '-'));
            $leftCase->setElse(SQL::newField('blog_left'));
            $alignSql->addUpdate('blog_left', $leftCase);

            $rightCase = SQL::newCase();
            $rightCase->add(
                SQL::newOprBw('blog_right', $fromLeft, $fromRight),
                SQL::newOpr('blog_right', $delta, '+')
            );
            $rightWhere = SQL::newWhere();
            $rightWhere->addWhereOpr('blog_right', $fromRight, '>');
            $rightWhere->addWhereOpr('blog_right', $toRight, '<');
            $rightCase->add($rightWhere, SQL::newOpr('blog_right', $gap, '-'));
            $rightCase->setElse(SQL::newField('blog_right'));
            $alignSql->addUpdate('blog_right', $rightCase);
        }
        Database::query($alignSql->get(dsn()), 'exec');

        // 旧親の兄弟 sort を詰める
        $shiftSortSql = SQL::newUpdate('blog');
        $shiftSortSql->setUpdate('blog_sort', SQL::newOpr('blog_sort', 1, '-'));
        $shiftSortSql->addWhereOpr('blog_sort', $fromSort, '>');
        $shiftSortSql->addWhereOpr('blog_parent', $fromParentId);
        Database::query($shiftSortSql->get(dsn()), 'exec');

        // 自身の parent / sort を更新
        $this->update($blogId, [
            'blog_parent' => $toParentBlogId,
            'blog_sort' => $toSort,
        ]);

        // 親の status を継承（親が close/secret なら、自身と子孫の公開度を親に合わせる）
        $this->inheritStatusFromParent($blogId, $toParentBlogId);

        return true;
    }

    /**
     * 自身を含む配下の blog の status を、指定 status に一括書き換えする
     *
     * 公開度が緩い ($statusIn に含まれる) 行を対象とし、対象範囲は
     * 「指定ブログ自身を含む nested set 配下」(left/right を含む inclusive 範囲)。
     *
     * @param array<int, string> $statusIn 置換対象として扱う status 値
     */
    public function cascadeStatusToSubtree(int $blogId, string $status, array $statusIn): void
    {
        if (count($statusIn) === 0) {
            return;
        }
        $boundary = $this->findBoundary($blogId);
        if ($boundary === null) {
            return;
        }
        $sql = SQL::newUpdate('blog');
        $sql->setUpdate('blog_status', $status);
        $sql->addWhereIn('blog_status', $statusIn);
        $sql->addWhereOpr('blog_left', $boundary['left'], '>=');
        $sql->addWhereOpr('blog_right', $boundary['right'], '<=');
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 親ブログの status を、自身と子孫に継承させる
     *
     * 親が close なら自身と子孫の open / secret を close に、
     * 親が secret なら open を secret に書き換える。親が open なら何もしない。
     */
    private function inheritStatusFromParent(int $blogId, int $parentBlogId): void
    {
        $parentStatus = \ACMS_RAM::blogStatus($parentBlogId);
        if ($parentStatus === 'close') {
            $this->cascadeStatusToSubtree($blogId, 'close', ['open', 'secret']);
            return;
        }
        if ($parentStatus === 'secret') {
            $this->cascadeStatusToSubtree($blogId, 'secret', ['open']);
            return;
        }
    }
}
