/**
 * 清掃ログ（`CleanLogItem`）を組み立てるときの変換ロジック（純粋関数のみ）。
 *
 * ★`lib/db/ds-status.ts` と同じ作法★ — DB アクセスを混ぜず、`prismaSource.cleanLogs()`
 * から呼ぶだけにして、テストは fixtures と純粋関数だけで書けるようにする
 * （指示書R 2-5 / 禁止事項13）。
 *
 * ★ここは「判定ロジック」の置き場所ではない★ — ◎○△× と L1〜L3 の判定は
 * `lib/judge/` を呼ぶ（D-090 項16。`CLAUDE.md`「判定ロジックは切替の外側に置く」は
 * 「fixtures 用と Prisma 用に分岐させるな」の意味であり、「`lib/db/` の外で呼べ」ではない）。
 */
import { GRADE_THRESHOLDS, MAKER_NAMES, ROBOT_STATUS_STANDBY } from "@/lib/config";
import {
  countConsecutiveProblemDays,
  judgeEscalationLevel,
  judgeGrade,
  runCompletionRate,
} from "@/lib/judge";
import { scheduledTimetableId } from "@/lib/db/timetable-link";
import { stripMakerPrefix } from "@/lib/format/robot-model";
import type { ScheduleDayResult } from "@/lib/judge/escalation";
import type { CleanLogItem } from "@/lib/types";

/**
 * `devices.maker_id` + `devices.model_name` → `CleanLogItem.model_name`（D-090 項4 の決定）
 *
 * ★連結規則: `"<メーカー名> <機種名>"`（半角スペース1つ区切り）★ — 例 `"PUDU CC1"`。
 *   fixtures（`lib/fixtures/clean-logs.ts:50` / `lib/fixtures/facilities.ts`）と
 *   `lib/config/index.ts` の `ROBOT_MODELS` がこの形なので、それに合わせた。
 *
 * ★未知の `maker_id` は `String(maker_id)` をそのまま出す★ — `prismaSource.facilities()`
 * （`lib/db/index.ts` の `MAKER_NAMES[...] ?? String(...)`）と同じ扱い。「不明」等に潰すと
 * 対応表に追記が必要なことに誰も気づけない（`lib/config/index.ts:91-93` / D-077 と同じ思想）。
 */
export function formatModelName(makerId: number, modelName: string): string {
  const maker = MAKER_NAMES[makerId] ?? String(makerId);
  // ★`model_name` がメーカー名込み（"PUDU CC1"）でも「PUDU PUDU CC1」にしない★（`stripMakerPrefix()`。ユーザー指示 2026-10-29）
  return `${maker} ${stripMakerPrefix(maker, modelName)}`;
}

/** `robot_error_reports` のうち、状態文字列を作るのに必要な最小限の形 */
export interface RobotErrorLike {
  /** ★DB の生値。7分類へのマッピングも日本語化もしない（D-077 / D-088）★ */
  error_type: string;
  /** `robot_error_reports.errored_at`（nullable。`prisma/schema.prisma:334`） */
  errored_at: Date | null;
}

/**
 * 事前チェック行に紐づくエラー群 → `status_1h` / `status_30m`（D-065 Q2 / D-098）
 *
 * ★エラーがあればその `error_type` を生値のまま★。★無ければ `ROBOT_STATUS_STANDBY`★。
 *
 * ★注意（重要）★: `Standby.` は「この行の `robots.status` を実際に読んで得た値」ではない。
 *   `robot_error_reports` に該当行が無いことを「エラーが無い＝待機」と読み替えている
 *   （D-065 Q2）。値そのものは TeamWorks の `robots.status` コード表の原文
 *   （`docs/handoff/TeamWorks回答_第2回_20260903.md:56` の `'0' => 'Standby.'`）であり、
 *   当方が作った文言ではないので D-077（推測で変換しない）に反しない。
 *
 * ★同じ事前チェック行に複数のエラーがある場合は `errored_at` が最新の1件を採る★
 *   （指示書R 2-3 の推奨）。`errored_at` が null の行は「時刻不明」として最も古く扱い、
 *   時刻が同着なら入力順の先頭を採る（安定ソートなのでDBの返却順がそのまま効く）。
 */
export function robotStatusFromErrors(errors: readonly RobotErrorLike[]): string {
  if (errors.length === 0) return ROBOT_STATUS_STANDBY;
  const sorted = [...errors].sort(
    (a, b) => (b.errored_at?.getTime() ?? -Infinity) - (a.errored_at?.getTime() ?? -Infinity)
  );
  return sorted[0].error_type;
}

/**
 * ★「その清掃の結果が記録されているか」★ — ★`clean_task_reports` の行の有無だけを見る★
 *
 * ★★取得元が `clean_status_histories` → `clean_task_reports` に変わった★★
 *   （指示書AQ 決定2・決定3。2026-09-30 ユーザー確定）。★見ているものは変わっていない★ —
 *   どちらでも「対応する結果行があるか」だけを見る関数である。
 *
 * ★★これは「出発したか」の判定ではない★★（D-107 で改称した）—
 *   旧名 `departedFromHistory()` は ★「行があるから出発した」という当方の推測★ を
 *   関数名で主張していた。★出発したかどうかの判定は `notExecutedFromReport()` が正★。
 *
 * ★★「行が**無い**なら出発失敗」という側は残っている★★ — ただし推測ではなく
 *   ★「過去なのに結果行が無いのはデータ収集処理の障害だから、黙って消さずに見せる」★
 *   という決定として（D-107 設計3-C。fail-loud）。★その条件は
 *   `notExecutedFromReport()` の中に入っている★ので、★呼び出し側でこの関数と
 *   別の条件を AND / OR しないこと★（D-103 項1。2箇所に規則が散る）。
 *
 * ★帰結として `lib/db/index.ts` からは直接呼ばれない★（`notExecutedFromReport()` /
 *   `completionRateFromReport()` の内部でだけ使う）。★それでも関数として残してある★ —
 *   判定の言葉を1箇所に保ち、意味をテストで固定しておくため。
 *
 * ★型述語にしてあるのは呼び出し側で型を絞るため★（実行時の挙動は `report !== undefined`）。
 *
 * @param report `clean_task_reports` から `clean_timetable_id` で引いた行（無ければ undefined）
 */
