<?php

declare(strict_types=1);

namespace Acms\Services\Entry;

use ACMS_RAM;
use Acms\Services\Facades\Application;
use Acms\Services\Facades\Database as DB;

/**
 * エントリーの表示順の採番
 *
 * 表示順は「スコープ内で 1 から始まる連番」を正とする。スコープは表示順のカラムごとに異なり、
 * entry_sort はブログ、entry_user_sort はブログとユーザー、entry_category_sort はブログと
 * カテゴリーで区切られる。
 */
class SortOrder
{
    /**
     * 表示順のカラム名とスコープの対応
     */
    public const SORT_FIELDS = ['entry_sort', 'entry_user_sort', 'entry_category_sort'];

    /**
     * 並べ替えの結果、スコープ内の各エントリーが取るべき表示順を求める
     *
     * 兄弟リストの先頭から 1, 2, 3... と採番し直し、移動対象を $nextEntryId の手前
     * （$nextEntryId が無ければ $prevEntryId の直後）へ差し込む。既に正しい値が入っている行は
     * 戻り値に含めないため、呼び出し側は差分だけを UPDATE すればよい。
     *
     * 移動対象を配置し終えたあと、既存の値が次の採番値以上になった時点で走査を打ち切る。
     * それ以降の行は押し出す必要が無いため、更新量が移動距離に比例する範囲に収まる。
     * 打ち切り位置より後ろに残っている重複はここでは解消しない（データ修正ツールの担当）。
     *
     * @param array<int, array{id: int, sort: int}> $siblings スコープ内の全エントリー。表示順・エントリーIDの昇順で渡すこと
     * @param int $movedEntryId 移動したエントリーのID
     * @param int|null $prevEntryId 移動先の直前に来るエントリーのID
     * @param int|null $nextEntryId 移動先の直後に来るエントリーのID
     * @return array<int, int> 更新が必要なエントリーID => 新しい表示順
     */
    public function calculate(array $siblings, int $movedEntryId, ?int $prevEntryId, ?int $nextEntryId): array
    {
        $movedSort = null;
        foreach ($siblings as $sibling) {
            if ($sibling['id'] === $movedEntryId) {
                $movedSort = $sibling['sort'];
                break;
            }
        }
        if ($movedSort === null) {
            // 対象がスコープ外（別カテゴリーへ移動された直後など）。並びを触らない
            return [];
        }

        $assignments = [];
        $position = 1;
        $movedPlaced = false;

        foreach ($siblings as $sibling) {
            $entryId = $sibling['id'];
            if ($entryId === $movedEntryId) {
                // 移動対象は前後関係から求めた位置に配置するため、元の位置では飛ばす
                continue;
            }

            if ($nextEntryId !== null && $entryId === $nextEntryId) {
                if ($movedSort !== $position) {
                    $assignments[$movedEntryId] = $position;
                }
                $position++;
                $movedPlaced = true;
            }

            if ($movedPlaced && $sibling['sort'] >= $position) {
                // 以降は既に採番値より大きい値を持っており押し出す必要が無い
                break;
            }

            if ($sibling['sort'] !== $position) {
                $assignments[$entryId] = $position;
            }
            $position++;

            if ($nextEntryId === null && $prevEntryId !== null && $entryId === $prevEntryId) {
                if ($movedSort !== $position) {
                    $assignments[$movedEntryId] = $position;
                }
                $position++;
                $movedPlaced = true;
            }
        }

        if (!$movedPlaced && $movedSort !== $position) {
            // 前後の手がかりが無い場合は末尾に置く
            $assignments[$movedEntryId] = $position;
        }

        return $assignments;
    }

    /**
     * 採番結果を、まとめて実行できる UPDATE の単位に分解する
     *
     * 差分（新しい値 - 元の値）が一定の連続した区間は「表示順に一定値を足す」1 本の UPDATE で
     * 書ける。Rails の acts_as_list が increment_all / decrement_all で採っているのと同じ形で、
     * 順位カラム方式では長距離移動や重複解消でこの形が支配的になる。実測でも、密な連番の
     * ブログで重複を解消すると 582 行が 1 つの区間に畳まれる。
     *
     * 差分が揃わない行（壊れたデータの正規化など）は算術ではまとめられないため、CASE 式で
     * 一括更新する側へ回す。
     *
     * 一時的に表示順が重複する順序で更新しても構わない。acts_as_list が 1 行ずつ更新する経路を
     * 持つのは順位カラムに一意制約がある場合の制約違反を避けるためで、entry_sort 系には
     * 一意制約が無いため区間の適用順は問わない。
     *
     * @param array<int, array{id: int, sort: int}> $siblings calculate() に渡したものと同じ並び
     * @param array<int, int> $assignments calculate() の戻り値
     * @return list<array{type: 'shift', delta: int, entryIds: list<int>}|array{type: 'assign', values: array<int, int>}>
     */
    public function buildUpdatePlan(array $siblings, array $assignments): array
    {
        /** @var list<array{delta: int, entryIds: list<int>}> $runs */
        $runs = [];
        foreach ($siblings as $sibling) {
            if (!isset($assignments[$sibling['id']])) {
                continue;
            }
            $delta = $assignments[$sibling['id']] - $sibling['sort'];
            $lastIndex = count($runs) - 1;
            if ($lastIndex >= 0 && $runs[$lastIndex]['delta'] === $delta) {
                $runs[$lastIndex]['entryIds'][] = $sibling['id'];
                continue;
            }
            $runs[] = ['delta' => $delta, 'entryIds' => [$sibling['id']]];
        }

        $plan = [];
        $assignValues = [];
        foreach ($runs as $run) {
            if (count($run['entryIds']) < 2) {
                // 1 行だけの区間を算術 UPDATE にしても文が増えるだけなので CASE 側へ回す
                $entryId = $run['entryIds'][0];
                $assignValues[$entryId] = $assignments[$entryId];
                continue;
            }
            $plan[] = ['type' => 'shift', 'delta' => $run['delta'], 'entryIds' => $run['entryIds']];
        }
        if (count($assignValues) > 0) {
            $plan[] = ['type' => 'assign', 'values' => $assignValues];
        }

        return $plan;
    }

    /**
     * 並べ替えを永続化する
     *
     * @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）
     * @param int $movedEntryId
     * @param int|null $prevEntryId
     * @param int|null $nextEntryId
     * @return int 更新したエントリー数
     */
    public function reorder(
        string $sortField,
        int $blogId,
        ?int $userId,
        ?int $categoryId,
        int $movedEntryId,
        ?int $prevEntryId,
        ?int $nextEntryId
    ): int {
        $repository = Application::make('entry.repository');
        assert($repository instanceof EntryRepository);

        $siblings = $repository->findSortOrder($sortField, $blogId, $userId, $categoryId);
        $assignments = $this->calculate($siblings, $movedEntryId, $prevEntryId, $nextEntryId);
        if (count($assignments) === 0) {
            return 0;
        }

        $plan = $this->buildUpdatePlan($siblings, $assignments);
        DB::transaction(function () use ($repository, $sortField, $blogId, $plan): void {
            $repository->updateSortOrder($sortField, $blogId, $plan);
        });

        foreach (array_keys($assignments) as $entryId) {
            ACMS_RAM::entry($entryId, null);
        }

        return count($assignments);
    }
}
