<?php

declare(strict_types=1);

namespace Acms\Services\Entry;

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

/**
 * エントリーのリポジトリ
 */
class EntryRepository
{
    /**
     * 次の表示順を取得
     *
     * @param int $blogId
     *
     * @return int
     **/
    public function nextSort(int $blogId): int
    {
        $sql = SQL::newSelect('entry');
        $sql->setSelect('entry_sort');
        $sql->addWhereOpr('entry_blog_id', $blogId);
        $sql->setOrder('entry_sort', 'DESC');
        $sql->setLimit(1);
        return intval(DB::query($sql->get(dsn()), 'one')) + 1;
    }

    /**
     * 次のユーザー絞り込み時の表示順を取得
     *
     * @param int $userId
     * @param int $blogId
     * @return int
     **/
    public function nextUserSort(int $userId, int $blogId): int
    {
        $sql = SQL::newSelect('entry');
        $sql->setSelect('entry_user_sort');
        $sql->addWhereOpr('entry_user_id', $userId);
        $sql->addWhereOpr('entry_blog_id', $blogId);
        $sql->setOrder('entry_user_sort', 'DESC');
        $sql->setLimit(1);
        return intval(DB::query($sql->get(dsn()), 'one')) + 1;
    }

    /**
     * 次のカテゴリー絞り込み時の表示順を取得
     *
     * @param int|null $categoryId
     * @param int $blogId
     *
     * @return int
     **/
    public function nextCategorySort(?int $categoryId, int $blogId): int
    {
        $sql = SQL::newSelect('entry');
        $sql->setSelect('entry_category_sort');
        $sql->addWhereOpr('entry_category_id', $categoryId);
        $sql->addWhereOpr('entry_blog_id', $blogId);
        $sql->setOrder('entry_category_sort', 'DESC');
        $sql->setLimit(1);
        return intval(DB::query($sql->get(dsn()), 'one')) + 1;
    }

    /**
     * 指定したスコープのエントリーIDと表示順を、表示順・エントリーIDの昇順で取得する
     *
     * 管理画面の一覧と同じ並び（ACMS_Filter::entryOrder + id の第2キー）を再現するため、
     * 表示順が重複していても取得順が一意に定まる。
     *
     * @param 'entry_sort'|'entry_user_sort'|'entry_category_sort' $sortField
     * @param int $blogId
     * @param int|null $userId entry_user_sort のときのスコープ
     * @param int|null $categoryId entry_category_sort のときのスコープ（カテゴリー未設定は null）
     * @return array<int, array{id: int, sort: int}>
     */
    public function findSortOrder(string $sortField, int $blogId, ?int $userId, ?int $categoryId): array
    {
        $sql = SQL::newSelect('entry');
        $sql->addSelect('entry_id');
        $sql->addSelect($sortField);
        $sql->addWhereOpr('entry_blog_id', $blogId);
        if ($sortField === 'entry_user_sort') {
            $sql->addWhereOpr('entry_user_id', $userId);
        } elseif ($sortField === 'entry_category_sort') {
            $sql->addWhereOpr('entry_category_id', $categoryId);
        }
        $sql->setOrder($sortField, 'ASC');
        $sql->addOrder('entry_id', 'ASC');

        $siblings = [];
        foreach (DB::query($sql->get(dsn()), 'all') as $row) {
            $siblings[] = [
                'id' => intval($row['entry_id']),
                'sort' => intval($row[$sortField]),
            ];
        }
        return $siblings;
    }

    /**
     * 1 本の算術 UPDATE に載せるエントリーIDの上限
     */
    private const SHIFT_CHUNK_SIZE = 1000;

    /**
     * 1 本の CASE 式 UPDATE に載せる行数の上限
     *
     * 1 行あたり WHEN 側と THEN 側で 2 つのプレースホルダを使うため、上限を上げすぎると
     * バインド変数の上限に近づく。数百行単位に分割するのが一般的な目安。
     */
    private const ASSIGN_CHUNK_SIZE = 500;

    /**
     * 表示順をまとめて更新する
     *
     * SortOrder::buildUpdatePlan() が分解した単位で書き込む。差分が一定の区間は
     * 「表示順に一定値を足す」1 本の UPDATE で、それ以外は CASE 式の一括 UPDATE で処理する。
     * 1 行ずつ UPDATE を撃つと、長距離移動や重複解消で数百文を発行することになり、
     * トランザクション内で行ロックを保持する時間も伸びる。
     *
     * @param 'entry_sort'|'entry_user_sort'|'entry_category_sort' $sortField
     * @param int $blogId
     * @param list<array{type: 'shift', delta: int, entryIds: list<int>}|array{type: 'assign', values: array<int, int>}> $plan
     * @return void
     * @throws \RuntimeException 更新に失敗した場合（トランザクションをロールバックさせる）
     */
    public function updateSortOrder(string $sortField, int $blogId, array $plan): void
    {
        foreach ($plan as $operation) {
            if ($operation['type'] === 'shift') {
                foreach (array_chunk($operation['entryIds'], self::SHIFT_CHUNK_SIZE) as $entryIds) {
                    $sql = SQL::newUpdate('entry');
                    $sql->addUpdate($sortField, SQL::newOpr($sortField, $operation['delta'], '+'));
                    $sql->addWhereIn('entry_id', $entryIds);
                    $sql->addWhereOpr('entry_blog_id', $blogId);
                    $this->executeSortOrderUpdate($sql);
                }
                continue;
            }
            foreach (array_chunk($operation['values'], self::ASSIGN_CHUNK_SIZE, true) as $values) {
                $case = SQL::newCase();
                foreach ($values as $entryId => $sort) {
                    $case->add(SQL::newOpr('entry_id', $entryId, '='), $sort);
                }
                $sql = SQL::newUpdate('entry');
                $sql->addUpdate($sortField, $case);
                $sql->addWhereIn('entry_id', array_keys($values));
                $sql->addWhereOpr('entry_blog_id', $blogId);
                $this->executeSortOrderUpdate($sql);
            }
        }
    }

    /**
     * 表示順の UPDATE を実行する
     *
     * DB::query() は失敗時に例外ではなく false を返すため、戻り値を検査して例外に変える。
     * 1 文で数百行を書き換えるので、黙って失敗するとトランザクションがコミットされて
     * 並び順が中途半端な状態で残ってしまう。
     *
     * @param \SQL_Update $sql
     * @return void
     * @throws \RuntimeException
     */
    private function executeSortOrderUpdate(\SQL_Update $sql): void
    {
        if (DB::query($sql->get(dsn()), 'exec') === false) {
            throw new \RuntimeException('Failed to update the entry sort order.');
        }
    }
}