export function hasCleanResult<T>(report: T | undefined): report is T {
  return report !== undefined;
}

/**
 * `clean_task_reports` のうち、★完了率の算出に必要な最小限の形★
 *
 * ★★完了率は `clean_area` / `task_area` から `runCompletionRate()` で出す★★（D-155。第11回で確定。D-158）—
 *   ★出所★: `task_area Float` / `clean_area Float`（`prisma/schema.prisma:533-534`。
 *   実DDLは `double(12,6)`。D-094）。★Prisma の型は `number`（Decimal ではない）★。
 *   ★旧: D-151（指示書AQ 決定4）では「`clean_area` / `task_area` を足さないこと。完了率は
 *   `clean_ratio` を正とする」だった★が、TeamWorks から「`clean_ratio` 列の計算式が間違っている」と
 *   回答があり（第10回 N-3）、サマリと同じ1回ごとの面積比に変えた（D-155 項1）。
 *
 * ★★`clean_error` は持っていない★★ — ★`clean_task_reports` にその列が無い★
 *   （`prisma/schema.prisma:517-558`）。旧 `CleanResultLike` は削除した。
 */
export interface CleanTaskReportLike {
  /** 清掃した面積の生値（同じ場所を複数回清掃した面積の総和。第10回 N-4） */
  clean_area: number;
  /** 清掃対象の面積の生値。★0 のとき完了率は 0★（`runCompletionRate()`。P-5=B。旧: null） */
  task_area: number;
  /**
   * ★完了率には使っていない★（D-155。指示書AS D6）。
   * ★`runCompletionRate()` を `clean_ratio` 列に戻すときのために残している★
   * （`prisma/schema.prisma:539`。実DDLは `double(4,1)`）。
   */
  clean_ratio: number;
}

/**
 * ★「清掃が実施されなかった（＝出発失敗）」の判定★ — ★`cleanLogs()` と `prechecks()` が共有する★
 *
 * ★★判定式（指示書AQ §1-1。2026-09-30 ユーザー確定）★★:
 * ```
 * 未実施 ＝ 本清掃の clean_timetables 行に対応する clean_task_reports の行が無い
 * ```
 *
 * ★★旧 `notExecutedFromHistory()` からの変更点★★ —
 *   旧実装は ★第1分岐（行が無い）★ と ★第2分岐（`clean_error === 1`）★ の OR だった。
 *   ★★第2分岐は消えた★★ — ★新しい取得元 `clean_task_reports` に `clean_error` 列が無い★
 *   （`prisma/schema.prisma:517-558`）ため、★条件として成立しない★。
 *   ★残った第1分岐は旧実装のものと同一の規則★なので、
 *   ★これは新しい推測ではなく既にあった規則の片方を残しただけ★（指示書AQ §1-1）。
 *   ★`clean_task_reports.status` を代わりに読まないこと★ —
 *   ★`status` は判定に使わない★（D-155 項2。ユーザー決定 2026-10-02。`prisma/schema.prisma:529`。
 *   値の意味は第10回 N-2 で受領済み）。未実施は「行が無いこと」だけで決める。
 *
 * ★★`undefined`（レポート行が無い）を `true`（出発失敗）にする理由★★ —
 *   ★これは推測ではなく決定★（D-107 / 設計3-C。指示書AQ §1-1 で取得元を変えても維持）。
 *   ★過去なのに結果行が無いのはデータ収集処理の障害★である。
 *   ★★行ごと結果から消すと（fail-silent）、運用担当は「何かが足りない」ことにすら
 *   気づけない★★ — 画面には何も出ず、件数がそっと減るだけ。出発失敗として並べば
 *   「翌朝確認型」の運用の中で異常として目に入る（`CLAUDE.md` §1）ので、
 *   ★fail-silent より fail-loud を採る★。
 *   ★帰結★: 前提が外れていても出発失敗が画面から消えることはなく、
 *   代わりに「結果がまだ書かれていないだけの清掃」まで出発失敗として出る（★過剰検知★）。
 *   ★意図的に過剰検知の側を選んでいる★。
 *
 * ★★「出発失敗」と「未実施」は同一扱いのまま★★（ユーザー確認 2026-09-30）。
 *   `CleanLogItem.departed === false`（清掃詳細ログ F-02）と
 *   `PrecheckItem.severity === "launch_failed"`（アラート F-03）は ★同じ出来事★であり、
 *   ★出発失敗の定義はこの関数1本だけ★（D-103 項1。2箇所に書かない）。
 *
 * ★★`clean_timetable_id` が NULL のレポート行の扱いに注意★★（指示書AQ 落とし穴1）—
 *   `clean_task_reports.clean_timetable_id` は ★nullable★（`prisma/schema.prisma:524`）。
 *   NULL は「予定外のタスク」（予定を持たない実在の清掃。TeamWorks 第10回 N-1 = A）で、
 *   `latestReportByTimetableId()` が Map に入れない（★完了率・実施回数に数えない★。D-155 項3）。
 *   ★`0` も予定外★（TeamWorks 回答 2026-10-27。判定は `scheduledTimetableId()` の1本）。
 *   予定（本清掃）側から見れば、★引き当たるレポートが無ければ「レポート行が無い」＝未実施★として出る。
 *
 * ★「予定＋猶予30分」を当方で計算しない★（D-091 項1。`LAUNCH_GRACE_MINUTES` は読まない）。
 *   ★未来の予定を除外するのは `isFutureSchedule()` の仕事★（必須。同関数参照）。
 *
 * ★★引数を総称型にしてある★★ — ★もう読むべき列が1つも無い★（行の有無だけが判定材料）ので、
 *   特定のカラムを要求する型にすると「その列を読んでいる」という誤解を型が主張してしまう。
 *
 * @param report `clean_task_reports` から `clean_timetable_id` で引いた行（無ければ undefined）
 */
