<?php

declare(strict_types=1);

namespace Acms\Services\Category;

use Acms\Services\Facades\Field as FieldFacade;
use SQL;
use Acms\Services\Facades\Database;

/**
 * カテゴリーのリポジトリ
 */
class CategoryRepository
{
    /**
     * カテゴリーが存在するかチェック
     *
     * @param int $categoryId カテゴリーID
     * @param int|null $blogId ブログID（指定された場合、そのブログに属するかもチェック）
     * @return bool 存在する場合true
     */
    public function exists(int $categoryId, ?int $blogId = null): bool
    {
        $sql = SQL::newSelect('category');
        $sql->setSelect('category_id');
        $sql->addWhereOpr('category_id', $categoryId);
        $sql->addWhereOpr('category_status', 'open');
        if ($blogId !== null) {
            $where = SQL::newWhere();
            $where->addWhereOpr('category_blog_id', $blogId, '=', 'OR');
            $where->addWhereOpr('category_scope', 'global', '=', 'OR');
            $sql->addWhere($where);
        }
        $sql->setLimit(1);
        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * 指定ブログ内で category_right が最大の行を返す（新規追加時の位置計算に利用）
     *
     * @param int $blogId
     * @return array<string, mixed>|null
     */
    public function findMaxRightCategoryInBlog(int $blogId): ?array
    {
        $sql = SQL::newSelect('category');
        $sql->addWhereOpr('category_blog_id', $blogId);
        $sql->setOrder('category_right', 'DESC');
        $sql->setLimit(1);

        $row = Database::query($sql->get(dsn()), 'row');
        if (!is_array($row) || count($row) === 0) {
            return null;
        }
        return $row;
    }

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

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

    /**
     * 指定カテゴリーの左右値（nested set）境界を取得
     *
     * @return array{left: int, right: int}|null
     */
    public function findBoundary(int $categoryId): ?array
    {
        $sql = SQL::newSelect('category');
        $sql->setSelect('category_left');
        $sql->addSelect('category_right');
        $sql->addWhereOpr('category_id', $categoryId);
        $sql->setLimit(1);

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

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

    /**
     * 指定ブログ内、祖先境界 (ancestorLeft, ancestorRight) 内の子孫カテゴリーを
     * 一括 UPDATE（祖先自身は更新しない）
     *
     * @param array<string, mixed> $columns カラム名 => 値
     */
    public function updateDescendants(int $blogId, int $ancestorLeft, int $ancestorRight, array $columns): void
    {
        if (count($columns) === 0) {
            return;
        }
        $sql = SQL::newUpdate('category');
        foreach ($columns as $column => $value) {
            $sql->addUpdate($column, $value);
        }
        $sql->addWhereOpr('category_blog_id', $blogId);
        $sql->addWhereOpr('category_left', $ancestorLeft, '>');
        $sql->addWhereOpr('category_right', $ancestorRight, '<');
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 指定カテゴリーを参照するエントリーが、指定ブログ以外のブログに存在するか
     */
    public function hasEntryInOtherBlog(int $categoryId, int $blogId): bool
    {
        $sql = SQL::newSelect('entry');
        $sql->setSelect('entry_id');
        $sql->addWhereOpr('entry_category_id', $categoryId);
        $sql->addWhereOpr('entry_blog_id', $blogId, '<>');
        $sql->setLimit(1);

        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * $descendantId が $ancestorId の真の子孫（自分自身を除く）かどうか
     */
    public function isDescendantOf(int $descendantId, int $ancestorId, int $blogId): bool
    {
        $boundary = $this->findBoundary($ancestorId);
        if ($boundary === null) {
            return false;
        }

        $sql = SQL::newSelect('category');
        $sql->setSelect('category_id');
        $sql->addWhereOpr('category_id', $descendantId);
        $sql->addWhereOpr('category_blog_id', $blogId);
        $sql->addWhereOpr('category_left', $boundary['left'], '>');
        $sql->addWhereOpr('category_right', $boundary['right'], '<');
        $sql->setLimit(1);

        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * 指定カテゴリーが子カテゴリーを持つか
     */
    public function hasChildren(int $categoryId): bool
    {
        $sql = SQL::newSelect('category');
        $sql->setSelect('category_id');
        $sql->addWhereOpr('category_parent', $categoryId);
        $sql->setLimit(1);
        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * カテゴリーを物理削除し、関連リソース（fulltext / geo / entry_sub_category /
     * カスタムフィールド / ワークフロー）も一括削除する
     *
     * 削除前に対象カテゴリーの行を取得し、参照エントリーの category_id リセットと
     * nested set ツリーの詰め直しを内部で実施する。
     */
    public function delete(int $categoryId): void
    {
        $category = $this->findCategoryRow($categoryId);

        // 参照エントリーのカテゴリ紐付けをリセット（行の有無に関わらず実行）
        $this->unsetEntryCategoryRef($categoryId);

        // nested set ツリーを詰め直す
        if ($category !== null) {
            $this->realignTreeAfterRemoval($category);
        }

        $deleteCategory = SQL::newDelete('category');
        $deleteCategory->addWhereOpr('category_id', $categoryId);
        Database::query($deleteCategory->get(dsn()), 'exec');

        $deleteFulltext = SQL::newDelete('fulltext');
        $deleteFulltext->addWhereOpr('fulltext_cid', $categoryId);
        Database::query($deleteFulltext->get(dsn()), 'exec');

        $deleteGeo = SQL::newDelete('geo');
        $deleteGeo->addWhereOpr('geo_cid', $categoryId);
        Database::query($deleteGeo->get(dsn()), 'exec');

        $deleteSubCategory = SQL::newDelete('entry_sub_category');
        $deleteSubCategory->addWhereOpr('entry_sub_category_id', $categoryId);
        Database::query($deleteSubCategory->get(dsn()), 'exec');

        // カスタムフィールド（添付ファイルの orphan 処理を含むため Common 経由）
        FieldFacade::save('cid', $categoryId);

        // ワークフロー（既存のグローバル関数を利用）
        if ($category !== null) {
            \deleteWorkflow((int) $category['category_blog_id'], $categoryId);
        }
    }

    /**
     * 指定 ID のカテゴリー行を取得（存在しなければ null）
     *
     * @return array<string, mixed>|null
     */
    private function findCategoryRow(int $categoryId): ?array
    {
        $sql = SQL::newSelect('category');
        $sql->addWhereOpr('category_id', $categoryId);
        $sql->setLimit(1);

        $row = Database::query($sql->get(dsn()), 'row');
        if (!is_array($row)) {
            return null;
        }
        return $row;
    }

    /**
     * 対象カテゴリーを参照するエントリーの entry_category_id を NULL にリセット
     */
    private function unsetEntryCategoryRef(int $categoryId): void
    {
        $sql = SQL::newUpdate('entry');
        $sql->addUpdate('entry_category_id', null);
        $sql->addUpdate('entry_category_sort', 1);
        $sql->addWhereOpr('entry_category_id', $categoryId);
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 指定カテゴリー ID 群の config_set_id を一括更新
     *
     * @param array<int|string> $categoryIds 更新対象 ID 群
     * @param int|null $configSetId 設定する config_set_id。null で解除
     */
    public function updateConfigSetBulk(array $categoryIds, ?int $configSetId): void
    {
        if (count($categoryIds) === 0) {
            return;
        }
        $sql = SQL::newUpdate('category');
        $sql->setUpdate('category_config_set_id', $configSetId);
        $sql->addWhereIn('category_id', $categoryIds);
        Database::query($sql->get(dsn()), 'exec');
    }

    /**
     * 同じブログ内、（strict mode の場合のみ）同じ親内に同一 code のカテゴリーが存在するか
     *
     * @param bool $strictParent true の場合、$parentId 内で重複チェック
     * @param int|null $excludeCategoryId 除外したいカテゴリーID（編集時に自分自身を除く）
     */
    public function existsByCode(
        string $code,
        int $blogId,
        bool $strictParent,
        int $parentId,
        ?int $excludeCategoryId
    ): bool {
        $sql = SQL::newSelect('category');
        $sql->setSelect('category_id');
        $sql->addWhereOpr('category_code', $code);
        $sql->addWhereOpr('category_blog_id', $blogId);
        if ($strictParent) {
            $sql->addWhereOpr('category_parent', $parentId);
        }
        if ($excludeCategoryId !== null && $excludeCategoryId > 0) {
            $sql->addWhereOpr('category_id', $excludeCategoryId, '<>');
        }
        $sql->setLimit(1);
        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * 自ブログの祖先ブログにグローバルカテゴリー（同一コード）が存在するか
     */
    public function existsGlobalCategoryInAncestorBlogs(string $code, int $blogId): bool
    {
        $sql = SQL::newSelect('category');
        $sql->addLeftJoin('blog', 'blog_id', 'category_blog_id');
        \ACMS_Filter::blogTree($sql, $blogId, 'ancestor');
        $sql->addSelect('category_id');
        $sql->addWhereOpr('category_code', $code);
        $sql->addWhereOpr('category_scope', 'global');
        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * 自ブログの子孫ブログに同一コードのカテゴリーが存在するか
     * （自身を global に設定したい場合のチェックに利用）
     */
    public function existsCategoryInDescendantBlogs(string $code, int $blogId): bool
    {
        $sql = SQL::newSelect('category');
        $sql->addLeftJoin('blog', 'blog_id', 'category_blog_id');
        \ACMS_Filter::blogTree($sql, $blogId, 'descendant');
        $sql->addSelect('category_id');
        $sql->addWhereOpr('category_code', $code);
        return !!Database::query($sql->get(dsn()), 'one');
    }

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

    /**
     * 自身と子孫の category_id 一覧を返す（nested set の左右値 inclusive 範囲）
     *
     * @return array<int>
     */
    public function findDescendantIdsInclusive(int $categoryId, int $blogId): array
    {
        $boundary = $this->findBoundary($categoryId);
        if ($boundary === null) {
            return [];
        }
        $sql = SQL::newSelect('category');
        $sql->setSelect('category_id');
        $sql->addWhereOpr('category_blog_id', $blogId);
        $sql->addWhereOpr('category_left', $boundary['left'], '>=');
        $sql->addWhereOpr('category_right', $boundary['right'], '<=');

        $rows = Database::query($sql->get(dsn()), 'all');
        if (!$rows) {
            return [];
        }
        return array_values(array_map(static fn ($row) => (int) $row['category_id'], $rows));
    }

    /**
     * 指定カテゴリーの祖先（自身は含まない）に category_status='close' のものがあるか
     */
    public function hasClosedAncestor(int $categoryId, int $blogId): bool
    {
        $boundary = $this->findBoundary($categoryId);
        if ($boundary === null) {
            return false;
        }
        $sql = SQL::newSelect('category');
        $sql->setSelect('category_id');
        $sql->addWhereOpr('category_blog_id', $blogId);
        $sql->addWhereOpr('category_left', $boundary['left'], '<');
        $sql->addWhereOpr('category_right', $boundary['right'], '>');
        $sql->addWhereOpr('category_status', 'close');
        $sql->setLimit(1);
        return !!Database::query($sql->get(dsn()), 'one');
    }

    /**
     * 同一親内での並び順を $toSort に変更し、関連する left/right/sort を再配置
     *
     * @return bool 成功時 true、無効な対象 / 同位置 / 範囲外などは false
     */
    public function reorderSibling(int $categoryId, int $toSort, int $blogId): bool
    {
        if ($categoryId <= 0 || $toSort < 1) {
            return false;
        }

        $parentSelect = SQL::newSelect('category');
        $parentSelect->setSelect('category_parent');
        $parentSelect->addWhereOpr('category_id', $categoryId);
        $parentSelect->addWhereOpr('category_blog_id', $blogId);
        $parentRow = Database::query($parentSelect->get(dsn()), 'row');
        if (!$parentRow) {
            return false;
        }
        $parentId = (int) $parentRow['category_parent'];

        // 同一親内の最大 sort を取得
        $maxSortSelect = SQL::newSelect('category');
        $maxSortSelect->setSelect('category_sort');
        $maxSortSelect->addWhereOpr('category_parent', $parentId);
        $maxSortSelect->addWhereOpr('category_blog_id', $blogId);
        $maxSortSelect->setOrder('category_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('category');
        $toSelect->addSelect('category_left');
        $toSelect->addSelect('category_right');
        $toSelect->addWhereOpr('category_sort', $toSort);
        $toSelect->addWhereOpr('category_parent', $parentId);
        $toSelect->addWhereOpr('category_blog_id', $blogId);
        $toSelect->setLimit(1);
        $toRow = Database::query($toSelect->get(dsn()), 'row');
        if (!$toRow) {
            return false;
        }
        $toLeft = (int) $toRow['category_left'];
        $toRight = (int) $toRow['category_right'];

        // 移動元の left/right/sort
        $fromSelect = SQL::newSelect('category');
        $fromSelect->addSelect('category_left');
        $fromSelect->addSelect('category_right');
        $fromSelect->addSelect('category_sort');
        $fromSelect->addWhereOpr('category_id', $categoryId);
        $fromSelect->addWhereOpr('category_blog_id', $blogId);
        $fromRow = Database::query($fromSelect->get(dsn()), 'row');
        if (!$fromRow) {
            return false;
        }
        $fromSort = (int) $fromRow['category_sort'];
        $fromLeft = (int) $fromRow['category_left'];
        $fromRight = (int) $fromRow['category_right'];

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

        $sql = SQL::newUpdate('category');
        $sql->addWhereOpr('category_blog_id', $blogId);

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

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

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

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

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

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

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

        return true;
    }

    /**
     * カテゴリーの親を付け替え、ツリーの left/right/sort を再配置する
     *
     * 以下の場合は false を返す:
     *   - 自分自身を親に指定
     *   - 指定カテゴリーが対象ブログに存在しない
     *   - すでに同じ親（no-op）
     *   - 新しい親が自分の子孫
     */
    public function changeParent(int $categoryId, int $toParentId, int $blogId): bool
    {
        if ($categoryId <= 0) {
            return false;
        }
        if ($toParentId === $categoryId) {
            return false;
        }

        // 自身の boundary / parent / sort を取得（ブログ所属チェック兼ねる）
        $fromSelect = SQL::newSelect('category');
        $fromSelect->addSelect('category_left');
        $fromSelect->addSelect('category_right');
        $fromSelect->addSelect('category_parent');
        $fromSelect->addSelect('category_sort');
        $fromSelect->addWhereOpr('category_id', $categoryId);
        $fromSelect->addWhereOpr('category_blog_id', $blogId);
        $fromRow = Database::query($fromSelect->get(dsn()), 'row');
        if (!$fromRow) {
            return false;
        }
        $fromLeft = (int) $fromRow['category_left'];
        $fromRight = (int) $fromRow['category_right'];
        $fromParentId = (int) $fromRow['category_parent'];
        $fromSort = (int) $fromRow['category_sort'];

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

        if ($toParentId > 0) {
            $toSelect = SQL::newSelect('category');
            $toSelect->addSelect('category_left');
            $toSelect->addSelect('category_right');
            $toSelect->addWhereOpr('category_id', $toParentId);
            $toSelect->addWhereOpr('category_blog_id', $blogId);
            $toRow = Database::query($toSelect->get(dsn()), 'row');
            if (!$toRow) {
                return false;
            }
            $toLeft = (int) $toRow['category_left'];
            $toRight = (int) $toRow['category_right'];

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

            $toSortSelect = SQL::newSelect('category');
            $toSortSelect->setSelect('category_sort');
            $toSortSelect->addWhereOpr('category_parent', $toParentId);
            $toSortSelect->addWhereOpr('category_blog_id', $blogId);
            $toSortSelect->setOrder('category_sort', 'DESC');
            $toSortSelect->setLimit(1);
            $toSort = (int) Database::query($toSortSelect->get(dsn()), 'one') + 1;
        } else {
            // 最上位（parent=0）へ移動
            $maxRightSelect = SQL::newSelect('category');
            $maxRightSelect->addSelect('category_right');
            $maxRightSelect->addSelect('category_sort');
            $maxRightSelect->addWhereOpr('category_blog_id', $blogId);
            $maxRightSelect->setOrder('category_right', 'DESC');
            $maxRightSelect->setLimit(1);
            $maxRow = Database::query($maxRightSelect->get(dsn()), 'row');
            if (!$maxRow) {
                return false;
            }
            $toLeft = (int) $maxRow['category_right'];
            $toRight = $toLeft + 1;
            $toSort = (int) $maxRow['category_sort'] + 1;
        }

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

        $alignSql = SQL::newUpdate('category');
        $alignSql->addWhereOpr('category_blog_id', $blogId);

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

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

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

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

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

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

        // 自身の parent / sort を更新
        $updateSql = SQL::newUpdate('category');
        $updateSql->addUpdate('category_parent', $toParentId);
        $updateSql->addUpdate('category_sort', $toSort);
        $updateSql->addWhereOpr('category_id', $categoryId);
        $updateSql->addWhereOpr('category_blog_id', $blogId);
        Database::query($updateSql->get(dsn()), 'exec');

        // 親の status を継承（親非公開のとき子公開、のような不整合を防止）
        $this->inheritStatusFromCurrentParent($categoryId, $blogId);

        \ACMS_RAM::category($categoryId, null);

        return true;
    }

    /**
     * 指定カテゴリーが現在所属する親の status を継承し、自身と子孫の category_status を
     * 親に揃える。親が open / 存在しない / parent=0 の場合は何もしない。
     *
     * `changeParent` 内部から呼ばれ、親変更後の status 不整合を防ぐ。
     */
    private function inheritStatusFromCurrentParent(int $categoryId, int $blogId): void
    {
        // 自身の現在の親 ID を取得
        $parentSelect = SQL::newSelect('category');
        $parentSelect->setSelect('category_parent');
        $parentSelect->addWhereOpr('category_id', $categoryId);
        $parentSelect->setLimit(1);
        $parentId = (int) Database::query($parentSelect->get(dsn()), 'one');
        if ($parentId <= 0) {
            return;
        }

        // 親の status を取得
        $parentStatusSelect = SQL::newSelect('category');
        $parentStatusSelect->setSelect('category_status');
        $parentStatusSelect->addWhereOpr('category_id', $parentId);
        $parentStatusSelect->setLimit(1);
        $parentStatusRaw = Database::query($parentStatusSelect->get(dsn()), 'one');
        if ($parentStatusRaw === false || $parentStatusRaw === null) {
            return;
        }
        $parentStatus = (string) $parentStatusRaw;
        if ($parentStatus === '' || $parentStatus === 'open') {
            return;
        }

        // 自身と子孫を一括で親の status に合わせる
        $descendantIds = $this->findDescendantIdsInclusive($categoryId, $blogId);
        if (count($descendantIds) > 0) {
            $this->updateStatusBulk($descendantIds, $parentStatus);
        }
    }

    /**
     * カテゴリー削除に伴う nested set ツリーの詰め直し
     *
     * - left/right が削除位置より後の行はギャップ 2 ぶん -2
     * - 削除カテゴリーの内側（祖先 - 子孫境界内）は -1（削除カテゴリーは通常リーフのため通常該当しない）
     * - 同一親内の sort が削除位置より大きい兄弟は -1
     *
     * @param array<string, mixed> $category 削除直前のカテゴリーレコード（category テーブル1行）
     */
    private function realignTreeAfterRemoval(array $category): void
    {
        $blogId = (int) $category['category_blog_id'];
        $left = (int) $category['category_left'];
        $right = (int) $category['category_right'];
        $sort = (int) $category['category_sort'];
        $parentId = (int) $category['category_parent'];

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

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

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

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

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