export function notExecutedFromReport<T>(report: T | undefined): boolean {
  // ★`report === undefined` をインラインで書かず `hasCleanResult()` を通す★
  //   （判定の言葉を1箇所に保つため）
  return !hasCleanResult(report);
}

/**
 * `clean_task_reports` → `CleanLogItem.completion_rate`（★1回ごとの完了率 / 未実施は null★）
 *
 * | 入力 | 戻り |
 * |---|---|
 * | ★レポートの行が無い★ | ★`null`★（未実施） |
 * | それ以外 | ★`runCompletionRate()`★（`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。★`task_area = 0` は 0★） |
 *
 * ★★経緯★★: D-151（2026-09-30。指示書AQ 決定4）では `clean_task_reports.clean_ratio` 列の生値だった。
 *   TeamWorks から「`clean_ratio` 列の計算式が間違っている」と回答があり（第10回 N-3。2026-10-02）、
 *   ★サマリと同じ1回ごとの面積比（上限100・小数第1位切り捨て）に変えた★（D-155 項1。★暫定★）。
 *   ★第11回で確定した★（P-2=A。`task_area = 0` は 0%: P-5=B。D-158。旧: `task_area = 0` は null）。
 *   ★1回ごとの完了率を決めるのは `runCompletionRate()` だけ★ — ここで割り算を書かないこと。
 *   ★行をそのまま渡す★（`clean_ratio` 列に戻すときに `runCompletionRate()` の1関数で済むように。D6）。
 *   ★期間の合計どうしの面積比（Σ`clean_area` ÷ Σ`task_area`）は廃止したまま★（ここは1回ずつ割る）。
 *
 * ★★「0%で実施した」と「未実施」は別物★★（★この区別は維持する★。指示書U 落とし穴1）—
 *   ★行があって `clean_area = 0`（`task_area > 0`）なら `0` を返す（`null` にしない）★（表1 R8）。
 *   潰すと「0%で実施した」に化け、
 *   ★判定（◎○△×）はどちらも × なので画面もテストも赤くならないまま壊れる★。
 *
 * ★★不変条件★★: `notExecutedFromReport()` と対になっており、
 *   ★`departed === false` ⟺ `completion_rate === null`★ が保たれる（★P-5=B で両向きに戻った★。D-158。
 *   旧: `task_area = 0` の清掃が `departed === true` で `completion_rate === null` になり、逆は成り立たなかった）。
 *   ★それでも「未実施」は `completion_rate` ではなく `departed` で数える★（【未決1】案A。D-156 / 指示書AU D3）
 *   — 完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため（要レスキュー R-1・連続問題施設の
 *   出発失敗日。`ConsecutiveInput.departed` / `RescueRowInput.departed`）。
 *   ★連続日数の問題日（`isProblemCompletion()`）は `null` を問題日のまま数える★（判定 × と整合する）。
 *   `lib/dashboard/aggregate.ts` の「予定 / 実施」は `departed` を数えている。
 *
 * ★丸めないこと★（`CLAUDE.md` §4.3）— 切り捨ては `runCompletionRate()` の中の1回ごとの値だけ。
 */
export function completionRateFromReport(
  report: CleanTaskReportLike | undefined
): number | null {
  // ★判定は `notExecutedFromReport()` を通す★（規則が2箇所に散らないように）。
  //   型述語を兼ねる `hasCleanResult()` で絞ってから返す
  if (notExecutedFromReport(report) || !hasCleanResult(report)) return null;
  return runCompletionRate(report);
}

/**
 * `clean_task_reports` を `clean_timetable_id` で引ける Map にする
 * — ★`cleanLogs()` と `prechecks()` が共有する★
 *
 * ★1本清掃 = 1レポートの想定だが、実データで複数あったときに黙って先頭を採らないよう
 *   ★順序キーが最大の1件★を残す★（「今の状態」は `clean_statuses` 側。D-093 項1）。
 *   ★同着は入力順の先頭★（`<` 比較なので後続で上書きされない）。
 *
 * ★★順序キー = `ended_at` があればそれ、無ければ `started_at`★★（指示書AQ §1-4）—
 *   ★`clean_task_reports.ended_at` は nullable★（`prisma/schema.prisma:541`。
 *   旧取得元 `clean_status_histories.ended_at` は NOT NULL だったので型ごと直した）。
 *   ★★この規則を決める場所はこの関数1箇所だけ★★（指示書AQ §1-4）—
 *   呼び出し側や `orderBy` で別の順序キーを書くと、
 *   `/detail-log` と `/alerts` で選ばれる行が食い違う（D-103 が禁じた状態）。
 *   そのため ★`reportOrderKey()` は export していない★。
 *
 * ★★`clean_timetable_id` が NULL の行は Map に入れない★★（指示書AQ 落とし穴1）—
 *   ★`clean_task_reports.clean_timetable_id` は nullable★（`prisma/schema.prisma:524`）。
 *   NULL をキーにすると `"null"` という実在しない `clean_timetable_id` のエントリが生まれ、
 *   ★たまたま同じキーになった複数の無関係なレポートが互いを上書きし合う★。
 *   ★落として構わない理由★: NULL は「予定外のタスク」（予定を持たない実在の清掃。
 *   TeamWorks 第10回 N-1 = A）で、本清掃 id で引く対象ではなく、★完了率・実施回数にも
 *   数えない★（D-155 項3）。★設計上ここから外れる行★であり、TeamWorks が後で埋める想定ではない。
 * ★★`clean_timetable_id` が `0` の行も同じく入れない★★（TeamWorks 回答 2026-10-27「0は予定外タスク」）—
 *   ★判定は `scheduledTimetableId()`（`lib/db/timetable-link.ts`）の1本★。`=== null` だけだと
 *   `"0"` というキーのエントリが生まれ、予定外のレポートどうしが上書きし合う（NULL のときと同じ事故）。
 *
 * ★キーは `clean_timetable_id.toString()`★ — `BigInt` はキーとして `===` で比較できない。
 *
 * ★申し送り★: 1つの `clean_timetable_id` にレポートが必ず1行かは TeamWorks へ確認中
 *   （D-107 の申し送り 項8）。複数あり得る場合にこの規則が妥当かの裏取りが要る。
 */
export function latestReportByTimetableId<
  T extends { clean_timetable_id: bigint | null; ended_at: Date | null; started_at: Date },
>(reports: readonly T[]): Map<string, T> {
  const byTimetableId = new Map<string, T>();
  for (const report of reports) {
    // ★予定外（NULL・0）はキーにできない（"null" / "0" という偽のキーを作らない）★
    const timetableId = scheduledTimetableId(report.clean_timetable_id);
    if (timetableId === null) continue;
    const k = timetableId.toString();
    const current = byTimetableId.get(k);
    if (!current || reportOrderKey(current) < reportOrderKey(report)) {
      byTimetableId.set(k, report);
    }
  }
  return byTimetableId;
}

/**
 * ★「最新1件」を決める順序キー★（指示書AQ §1-4）— ★module-private。export しないこと★
 *
 * ★`ended_at` があればそれ、無ければ `started_at`★。
 * `started_at` は NOT NULL（`prisma/schema.prisma:540`）なので必ず値が決まる。
 *
 * ★`??` を `||` にしないこと★ — `Date` はオブジェクトなので今は差が出ないが、
 *   将来キーを数値に変えたときに `0`（1970-01-01）が落ちる。
 */
function reportOrderKey(report: { ended_at: Date | null; started_at: Date }): number {
  return (report.ended_at ?? report.started_at).getTime();
}

/**
 * ★清掃詳細ログの行の一意キー★（`CleanLogItem.row_key`。指示書BG §2-1）— ★作るのはこの2関数だけ★。
 *
 * - 予定のある行 … `"tt-<予定の id>"`（`clean_timetables.id`）
 * - 予定外の行   … `"rp-<report_id>"`（`clean_task_reports.report_id`）
 *
 * ★前置きを分けてある★ので、予定の id とレポートの id が同じ数字でも重ならない。
 * 画面の React の key と行の開閉状態はこのキーで持つ（予定外の行は `id` が null で重なるため）。
 */
export function scheduledCleanLogRowKey(timetableId: number | bigint): string {
  return `tt-${timetableId.toString()}`;
}

/** 予定外の行のキー（`scheduledCleanLogRowKey()` の対）。`report_id` は文字列のまま（19桁で安全な整数を超える） */
export function unscheduledCleanLogRowKey(reportId: string): string {
  return `rp-${reportId}`;
}

/**
 * ★予定外タスクの1件★（`clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行）から
 * 清掃詳細ログの1行を作るのに要る最小限の形（指示書BG §1・§2-1）。
 */
export interface UnscheduledReportLike extends CleanTaskReportLike {
  /** `clean_task_reports.report_id`（主キー。varchar(20)） */
  report_id: string;
  /** ★0 か NULL のときだけ予定外★（`scheduledTimetableId()` の1本で見る） */
  clean_timetable_id: bigint | number | null;
  /** `clean_task_reports.status` の生値（表示名にするのは画面・CSV。D-155 項2） */
  status: number;
  /** ★`started_at` の JST の ISO8601（`jstIsoFromDb()` を通した値）★ — 予定外の行の `scheduled_at` になる */
  started_at_iso: string;
}

/** 予定外の行の、施設・ロボットの表示に要る値（予定のある行と同じ出し方。呼び出し側が詰める） */
export interface UnscheduledRowContext {
  facility_id: number;
  facility_name: string;
  /** 清掃エリアに有効なロボットがいなければ null（予定のある行と同じ。指示書BB U2・D-182） */
  model_name: string | null;
  serial_number: string | null;
}

/**
 * ★予定外タスクか★ — ★`scheduledTimetableId()` を通す★（`=== null` を直書きしない。指示書BG §5）。
 * 0 と NULL を同じに扱う（表U1 U1-3）。
 */
export function isUnscheduledReport(report: { clean_timetable_id: bigint | number | null }): boolean {
  return scheduledTimetableId(report.clean_timetable_id) === null;
}

/**
 * 予定外タスクの1件 → 清掃詳細ログの1行（★連続日数・L1〜L3 は呼び出し側が渡す★。純粋関数。指示書BG §2-1）。
 *
 * | 項目 | 値 |
 * |---|---|
 * | `id` | ★`null`★（予定の id が無い。`report_id` は19桁で安全な整数を超えるので数値の id にしない） |
 * | `row_key` / `report_id` / `unscheduled` | `"rp-<report_id>"` / `report_id` / `true` |
 * | `scheduled_at` | ★`started_at`★（実際に始めた時刻。CLAUDE.md §4.7） |
 * | 事前チェック4列 | ★`null`★（予定外には事前チェックの回が無い） |
 * | `departed` | ★`true`★（清掃結果の行そのものが起点なので、出発済み。`notExecutedFromReport()` と同じ答え） |
 * | `task_status` | `status` の生値 |
 * | `result_pending` | ★`false`★（結果はもう届いている） |
 * | `completion_rate` | `completionRateFromReport()`（＝ `runCompletionRate()`。予定のある行と同じ1本。`task_area = 0` は 0） |
 * | `grade` | `judgeGrade()`（予定のある行と同じ） |
 *
 * ★予定のある行（`clean_timetable_id` が 0 でも NULL でもない）を渡したら例外★ — 呼び出し側の絞り込みの誤りを
 *   黙って予定外の行に化けさせない。
 */
export function unscheduledCleanLogItem(
  report: UnscheduledReportLike,
  context: UnscheduledRowContext,
  consecutiveProblemDays: number
): CleanLogItem {
  if (!isUnscheduledReport(report)) {
    throw new Error(
      `[clean-logs] 予定のあるレポート（report_id=${report.report_id}）を予定外の行にしようとしました`
    );
  }
  const completionRate = completionRateFromReport(report);
  return {
    id: null,
    row_key: unscheduledCleanLogRowKey(report.report_id),
    report_id: report.report_id,
    unscheduled: true,
    facility_id: context.facility_id,
    facility_name: context.facility_name,
    model_name: context.model_name,
    serial_number: context.serial_number,
    scheduled_at: report.started_at_iso,
    status_1h: null,
    status_1h_minutes: null,
    status_30m: null,
    status_30m_minutes: null,
    // ★`notExecutedFromReport()` を通す★（行があるので必ず false ＝ 出発済み。規則を2箇所に書かない）
    departed: !notExecutedFromReport(report),
    task_status: report.status,
    result_pending: false,
    completion_rate: completionRate,
    grade: judgeGrade(completionRate),
    consecutive_problem_days: consecutiveProblemDays,
    escalation_level: judgeEscalationLevel(consecutiveProblemDays),
  };
}

/**
 * ★清掃詳細ログの並び順★（`scheduled_at` の新しい順。同着は予定のある行を先に、その中は `id` 昇順、
 * 予定外どうしは `row_key` 昇順）— 予定のある行と予定外の行を混ぜて並べる（指示書BG §2-1）。
 * ★予定のある行どうしの順序は今までと同じ★（`scheduled_at` 降順 → `id` 昇順）。
 */
export function compareCleanLogItems(a: CleanLogItem, b: CleanLogItem): number {
  if (a.scheduled_at !== b.scheduled_at) {
    return a.scheduled_at < b.scheduled_at ? 1 : -1;
  }
  if (a.id !== null && b.id !== null) return a.id - b.id;
  if (a.id !== null) return -1;
  if (b.id !== null) return 1;
  return a.row_key < b.row_key ? -1 : a.row_key > b.row_key ? 1 : 0;
}

/**
 * ★「予定時刻がまだ来ていない本清掃」か★ — ★`cleanLogs()` と `prechecks()` が共有する★
 *
 * ★なぜ要るか★: `clean_timetables` には ★1週間分の清掃予定がまとめて登録される★
 *   （ユーザー回答 2026-09-10。「仮に予定が無い場合は『清掃予定なし』として異常にはしない」）。
 *   つまり ★最大7日先までの本清掃行が常時DBに存在する★。
 *   ★★`notExecutedFromReport()` は「レポートの行が無い」を出発失敗と判定する★★（D-107 設計3-C）ため、
 *   ★これから実施する予定の行まで全部「出発失敗」として並んでしまう★
 *   （実測: 3日後の予定を1行入れると `launch_failed` が 13 → 14 件に増えた）。
 *   ★まだ時刻が来ていない清掃は「出発しなかった」とは言えない★ので、
 *   出発失敗／未出発の判定対象から外す。
 *
 * ★★本関数は D-107（設計3-C）の下では**必須**★★ — ★消さないこと★。
 *   ★未来の予定にはまだレポートの行が無いので、外すと未来の清掃が**全部「出発失敗」になる**★。
 *   ★加えて逆向きの事故も止める★ — もし TeamWorks が「予定の行を作った時点でレポートの行も
 *   先に作る」実装だった場合、未来の予定に面積が既定値（`clean_area = 0`）のレポートが付き、
 *   ★未来の清掃が「完了率0%・×」として並ぶ★（`task_area` も既定値 0 なら完了率 0・×。P-5=B）。本関数はその事故も1行で止める。
 *   ★★取得元が `clean_task_reports` になって、この危険は増している★★ —
 *   ★未実施の判定材料が「行の有無」だけになった★ため、行が先に作られると
 *   ★全部「実施済み・完了率0%」に化け、例外もテスト失敗も出ない★（指示書AQ §1-1）。
 *
 * ★★判定材料は `start_at` と現在時刻だけ★★ —
 *   ★`LAUNCH_GRACE_MINUTES`（猶予30分）をここから読まない★（D-091 項1 / 指示書R 禁止事項3）。
 *   「予定＋猶予30分」を当方で計算するのは禁止されている。
 *   ★本関数がやっているのは「まだ予定時刻に達していない」ことの判定であって猶予の話ではない★ —
 *   ★猶予分を足したくなっても足さないこと★。足した瞬間に、設定値を変えると過去のログの
 *   判定まで変わるという D-091 項1 が禁じた状態になる。
 *
 * ★`now` を引数にしてある理由★: テストが実行時刻で揺れないようにするため（`CLAUDE.md` §9.4）。
 *   呼び出し側は1回の集計で ★同じ `now` を使い回す★こと（行ごとに `new Date()` を作ると、
 *   走査の途中で時刻が進んで境界の行の扱いがぶれる）。
 *
 * ★同時刻（`start_at === now`）は「来ていない」に入れない★ — 予定時刻には達しているので
 *   出発失敗の判定対象に含める（`>` であって `>=` ではない）。
 *
 * @param startAt `clean_timetables.start_at`（本清掃の予定開始時刻）
 * @param now     判定の基準時刻。既定は現在時刻
 */
export function isFutureSchedule(startAt: Date, now: Date = new Date()): boolean {
  return startAt.getTime() > now.getTime();
}

/**
 * その清掃が「問題あり」か（△以下 または 未実施）。
 *
 * ★`CLAUDE.md` §4.1 の△は「10%以上70%未満」、§4.5 のR3/R4は「△以下」なので、
 *   完了率ちょうど 70.0（判定は ★○★）は「問題なし」。
 *   ★★`<` であって `<=` ではない★★（★D-139。ユーザー指示 2026-09-16
 *   「◯、×は90%以上のように境はふくめてください」で境界が上のランクに入るよう変わった★）。
 *   ★★旧 D-020b 下では `<=` が正しかった★★ — 当時は 70.0 が △ だったため
 *   （指示書V 決定3。2026-09-11 ユーザー確定）。★その前提ごと覆っている★。
 *
 * ★★`judgeGrade()`（`lib/judge/grade.ts`）と結果が常に一致する★★ —
 *   入力が違うだけの同じ規則なので、★片方の比較演算子だけを動かさないこと★
 *   （`judgeGrade()` の「○ = 70%以上」より、△× ⟺ `null` または 70 未満）。
 *
 * ★★閾値は `GRADE_THRESHOLDS.good` から読む。`70` を直書きしない★★（`CLAUDE.md` §7）。
 *
 * ★`lib/fixtures/clean-logs.ts:226` の `consecutive_problem_days` の生成式
 *   （`rate === null || rate < GRADE_THRESHOLDS.good`）は★同じ概念で、既に `<`★なので
 *   D-139 後はそのまま一致する（変更不要）★。
 */
export function isProblemCompletion(completionRate: number | null): boolean {
  return completionRate === null || completionRate < GRADE_THRESHOLDS.good;
}

/** 連続日数の算出に必要な最小限の形 */
export interface ConsecutiveInput {
  /**
   * `CleanLogItem.id`（= 本清掃の `clean_timetables.id`）。
   * ★予定外タスクの1回は `null`★（指示書BG §2-3。`CleanLogItem.id` と同じ — 予定の id が無く、19桁の `report_id` を
   * 数値の id にしない）。★予定の id と同じ値の空間に入れないため★、`computeConsecutiveProblemDays()` の Map には載らない
   * （施設 × 日付で引く `consecutiveProblemDaysByFacilityDate()` を使う）
   */
  id: number | null;
  /**
   * ★予定外タスクの1回か★（`clean_task_reports` の `clean_timetable_id` が 0 か NULL。指示書BG §2-3）。
   * ★必須（optional にしない）★ — 予定外も予定のある清掃と同じく、その施設のその日の結果に入る（問題日の OR・
   * 予定外だけの日もスケジュール存在日）。未実施にはならない（`departed` は常に true）
   */
  unscheduled: boolean;
  /**
   * 施設の識別子（`CLAUDE.md` §4.5 の R3/R4「同一施設で」に合わせた分割キー）。
   * ★★以前は `serial_number: string` を分割キーにしていたが誤りだった★★ —
   * 「P1 は 1清掃エリア = 1ロボットなので serial_number で代用できる」という前提は、
   * 1施設に複数の清掃エリア（＝複数ロボット）が実在する施設（施設5=2台 / 施設7=3台）が
   * あるため成り立たない（指示書V 不具合B）。
   */
  facility_id: number;
  /** ISO8601（+09:00）。日付昇順に並べ替えるキー */
  scheduled_at: string;
  /**
   * 1回ごとの完了率（`completionRateFromReport()` ＝ `runCompletionRate()` の戻り）。丸める前の生値。
   * ★未実施は null★（未実施の数え方は `departed` で見る。指示書AU D3）
   */
  completion_rate: number | null;
  /**
   * ★出発したか（＝レポートの行があるか）★ — `!notExecutedFromReport(report)`。
   *
   * ★★【未決1】案A（D-156。指示書AS 段階3-4）★★: ★要レスキュー R-1（未実施3日連続）と
   *   連続問題施設の出発失敗日（`launchFailedDaysInRun()`）は、`completion_rate === null` ではなく
   *   この値で「未実施」を数える★。★いまは `completion_rate === null` と同値★（P-5=B）だが、
   *   ★完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため★に別に持つ（指示書AU D3）。
   * ★★optional（`?`）にしないこと★★ — 詰め忘れが `undefined`（＝偽＝全部未実施）のまま通る。
   */
  departed: boolean;
  /**
   * 面積の生値（★旧: 急落判定 R-3 の面積加重に使っていた。指示書AI 段階2★）。
   *
   * ★★R-3 は1回ごとの完了率の単純平均★★（`meanCleanRatio()`。指示書AQ 決定4 / §5-5・D-155）—
   *   ★判定（`judgeRescue()`）はこの2列を読まない★（`RescueRowInput` が型から外している）。
   *   ★完了率の分子・分母（Σ面積 ÷ Σ面積）としてここを読み直さないこと★。
   *
   * ★レポートの行が無い行（＝未実施）は 0★。
   * ★★`completion_rate` は `runCompletionRate()` で作ったもの。ここで割り直さないこと★★
   *   （規則が2箇所に散る）。1行（＝1清掃エリア）の率で、未実施は `null`。
   * ★★optional（`?`）にしないこと★★ — 詰め忘れが `undefined` のまま通る。
   *   ★旧: 面積加重が静かに 0/0 → `null` に化ける（指示書AI §13-2）という理由だった★が、
   *   R-3 が単純平均に変わったのでその事故はもう起きない。★型を必須のまま残しているのは、
   *   Prisma 版（`loadConsecutiveInputs()`）と fixtures 版（`rescueInputsFromCleanLogs()`）の
   *   形を揃えておくため★（どちらも 0 を詰めている）。
   */
  clean_area: number;
  task_area: number;
}

/**
 * 同一日の複数行を OR で1日に畳む（A案。ユーザー確定 2026-09-11 / D-109）。
 *
 * ★なぜ畳むのか★ — `countConsecutiveProblemDays()`（`lib/judge/escalation.ts`）の契約は
 *   「スケジュール存在日1日分の結果」を★1日1エントリ★で渡すことである。
 *   施設に複数の清掃エリア（ロボット）があると、同じ日に複数行が生まれるため、
 *   畳まずに渡すと同じ日付の要素が複数になり「N日連続」と水増しされる（落とし穴1）。
 *
 * ★畳み方（A案）★: 同一日に問題ありの行が1つでもあれば、その日は「問題あり」とする（OR）。
 *   ★★ユーザー確定の原文（2026-09-11）★★: 「2台のうち 1日目Aが問題 / 2日目Bが問題 /
 *   3日目Aが問題 でも3日連続で問題発生と記録する」。
 *   ★B案（全台が問題のときだけ問題日とする。AND）は不採用★ —
 *   3台ある施設がほぼ永久にエスカレーションしなくなるため。
 *
 * ★★同一ロボットが同じ日に2回清掃した場合も同じ理由で1日に畳む（決定2）★★ —
 *   D-026「連続はスケジュール存在日の連続」は日単位であり、回数単位ではない。
 *
 * @returns 日付昇順・重複なしの `ScheduleDayResult[]`
 */
export function foldProblemDaysByDate(
  rows: readonly ConsecutiveInput[]
): ScheduleDayResult[] {
  const isProblemByDate = new Map<string, boolean>();
  for (const row of rows) {
    const date = row.scheduled_at.slice(0, 10);
    const isProblem = isProblemCompletion(row.completion_rate);
    isProblemByDate.set(date, (isProblemByDate.get(date) ?? false) || isProblem);
  }
  return [...isProblemByDate.entries()]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([date, isProblem]) => ({ date, isProblem }));
}

/**
 * `consecutive_problem_days` を全行ぶんまとめて算出する（D-026 / 指示書R 6-3 / 指示書V 不具合B）。
 *
 * ★DB に対応するカラムは無い★ので、施設ごとに全行を集め、`foldProblemDaysByDate()` で
 * 同一日を1エントリに畳んだうえで、各行について「その行の日付までの連続」を数える。
 *
 * ★★分割キーは `facility_id`★★（`CLAUDE.md` §4.5 の R3/R4「同一施設で」）。
 *   ★★畳み込みはこの関数の内部だけで行う★★（呼び出し側でやると規則が2箇所に散る。D-103）。
 *   ★★同じ日の全行に同じ連続日数が入る★★（畳んだ1エントリの値を全員で共有するため）。
 *
 * ★「連続」はカレンダー日ではなく「スケジュール存在日の連続」★（D-026 / `CLAUDE.md` §4.5）。
 *   ここでは ★「清掃ログの行が存在する日」＝「スケジュール存在日」★ とみなす
 *   （`clean_timetables` の行はスケジュールから生成されるため。D-049）。
 *   したがって清掃予定のない日は自然に飛ばされ、連続が途切れない（畳み込み後も同じ）。
 *   判定そのものは `lib/judge/escalation.ts` の `countConsecutiveProblemDays()` に渡す
 *   （どの日がスケジュール存在日かの判断は呼び出し側の責任、という同関数の契約どおり）。
 *
 * ★★施設単位にした帰結（決定4。意図した結果）★★ — 正常終了した行にも R3/R4 が出ることが
 *   あり（別の台のせいで施設の連続が2日以上になる）、逆に別の台のせいでその台にとって
 *   初回の出発失敗でも R1 が出ないことがある（`lib/judge/advice.ts` の
 *   `consecutiveProblemDays < level2Days`。指示書BB A13 で閾値を設定から読むようにした）。
 *
 * ★★入力は「一覧と同じ期間で絞った行」にしないこと★★（指示書AC C-2 / 段階1）:
 *   この関数は ★渡された行の範囲でしか連続を数えられない★。一覧と同じ期間で絞った行を渡すと
 *   ★期間の外にある前日の行が消えて連続が途切れる★ — 「9/8× 9/9× 9/10×」（3日連続 = L3）を
 *   「9/10〜9/10」で絞って渡すと ★1日 = L1 に化ける★。
 *   ★★例外もテスト失敗も出ない fail-silent★★。
 *
 *   ★入力を作る責務は `loadConsecutiveInputs()`（`lib/db/consecutive-source.ts`）の1本に
 *   切り出してある★ — ★★`cleanLogs()` / `facilityPerformance()` / `continuousProblems()` は
 *   その1本を呼ぶこと★★（D-109 / D-103 の再発防止。連続日数を2箇所で別々に計算しない）。
 *   ★★この関数を呼ぶ前に自前で期間を絞り直さないこと★★。
 *
 *   ★★その1本は指示書AC 段階6 で有界になっている★★ — ★全期間読みではありません★。
 *   読む範囲は ★「表示期間 ＋ 保存された表示上限 +1日ぶんの遡り」★ で、
 *   遡り幅は `loadConsecutiveDaysMax()` の値から導く（★定数に写し取らないこと★）。
 *   ★真の連続が上限以下なら全部読めて正しい値★、★上限を超えるなら「{上限}日以上」★になる
 *   （根拠と遡り幅の決め方は `loadConsecutiveInputs()` の docstring が正）。
 *   ★例外★: `date_from` が `null`（単一ID取得など下限なしの呼び出し）のときは下限が付かない。
 *   ★代わりに読む列を最小限にしてある★（`select` で4列/6列。`include` は一切しない。
 *   ★列を増やしてよい条件は `loadConsecutiveInputs()` の docstring が正★）。
 *   畳み込みは施設ごとの1パスを足すだけなので、走査回数は増えも減りもしない。
 *
 * ★★予定外タスクの行（`id === null`。指示書BG §2-3）も畳み込みには入る★★（その日の問題日の OR・予定外だけの日も
 *   スケジュール存在日）が、★戻りの Map には載せない★（予定の id が無い）。予定外の行の値は
 *   `consecutiveProblemDaysByFacilityDate()`（施設 × 日付）で引くこと — 同じ施設・同じ日の行は全部同じ値になる。
 *
 * @returns `CleanLogItem.id`（★予定のある行だけ★）→ 連続問題日数
 */
export function computeConsecutiveProblemDays(
  rows: readonly ConsecutiveInput[]
): Map<number, number> {
  const byFacility = new Map<number, ConsecutiveInput[]>();
  for (const row of rows) {
    const list = byFacility.get(row.facility_id);
    if (list) list.push(row);
    else byFacility.set(row.facility_id, [row]);
  }

  const result = new Map<number, number>();
  for (const list of byFacility.values()) {
    // 同一日を1エントリに畳む（日付昇順で返る）
    const days = foldProblemDaysByDate(list);
    const dayIndexByDate = new Map<string, number>();
    days.forEach((day, i) => dayIndexByDate.set(day.date, i));

    for (const row of list) {
      // ★予定外タスク（id なし）は畳み込みにだけ入り、Map には載らない★（指示書BG §2-3）
      if (row.id === null) continue;
      const date = row.scheduled_at.slice(0, 10);
      // ★foldProblemDaysByDate() が同じ行から作った days なので必ず見つかる★
      const dayIndex = dayIndexByDate.get(date) as number;
      // 「その日まで」の連続なので、先頭からその日までを渡す（同じ日の全行が同じ値になる）
      result.set(row.id, countConsecutiveProblemDays(days.slice(0, dayIndex + 1)));
    }
  }
  return result;
}

/** `consecutiveProblemDaysByFacilityDate()` のキー（施設 × JST の日付） */
export function facilityDateKey(facilityId: number, scheduledAt: string): string {
  return `${facilityId}|${scheduledAt.slice(0, 10)}`;
}

/**
 * `computeConsecutiveProblemDays()` と同じ規則で、★施設 × 日付 → 連続問題日数★ を返す（指示書BG §2-1・§2-3）。
 *
 * ★予定外タスクの行（`CleanLogItem.id` が null）の連続日数を引くため★ — 予定外の行は予定の id を持たないので、
 *   id で引く `computeConsecutiveProblemDays()` の Map からは引けない。
 *   ★同じ施設の同じ日の行は全部同じ値★（`foldProblemDaysByDate()` で1日に畳むため）なので、施設 × 日付で引いて同じ答えになる。
 * ★入力は `computeConsecutiveProblemDays()` と同じもの★（`loadConsecutiveInputs()` の戻り）。
 *   予定外の行がその日の結果に入っているかは入力を作る側（`loadConsecutiveInputs()`）の責任。
 */
export function consecutiveProblemDaysByFacilityDate(
  rows: readonly ConsecutiveInput[]
): Map<string, number> {
  const byFacility = new Map<number, ConsecutiveInput[]>();
  for (const row of rows) {
    const list = byFacility.get(row.facility_id);
    if (list) list.push(row);
    else byFacility.set(row.facility_id, [row]);
  }
  const result = new Map<string, number>();
  for (const [facilityId, list] of byFacility) {
    const days = foldProblemDaysByDate(list);
    days.forEach((day, i) => {
      result.set(
        facilityDateKey(facilityId, day.date),
        countConsecutiveProblemDays(days.slice(0, i + 1))
      );
    });
  }
  return result;
}
