import { afterEach, describe, expect, it } from "vitest";

import {
  CLEAN_TIMETABLE_PRECHECK,
  CONSECUTIVE_DAYS_DISPLAY,
  CONSECUTIVE_LOOKBACK,
  RESULT_WAIT_MINUTES,
} from "@/lib/config";
import {
  completionRateFromReport,
  computeConsecutiveProblemDays,
  consecutiveProblemDaysByFacilityDate,
  facilityDateKey,
  foldProblemDaysByDate,
  isFutureSchedule,
  latestReportByTimetableId,
  type ConsecutiveInput,
} from "@/lib/db/clean-log-mapping";
import {
  consecutiveInputsFromRows,
  consecutiveLookbackCalendarDays,
  consecutiveLookbackFrom,
  loadConsecutiveInputs,
  CONSECUTIVE_RANGE_UNBOUNDED,
  type ConsecutiveDbRange,
  type ConsecutiveSourcePrisma,
  type ConsecutiveTaskReportRow,
  type ConsecutiveTimetableRow,
  type ConsecutiveUnscheduledReportRow,
  type ConsecutiveUnscheduledReportWhere,
} from "@/lib/db/consecutive-source";
import {
  consecutiveProblemDaysAtPeriodEnd,
  continuousProblemsInPeriod,
} from "@/lib/db/report-mapping";
import { buildRescueRequired } from "@/lib/db/rescue-mapping";
import { judgeEscalationLevel } from "@/lib/judge";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { runCompletionRate } from "@/lib/judge";
import { fromDbJst } from "@/lib/format/date";
import { periodRange } from "@/lib/format/period";
import {
  __setDisplayLimitStoreForTesting,
  type DisplayLimitOverrides,
} from "@/lib/settings/display-limits";

/**
 * 指示書AC 段階1 の期待値表（6行）。
 *
 * ★★段階1 は「挙動を1ミリも変えないリファクタ」なので、固定するのは
 *   「値が**変わらない**こと」です★★。
 *
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | 1 | ローカルDB 全件 | `loadConsecutiveInputs()` の件数 ＝ 段階0 の `cleanLogs()` の件数（538） |
 * | 2 | ローカルDB 全件 | `computeConsecutiveProblemDays()` の Map が段階0 のスナップショットと完全一致 |
 * | 3 | ローカルDB 全件 | `facilityPerformance()` の `consecutive_problem_days` が段階0 と一致 |
 * | 4 | ローカルDB 全件 | `continuousProblems()` の全行が段階0 と一致 |
 * | 5 | 合成データ | 同一施設・2台・Aの翌日にBが問題 → ★2日連続★（D-109 A案） |
 * | 6 | 合成データ | 清掃予定のない日を挟んでも連続が途切れない（D-026） |
 *
 * ★★#1〜#4 の期待値は、段階0（refactor 前の HEAD）で ★ローカルDB `dwatch_local` から実測した
 *   スナップショット★ をそのままこのファイルに書き写したものです★★
 *   （2026-09-13 実測。`clean_timetables` 1,044行 / 本清掃 538行 / 施設12件）。
 *   ★★テストからDBに接続しないこと★★（`lib/db/` のテストはDB非依存が原則）なので、
 *   ★施設9（★2台の清掃エリアを持ち、実測の最大連続6日を含む施設★）の実測62行を
 *   `LOCAL_DB_FACILITY_9` に固定し、その行から作ったスタブを通して同じ値が出ることを確認する★。
 *   ★全538行ぶんの一致は、同じスクリプトで段階0 と段階1 の全メソッドの戻り値を
 *   突き合わせて確認済み（11メソッドすべて完全一致）★。
 */

/**
 * ★★指示書AQ（参照元を `clean_task_reports` に差し替える）の期待値表（10行）★★
 *
 * ★実装の前にこの表を確定させたもの★（`CLAUDE.md` §0-2 / §6-2）。
 * ★★`clean_timetable_id` が NULL の行と `ended_at` が NULL の行を必ず含めること★★
 * （指示書AQ 罠1 / 罠3。★どちらも例外もテスト失敗も出ずに壊れる形★）。
 *
 * | #  | 入力 | 期待 |
 * |----|---|---|
 * | Q1 | 本清掃1行 + レポート1行（面積 50/100） | `completion_rate = 50`（★`runCompletionRate()`。D-155★） |
 * | Q2 | 本清掃1行 + ★対応レポートなし★ | `completion_rate = null`（★未実施★。§1-1） |
 * | Q3 | レポートの `clean_timetable_id` が ★NULL★ | ★`IN` に一致せず返らない → その清掃は「未実施」★（罠1） |
 * | Q4 | レポート3行・`ended_at` すべてあり | ★`ended_at` が最新★の1件 |
 * | Q5 | レポート2行・片方の `ended_at` が ★NULL★ | ★NULL 側は `started_at` を順序キーにする★（§1-4） |
 * | Q6 | レポート2行・★両方 `ended_at` が NULL★ | ★`started_at` が新しいほう★ |
 * | Q7 | レポート2行・順序キーが同着 | ★入力順の先頭★（`clean_status_histories` 時代と同じ規則） |
 * | Q8 | 面積 0/100 のレポートあり | ★`0`★（★`null` にしない★。「0%で実施」と「未実施」は別物） |
 * | Q9 | 面積 900/1000 のレポート / 0/0 のレポート / レポートなし | ★面積は生値 / なしは `0`★。完了率は 90 / ★0（`task_area = 0`。P-5=B。旧: null）★ / null、★`departed` は true / true / false★（D-155 / D-156 / D-158） |
 * | Q10| `select` の列 | 6列（`clean_timetable_id` / `clean_ratio` / `started_at` / `ended_at` / `clean_area` / `task_area`）。★`clean_error` は無い★ |
 *
 * ★★Q1〜Q10 の期待値は実測ではありません★★（`CLAUDE.md` §6-6）— ★仕様（指示書AQ §1-1〜§1-4）
 *   から起こした合成データ★です。★本番DBの `clean_task_reports` は0行★で、
 *   ★ユーザー提供CSV 189行が唯一の実データ★（指示書AQ 決定4の根拠表）。
 */

// ---------------------------------------------------------------- ヘルパー

/**
 * `clean_timetables.start_at` は ★日本時間の壁時計値★ が入った DATETIME（D-115 / 指示書Y）。
 * ISO文字列（+09:00）から、DBに入っているのと同じ「壁時計値としての Date」を作る。
 * ★`fromDbJst()` を通すと元の ISO に戻る★のが正しい状態。
 */
function dbWallClock(jstIso: string): Date {
  return new Date(jstIso.replace("+09:00", "Z"));
}

/**
 * @param endJstIso ★予定の終了★（結果待ちの判定用。D-173）。★省略時は開始の1時間後★
 */
function timetable(
  id: number,
  facilityId: number,
  jstIso: string,
  precheck: number = CLEAN_TIMETABLE_PRECHECK.main,
  endJstIso?: string
): ConsecutiveTimetableRow {
  const startAt = dbWallClock(jstIso);
  return {
    id: BigInt(id),
    facility_id: BigInt(facilityId),
    start_at: startAt,
    end_at: endJstIso === undefined ? new Date(startAt.getTime() + 3_600_000) : dbWallClock(endJstIso),
    precheck,
  };
}

/**
 * `clean_task_reports` の1行を作る（指示書AQ 決定2）。
 *
 * ★★「未実施」をこの関数では作れない★★（指示書AQ §1-1）— 未実施は
 *   ★★レポートの行そのものが無いこと★★で表すので、★行を作らない★のが正しい。
 *   ★`clean_ratio = 0` を未実施の代わりに使わないこと★（「0%で実施した」と別物。
 *   指示書U 落とし穴1）。
 *
 * ★面積の既定は `cleanRatio / 100`★（D-155）— 完了率は面積から `runCompletionRate()` で決まるので、
 *   `task_area = 100` に対して `clean_area = cleanRatio` とし、`clean_ratio` 列にも同じ値を入れる
 *   （★列は完了率に使っていない。指示書AS D6★）。★旧: 面積の既定は 0 だった★（指示書AI 段階2）。
 *   ★R-3 は面積で重み付けしない★（1回ごとの完了率の単純平均＝`meanCleanRatio()`。
 *   指示書AQ 決定4 / §5-5）。R-3 の期待値表は `lib/judge/__tests__/rescue.test.ts` が持つ。
 *
 * @param timetableId ★`null` を渡すと `clean_timetable_id` が NULL の行になる★（罠1 の検証用）
 * @param endedAtJstIso ★`null` を渡すと `ended_at` が NULL の行になる★（罠3 / §1-4 の検証用）
 */
function taskReport(
  timetableId: number | null,
  cleanRatio: number,
  endedAtJstIso: string | null = "2026-09-13T05:00:00+09:00",
  cleanArea = cleanRatio,
  taskArea = 100,
  // ★順序キーのフォールバック★（`ended_at` が NULL のとき使われる。指示書AQ §1-4）
  startedAtJstIso = "2026-09-13T04:00:00+09:00"
): ConsecutiveTaskReportRow {
  return {
    clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
    clean_ratio: cleanRatio,
    started_at: dbWallClock(startedAtJstIso),
    ended_at: endedAtJstIso === null ? null : dbWallClock(endedAtJstIso),
    clean_area: cleanArea,
    task_area: taskArea,
  };
}

/**
 * `findMany()` に渡された引数を控えるスタブ（★DBに接続しない★）。
 *
 * ★★`where.start_at` と `where.clean_timetable_id` は**DBと同じように絞る**★★ —
 *   段階6（遡り範囲を有界にする）の検証は「★どの行が返らなくなったか★」を見る必要があるため。
 *
 * ★★ただし `where.precheck` はあえて**無視する**★★ —
 *   `precheck` の絞り込みは SQL にも降ろしてあるが、★規則の正は
 *   `consecutiveInputsFromRows()` のメモリ側 filter★（指示書AC C-5）。
 *   スタブが SQL 側で落としてしまうと、★純粋関数側の filter を消しても
 *   テストが緑のまま★になる。
 */
function stubPrisma(
  timetables: readonly ConsecutiveTimetableRow[],
  reports: readonly ConsecutiveTaskReportRow[],
  // ★`clean_timetable_histories` の行（指示書AU 段階3b。既定は空）★
  historyTimetables: readonly ConsecutiveTimetableRow[] = [],
  // ★予定外タスクの行（指示書BG §2-3。3本目のクエリ。既定は空）★
  unscheduledReports: readonly ConsecutiveUnscheduledReportRow[] = []
): { prisma: ConsecutiveSourcePrisma; calls: Record<string, unknown[]> } {
  const calls: Record<string, unknown[]> = {
    cleanTimetable: [],
    cleanTimetableHistory: [],
    cleanTaskReport: [],
    // ★予定外タスクの3本目は別の箱に控える★（2本目の引数を見ている既存のテストを変えないため）
    cleanTaskReportUnscheduled: [],
  };
  /**
   * 予定の表の `findMany`（本表・履歴で共通）。★`where.id.in`（影チェック。`lib/db/timetable-source.ts`）だけは
   *   再現する★ — 再現しないと、影チェックが本表の全行を返して「本表に同じ id がある」と誤認する。
   */
  const timetableFindMany =
    (rows: readonly ConsecutiveTimetableRow[], bucket: "cleanTimetable" | "cleanTimetableHistory") =>
    async (args: Parameters<ConsecutiveSourcePrisma["cleanTimetable"]["findMany"]>[0]) => {
      const idIn = (args.where as { id?: { in: bigint[] } } | undefined)?.id?.in;
      // ★影チェックは記録しない★（`calls.cleanTimetable` の引数を見ている既存のテストを変えないため）
      if (idIn) return rows.filter((row) => idIn.some((id) => id === row.id));
      calls[bucket].push(args);
      const range = args.where?.start_at;
      return rows.filter((row) => {
        if (range?.gte && row.start_at < range.gte) return false;
        if (range?.lt && row.start_at >= range.lt) return false;
        return true;
      });
    };
  const prisma: ConsecutiveSourcePrisma = {
    cleanTimetable: { findMany: timetableFindMany(timetables, "cleanTimetable") },
    cleanTimetableHistory: { findMany: timetableFindMany(historyTimetables, "cleanTimetableHistory") },
    cleanTaskReport: {
      // ★2つの形を `where.clean_timetable_id.in` の有無で見分ける★（予定の id で引く2本目／予定外タスクの3本目）。
      //   ★交差型の `findMany` は2つの形のどちらにも代入できる必要がある★ので、和の引数で受けて型を合わせる
      findMany: (async (
        args:
          | { where: { clean_timetable_id: { in: bigint[] } } }
          | { where: ConsecutiveUnscheduledReportWhere }
      ) => {
        if ("clean_timetable_id" in args.where) {
          calls.cleanTaskReport.push(args);
          const ids = new Set(args.where.clean_timetable_id.in.map((id) => id.toString()));
          return reports.filter((row) => {
            // ★★SQL と同じ挙動にする — NULL は `IN` に一致しない★★（指示書AQ 罠1）。
            //   ★ここを `?? ""` 等で誤魔化すと、罠1（NULL 行が「未実施」に化ける）を
            //   テストで再現できなくなります★
            if (row.clean_timetable_id === null) return false;
            return ids.has(row.clean_timetable_id.toString());
          });
        }
        calls.cleanTaskReportUnscheduled.push(args);
        const where = args.where;
        // ★SQL と同じ意味で当てる★: `OR`（NULL か 0）・`started_at` の範囲・施設の `IN`
        return unscheduledReports.filter((row) => {
          const isUnscheduled = where.OR.some((cond) =>
            cond.clean_timetable_id === null
              ? row.clean_timetable_id === null
              : row.clean_timetable_id !== null && row.clean_timetable_id === cond.clean_timetable_id
          );
          if (!isUnscheduled) return false;
          if (where.started_at?.gte && row.started_at < where.started_at.gte) return false;
          if (where.started_at?.lt && row.started_at >= where.started_at.lt) return false;
          if (where.facility_id && !where.facility_id.in.some((id) => id === row.facility_id)) return false;
          return true;
        });
      }) as unknown as ConsecutiveSourcePrisma["cleanTaskReport"]["findMany"],
    },
  };
  return { prisma, calls };
}

/**
 * ★予定外タスクの `clean_task_reports` の1行★（指示書BG §2-3）。
 * @param timetableId ★0 か `null`★（どちらも予定外。表U1 U1-3）
 */
function unscheduledReport(
  facilityId: number,
  startedAtJstIso: string,
  cleanArea: number,
  taskArea = 100,
  timetableId: 0 | null = null
): ConsecutiveUnscheduledReportRow {
  return {
    facility_id: BigInt(facilityId),
    clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
    clean_ratio: 0,
    started_at: dbWallClock(startedAtJstIso),
    ended_at: dbWallClock(startedAtJstIso),
    clean_area: cleanArea,
    task_area: taskArea,
  };
}

/**
 * 「システム管理画面で保存された表示上限」を差し替える（★実ファイルを触らない★）。
 *
 * ★段階6 の期待値表 #5 / #6（上限を大きく／小さくしたらクエリの遡り範囲も追随する）の
 *   検証に使う★。★`undefined` を渡すと「未編集」＝ `lib/config/` の既定値に倒れる★。
 */
function useSavedConsecutiveDaysMax(value: number | undefined): void {
  const overrides: DisplayLimitOverrides =
    value === undefined ? {} : { consecutive_days_max: value };
  __setDisplayLimitStoreForTesting(
    {
      async read() {
        return { ...overrides };
      },
      async write() {
        /* 段階6 は読むだけ。保存はしない */
      },
    },
    "(test)"
  );
}

afterEach(() => {
  // ★差し替えを必ず戻す★ — 残すと他のテストが実ファイルではなくこのスタブを読む
  __setDisplayLimitStoreForTesting(null);
});

/**
 * ★本体（`consecutiveInputsFromRows()`）とは独立に書いた素朴な実装★。
 * ★これと本体の結果が完全一致することを合格条件にする★（本体のヘルパーを1つも使わない）。
 *
 * ★★指示書AQ の切替でこの関数も書き直した★★ — 以前は
 *   `completionRateFromHistory()` / `latestHistoryByTimetableId()` を呼んでいたが、
 *   ★`clean_error` が `clean_task_reports` に無い★ため成立しない（指示書AQ §1-1）。
 *   ★規則をベタ書きにして「本体と同じ関数を呼ぶから一致する」トートロジーを避ける★。
 */
function legacyConsecutiveInputs(
  timetables: readonly ConsecutiveTimetableRow[],
  reports: readonly ConsecutiveTaskReportRow[],
  now: Date
): ConsecutiveInput[] {
  const mainRows = timetables.filter(
    (row) => row.precheck === CLEAN_TIMETABLE_PRECHECK.main
  );
  // ★順序キー: `ended_at` があればそれ、無ければ `started_at`★（指示書AQ §1-4）。
  //   ★同着は入力順の先頭★（`<` 比較なので後続で上書きされない）
  const reportByTimetableId = new Map<string, ConsecutiveTaskReportRow>();
  for (const report of reports) {
    if (report.clean_timetable_id === null) continue; // ★NULL はキーにできない★（罠1）
    const key = report.clean_timetable_id.toString();
    const current = reportByTimetableId.get(key);
    const orderAt = report.ended_at ?? report.started_at;
    if (!current || (current.ended_at ?? current.started_at) < orderAt) {
      reportByTimetableId.set(key, report);
    }
  }
  return mainRows
    .filter((row) => !isFutureSchedule(fromDbJst(row.start_at), now))
    // ★結果待ち（レポートなし かつ 予定の終了＋待ち時間より前）は外す★（表2 C10。D-173）。
    //   ★本体の `isResultPending()` を呼ばずにベタ書きする★（トートロジーを避ける。上の docstring）
    .filter(
      (row) =>
        reportByTimetableId.has(row.id.toString()) ||
        now.getTime() >= fromDbJst(row.end_at).getTime() + RESULT_WAIT_MINUTES * 60_000
    )
    .map((row) => {
      const r = reportByTimetableId.get(row.id.toString());
      return {
        id: Number(row.id),
        unscheduled: false,
        facility_id: Number(row.facility_id),
        scheduled_at: new Date(
          fromDbJst(row.start_at).getTime()
        ).toISOString(), // ★後段で jstIsoFromDb と突き合わせるため、ここでは比較対象から外す★
        // ★未実施 ＝ レポートの行が無い★（指示書AQ §1-1）。★`clean_error` は見ない★
        //   ★1回ごとの完了率は `runCompletionRate()` を呼ぶ★（指示書AU 段階2-4。テストの中に規則を写さない —
        //   規則は `lib/judge/__tests__/run-completion-rate.test.ts` の表1 で固定している）
        completion_rate: r === undefined ? null : runCompletionRate(r),
        departed: r !== undefined,
        // ★レポートが無い行は 0★（指示書AI 段階2。本体側と同じ規則をここにも写す）
        clean_area: r?.clean_area ?? 0,
        task_area: r?.task_area ?? 0,
      };
    });
}

// ---------------------------------------------------------------- 段階0 の実測スナップショット

/**
 * ★段階0（refactor 前）にローカルDB `dwatch_local` から実測した施設9の全62行★。
 * `[清掃ログid, scheduled_at, completion_rate, ★段階0 の consecutive_problem_days★]`
 *
 * ★施設9 を選んだ理由★: ★清掃エリアが2つ（1日に2行）ある★ので同一日OR畳み込み（D-109 A案）が
 * 効いており、かつ ★実測の最大連続日数6日★ を含むため。
 * ★この表の値を書き換えないこと★ — 書き換えたら「挙動が変わっていない」ことの証拠が消える。
 */
const LOCAL_DB_FACILITY_9: readonly [number, string, number | null, number][] = [
  [1008, "2026-09-13T02:30:00+09:00", 9.9, 6],
  [1017, "2026-09-08T02:30:00+09:00", 22, 1],
  [1018, "2026-09-09T02:30:00+09:00", 18, 2],
  [1019, "2026-09-10T02:30:00+09:00", 25, 3],
  [1020, "2026-09-11T02:30:00+09:00", 15, 4],
  [1021, "2026-09-12T02:30:00+09:00", 12, 5],
  [1038, "2026-09-07T02:30:00+09:00", 75.9, 0],
  [1048, "2026-09-06T02:30:00+09:00", 96.9, 1],
  [1058, "2026-09-05T02:30:00+09:00", 93.8, 0],
  [1068, "2026-09-04T02:30:00+09:00", 94.6, 1],
  [1078, "2026-09-03T02:30:00+09:00", 94.6, 0],
  [1088, "2026-09-02T02:30:00+09:00", 98.1, 1],
  [1098, "2026-09-01T02:30:00+09:00", 94.5, 0],
  [1108, "2026-08-31T02:30:00+09:00", 98.5, 2],
  [1118, "2026-08-30T02:30:00+09:00", 8.5, 1],
  [1128, "2026-08-29T02:30:00+09:00", 95.9, 0],
  [1138, "2026-08-28T02:30:00+09:00", 97.7, 1],
  [1148, "2026-08-27T02:30:00+09:00", 98.2, 0],
  [1158, "2026-08-26T02:30:00+09:00", null, 3],
  [1168, "2026-08-25T02:30:00+09:00", 9.1, 2],
  [1178, "2026-08-24T02:30:00+09:00", 99.4, 1],
  [1188, "2026-08-23T02:30:00+09:00", 96.6, 0],
  [1198, "2026-08-22T02:30:00+09:00", 76.4, 2],
  [1208, "2026-08-21T02:30:00+09:00", 76.2, 1],
  [1218, "2026-08-20T02:30:00+09:00", 93.7, 0],
  [1228, "2026-08-19T02:30:00+09:00", 98.1, 1],
  [1238, "2026-08-18T02:30:00+09:00", 95.1, 0],
  [1248, "2026-08-17T02:30:00+09:00", 99.1, 1],
  [1258, "2026-08-16T02:30:00+09:00", 96.2, 0],
  [1268, "2026-08-15T02:30:00+09:00", 94.7, 1],
  [1278, "2026-08-14T02:30:00+09:00", 94.7, 0],
  [3008, "2026-08-14T01:20:00+09:00", 73.6, 0],
  [3017, "2026-08-15T01:20:00+09:00", 5.6, 1],
  [3026, "2026-08-16T01:20:00+09:00", 98.5, 0],
  [3035, "2026-08-17T01:20:00+09:00", 30.6, 1],
  [3044, "2026-08-18T01:20:00+09:00", 85.1, 0],
  [3053, "2026-08-19T01:20:00+09:00", 8.3, 1],
  [3062, "2026-08-20T01:20:00+09:00", 92.8, 0],
  [3071, "2026-08-21T01:20:00+09:00", 53.8, 1],
  [3080, "2026-08-22T01:20:00+09:00", null, 2],
  [3089, "2026-08-23T01:20:00+09:00", 79.2, 0],
  [3098, "2026-08-24T01:20:00+09:00", 4.4, 1],
  [3107, "2026-08-25T01:20:00+09:00", 98.1, 2],
  [3116, "2026-08-26T01:20:00+09:00", 36.6, 3],
  [3125, "2026-08-27T01:20:00+09:00", 87.4, 0],
  [3134, "2026-08-28T01:20:00+09:00", 0.9, 1],
  [3143, "2026-08-29T01:20:00+09:00", 95.9, 0],
  [3152, "2026-08-30T01:20:00+09:00", 57.9, 1],
  [3161, "2026-08-31T01:20:00+09:00", null, 2],
  [3170, "2026-09-01T01:20:00+09:00", 71.2, 0],
  [3179, "2026-09-02T01:20:00+09:00", 1, 1],
  [3188, "2026-09-03T01:20:00+09:00", 99.4, 0],
  [3197, "2026-09-04T01:20:00+09:00", 50.3, 1],
  [3206, "2026-09-05T01:20:00+09:00", 76.1, 0],
  [3215, "2026-09-06T01:20:00+09:00", 2.8, 1],
  [3224, "2026-09-07T01:20:00+09:00", 93.7, 0],
  [3233, "2026-09-08T01:20:00+09:00", 60.3, 1],
  [3242, "2026-09-09T01:20:00+09:00", null, 2],
  [3251, "2026-09-10T01:20:00+09:00", 88.6, 3],
  [3260, "2026-09-11T01:20:00+09:00", 6.2, 4],
  [3269, "2026-09-12T01:20:00+09:00", 94.7, 5],
  [3278, "2026-09-13T01:20:00+09:00", 38.4, 6],
];

/** 段階0 実測: `db.cleanLogs()` の全件数（＝ `loadConsecutiveInputs()` が返すべき件数） */
const LOCAL_DB_TOTAL_MAIN_ROWS = 538;

const FACILITY_9_NAME = "メガバリューマート三ツ橋中央ショッピングセンター店";

/** ★段階0 実測★: `facilityPerformance()` の施設9の `consecutive_problem_days` */
const LOCAL_DB_FACILITY_PERFORMANCE_9: readonly {
  period: "weekly" | "monthly";
  year: number;
  month: number;
  week: number;
  consecutive_problem_days: number;
}[] = [
  { period: "monthly", year: 2026, month: 8, week: 0, consecutive_problem_days: 2 },
  { period: "monthly", year: 2026, month: 9, week: 0, consecutive_problem_days: 6 },
  { period: "weekly", year: 2026, month: 9, week: 2, consecutive_problem_days: 5 },
  { period: "weekly", year: 2026, month: 9, week: 3, consecutive_problem_days: 6 },
];

/** ★段階0 実測★: `continuousProblems()` の施設9の全行（★`week: 5` は行が出ない★） */
const LOCAL_DB_CONTINUOUS_PROBLEMS_9: readonly {
  period: "weekly" | "monthly";
  year: number;
  month: number;
  week: number;
  item: Record<string, unknown> | null;
}[] = [
  {
    period: "weekly",
    year: 2026,
    month: 8,
    week: 3,
    item: {
      facility_id: 9,
      facility_name: FACILITY_9_NAME,
      consecutive_problem_days: 1,
      escalation_level: "L1",
      last_problem_on: "2026-08-15",
      summary: "{n}日連続で完了率が△以下",
      launch_failed_days: 0,
    },
  },
  {
    period: "weekly",
    year: 2026,
    month: 8,
    week: 4,
    item: {
      facility_id: 9,
      facility_name: FACILITY_9_NAME,
      consecutive_problem_days: 2,
      escalation_level: "L2",
      last_problem_on: "2026-08-22",
      summary: "{n}日連続で△以下（うち{m}日は出発失敗）",
      launch_failed_days: 1,
    },
  },
  // ★この週だけ行が出ない（連続0日）★ — 「全期間を読んでいれば必ず行が出る」ではないことの固定
  { period: "weekly", year: 2026, month: 8, week: 5, item: null },
  {
    period: "weekly",
    year: 2026,
    month: 8,
    week: 6,
    item: {
      facility_id: 9,
      facility_name: FACILITY_9_NAME,
      consecutive_problem_days: 2,
      escalation_level: "L2",
      last_problem_on: "2026-08-31",
      summary: "{n}日連続で△以下（うち{m}日は出発失敗）",
      launch_failed_days: 1,
    },
  },
  {
    period: "monthly",
    year: 2026,
    month: 8,
    week: 0,
    item: {
      facility_id: 9,
      facility_name: FACILITY_9_NAME,
      consecutive_problem_days: 2,
      escalation_level: "L2",
      last_problem_on: "2026-08-31",
      summary: "{n}日連続で△以下（うち{m}日は出発失敗）",
      launch_failed_days: 1,
    },
  },
  {
    period: "weekly",
    year: 2026,
    month: 9,
    week: 2,
    item: {
      facility_id: 9,
      facility_name: FACILITY_9_NAME,
      consecutive_problem_days: 5,
      escalation_level: "L3",
      last_problem_on: "2026-09-12",
      summary: "{n}日連続で△以下（うち{m}日は出発失敗）",
      launch_failed_days: 1,
    },
  },
  {
    period: "weekly",
    year: 2026,
    month: 9,
    week: 3,
    item: {
      facility_id: 9,
      facility_name: FACILITY_9_NAME,
      consecutive_problem_days: 6,
      escalation_level: "L3",
      last_problem_on: "2026-09-13",
      summary: "{n}日連続で△以下（うち{m}日は出発失敗）",
      launch_failed_days: 1,
    },
  },
  {
    period: "monthly",
    year: 2026,
    month: 9,
    week: 0,
    item: {
      facility_id: 9,
      facility_name: FACILITY_9_NAME,
      consecutive_problem_days: 6,
      escalation_level: "L3",
      last_problem_on: "2026-09-13",
      summary: "{n}日連続で△以下（うち{m}日は出発失敗）",
      launch_failed_days: 1,
    },
  },
];

/**
 * 施設9 の実測行から、DB に入っているのと同じ形の行を組み立てる。
 *
 * ★★完了率が `null`（未実施）の行はレポートを作らない★★（指示書AQ §1-1）—
 *   ★旧実装では `clean_error = 1` の履歴行を作っていた★が、`clean_task_reports` には
 *   その列が無く、★未実施は「行が無いこと」でしか表せない★。
 *   ★★段階0 の実測スナップショット（連続日数）は1つも変わらないこと★★が合格条件。
 */
function facility9Rows(): {
  timetables: ConsecutiveTimetableRow[];
  reports: ConsecutiveTaskReportRow[];
} {
  const timetables: ConsecutiveTimetableRow[] = [];
  const reports: ConsecutiveTaskReportRow[] = [];
  for (const [id, jstIso, rate] of LOCAL_DB_FACILITY_9) {
    timetables.push(timetable(id, 9, jstIso));
    if (rate === null) continue; // ★未実施 ＝ レポートの行が無い★
    reports.push(taskReport(id, rate, jstIso));
  }
  return { timetables, reports };
}

/** ★段階0 の実測より後の時刻★（未来除外が効かない基準時刻。実測時と同じ状態を再現する） */
const NOW_AFTER_LOCAL_DB = new Date("2026-09-30T00:00:00+09:00");

// ---------------------------------------------------------------- テスト

describe("loadConsecutiveInputs（指示書AC 段階1 — 連続日数の入力を1本に切り出す）", () => {
  it("読む列を `select` で絞り、`include` を一切しない（★この2本だけが広い範囲を読むため★）", async () => {
    const { prisma, calls } = stubPrisma(
      [timetable(1, 1, "2026-09-01T02:00:00+09:00")],
      [taskReport(1, 50)]
    );
    await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);

    expect(calls.cleanTimetable).toEqual([
      {
        // ★範囲なしのときは `start_at` のキーごと付けない★。
        //   ★削除済みの施設・清掃エリアの行は入力に入れない（指示書BB U1・表1 V15）。`select` は変えない（5-12）★
        where: { precheck: CLEAN_TIMETABLE_PRECHECK.main, ...FACILITY_SECTION_VISIBLE_WHERE },
        // ★`end_at` は結果待ちの判定用（D-173）★
        select: { id: true, facility_id: true, start_at: true, end_at: true, precheck: true },
      },
    ]);
    // ★★履歴にも同じ引数が渡る★★（指示書AU 段階3b。`where` を2回書かない）
    expect(calls.cleanTimetableHistory).toEqual(calls.cleanTimetable);
    expect(calls.cleanTaskReport).toEqual([
      {
        // ★レポートは「引いた本清掃の id」で引き直す（全件読みに戻さない）★
        where: { clean_timetable_id: { in: [BigInt(1)] } },
        select: {
          clean_timetable_id: true,
          clean_ratio: true,
          // ★★`clean_error` は `clean_task_reports` に無い★★（指示書AQ §1-1）。
          //   ★この行が復活したら未実施の判定規則が戻っている★
          // ★順序キーのフォールバック★（`ended_at` が nullable。指示書AQ §1-4）
          started_at: true,
          ended_at: true,
          // ★旧: 要レスキューの急落判定（R-3）の面積の重み付けに読んでいた2列（指示書AI 段階2）★。
          //   ★2026-09-30 から R-3 は1回ごとの完了率の単純平均で、この2列は完了率の平均には使わない★（指示書AQ 決定4。D-155 以降の1回ごとの完了率は面積から `runCompletionRate()` が作るので、この2列はそのために読んでいる）。
          //   ★これ以上増やすときの条件は `loadConsecutiveInputs()` の docstring★
          clean_area: true,
          task_area: true,
        },
      },
    ]);
    // ★`include` を足していないこと（キーが `where` と `select` だけ）★
    for (const args of [...calls.cleanTimetable, ...calls.cleanTimetableHistory, ...calls.cleanTaskReport]) {
      expect(Object.keys(args as object).sort()).toEqual(["select", "where"]);
    }
  });

  it("★履歴（`clean_timetable_histories`）だけにある予定も読み、レポートを引き直す★（指示書AU 段階3b）", async () => {
    const { prisma, calls } = stubPrisma(
      [timetable(1, 1, "2026-09-01T02:00:00+09:00")],
      [taskReport(1, 50), taskReport(2, 80)],
      [timetable(2, 1, "2026-09-02T02:00:00+09:00")]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    expect(rows.map((r) => r.id).sort()).toEqual([1, 2]);
    // ★レポートは合わせた後の id で引く（履歴の予定に紐づく行も取れる）★
    const reportIds = (calls.cleanTaskReport as { where: { clean_timetable_id: { in: bigint[] } } }[])
      .flatMap((c) => c.where.clean_timetable_id.in)
      .map(String)
      .sort();
    expect(reportIds).toEqual(["1", "2"]);
  });

  it("本清掃が0件なら履歴のクエリを1本も撃たない（`in: []` の空撃ちをしない）", async () => {
    const { prisma, calls } = stubPrisma([], []);
    const rows = await loadConsecutiveInputs(
      prisma,
      NOW_AFTER_LOCAL_DB,
      CONSECUTIVE_RANGE_UNBOUNDED
    );

    expect(rows).toEqual([]);
    expect(calls.cleanTaskReport).toEqual([]);
  });

  it("本清掃だけに絞る（事前チェック行は入れない）", async () => {
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, "2026-09-01T02:00:00+09:00"),
        timetable(2, 1, "2026-09-01T01:00:00+09:00", 60),
        timetable(3, 1, "2026-09-01T01:30:00+09:00", 30),
      ],
      [taskReport(1, 50), taskReport(2, 0), taskReport(3, 0)]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    expect(rows.map((r) => r.id)).toEqual([1]);
  });

  it("★`isFutureSchedule()` を外さない★ — 予定時刻がまだ来ていない本清掃を除外する（D-106 / D-107 / D-110）", async () => {
    const now = new Date("2026-09-10T03:00:00+09:00");
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, "2026-09-10T02:00:00+09:00"),
        timetable(2, 1, "2026-09-10T03:00:00+09:00"), // ★同時刻は「来ていない」に入れない★
        timetable(3, 1, "2026-09-11T02:00:00+09:00"), // 未来 → 除外
      ],
      // ★id 2 にもレポートを付ける★ — 無いと結果待ち（D-173）で外れ、`isFutureSchedule()` の境界を確かめられない
      [taskReport(1, 50), taskReport(2, 50)]
    );
    const rows = await loadConsecutiveInputs(prisma, now, CONSECUTIVE_RANGE_UNBOUNDED);
    expect(rows.map((r) => r.id)).toEqual([1, 2]);
    // ★未来を外さないと履歴の無い行が「未実施（出発失敗）」として連続日数に混ざる★
    expect(rows.find((r) => r.id === 3)).toBeUndefined();
  });

  it("★結果待ち（D-173。表2 C10）は未来の予定と同じく入力から外す★ — 確定した出発失敗は残す", async () => {
    // 予定 02:00〜03:00、W=120（表1 と同じ時刻）。now=04:59 は結果待ち、レポートありは残る
    const day = "2026-09-10";
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, `${day}T02:00:00+09:00`, CLEAN_TIMETABLE_PRECHECK.main, `${day}T03:00:00+09:00`), // レポートなし → 結果待ち
        timetable(2, 1, `${day}T02:00:00+09:00`, CLEAN_TIMETABLE_PRECHECK.main, `${day}T03:00:00+09:00`), // レポートあり
        timetable(3, 1, `${day}T00:00:00+09:00`, CLEAN_TIMETABLE_PRECHECK.main, `${day}T02:59:00+09:00`), // レポートなし・04:59 で確定
      ],
      [taskReport(2, 50)]
    );
    const pending = await loadConsecutiveInputs(
      prisma,
      new Date(`${day}T04:59:00+09:00`),
      CONSECUTIVE_RANGE_UNBOUNDED
    );
    expect(pending.map((r) => r.id).sort()).toEqual([2, 3]);
    expect(pending.find((r) => r.id === 3)?.departed).toBe(false);

    // ★終了＋120分ちょうど（05:00）で確定 → 出発失敗として入力に戻る（表1 P6）★
    const settled = await loadConsecutiveInputs(
      prisma,
      new Date(`${day}T05:00:00+09:00`),
      CONSECUTIVE_RANGE_UNBOUNDED
    );
    expect(settled.map((r) => r.id).sort()).toEqual([1, 2, 3]);
    expect(settled.find((r) => r.id === 1)?.departed).toBe(false);
  });

  it("`start_at` を壁時計値として読む（`fromDbJst()` / `jstIsoFromDb()` を通す。D-115）", async () => {
    const { prisma } = stubPrisma(
      [timetable(1, 7, "2026-09-10T20:00:00+09:00")],
      [taskReport(1, 88.6)]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    expect(rows).toEqual([
      {
        id: 1,
        unscheduled: false,
        facility_id: 7,
        scheduled_at: "2026-09-10T20:00:00+09:00",
        completion_rate: 88.6,
        departed: true,
        // ★`taskReport()` の既定（面積 88.6/100）。この検証の対象は日時の読み方であって面積ではない★
        clean_area: 88.6,
        task_area: 100,
      },
    ]);
  });

  it("★未実施＝レポートの行が無いこと★。完了率 0（面積 0/100）は 0 のまま（0%実施に化けさせない）", async () => {
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, "2026-09-01T02:00:00+09:00"), // ★レポートなし → 未実施★
        timetable(2, 1, "2026-09-02T02:00:00+09:00"), // ★0% で実施★
        timetable(3, 1, "2026-09-03T02:00:00+09:00"), // ★レポートなし → 未実施★
      ],
      [taskReport(2, 0)]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    // ★「0%で実施した」と「未実施」は別物★（指示書U 落とし穴1。判定はどちらも × なので
    //   ★画面もテストも赤くならないまま壊れる★ところ）
    expect(rows.map((r) => r.completion_rate)).toEqual([null, 0, null]);
  });

  it("レポートが複数あるときは順序キーが最新の1件を採る（指示書AQ §1-4）", async () => {
    const { prisma } = stubPrisma(
      [timetable(1, 1, "2026-09-01T02:00:00+09:00")],
      [
        taskReport(1, 10, "2026-09-01T03:00:00+09:00"),
        taskReport(1, 90, "2026-09-01T05:00:00+09:00"),
        taskReport(1, 40, "2026-09-01T04:00:00+09:00"),
      ]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    expect(rows[0].completion_rate).toBe(90);
  });

  it("★面積の生値をそのまま持ち帰る（レポートなしは 0）★（指示書AI 段階2。★R-3 は面積で重み付けしない★ — 指示書AQ 決定4）", async () => {
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, "2026-09-01T02:00:00+09:00"),
        timetable(2, 1, "2026-09-02T02:00:00+09:00"),
        timetable(3, 1, "2026-09-03T02:00:00+09:00"),
      ],
      [
        taskReport(1, 90, "2026-09-01T05:00:00+09:00", 900, 1000),
        // ★面積0（`task_area = 0`）で実施したレポート★（★未実施ではない★。表5 U2）
        taskReport(2, 0, "2026-09-02T05:00:00+09:00", 0, 0),
        // id=3 はレポートなし ＝ ★未実施★
      ]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    expect(rows.map((r) => [r.clean_area, r.task_area])).toEqual([
      [900, 1000],
      [0, 0],
      // ★★レポートが無い行は `undefined` ではなく 0★★（Prisma 版と fixtures 版の形を揃えるため。
      //   旧: optional にすると R-3 の面積加重が静かに壊れる、という理由だった）
      [0, 0],
    ]);
    // ★★完了率は1回ごとの面積比（`runCompletionRate()`。D-155）★★ —
    //   id=1 は 900/1000 = 90、★id=2 は `task_area = 0` なので 0（P-5=B）★、id=3 はレポートなしで null
    expect(rows.map((r) => r.completion_rate)).toEqual([90, 0, null]);
    // ★★未実施は `departed` で見る★★（【未決1】案A。D-156）— ★id=2 は出発している★
    expect(rows.map((r) => r.departed)).toEqual([true, true, false]);
  });

  it("★独立に書いた素朴な実装と完全一致する★（本体のヘルパーを1つも使わない突き合わせ）", () => {
    const now = new Date("2026-09-10T03:00:00+09:00");
    const timetables = [
      timetable(1, 1, "2026-09-08T02:00:00+09:00"),
      timetable(2, 1, "2026-09-09T02:00:00+09:00"), // ★レポートなし ＝ 未実施★
      timetable(3, 1, "2026-09-10T02:00:00+09:00"),
      timetable(4, 1, "2026-09-11T02:00:00+09:00"), // 未来
      timetable(5, 2, "2026-09-08T01:00:00+09:00", 60), // 事前チェック
      timetable(6, 2, "2026-09-08T02:00:00+09:00"),
      timetable(7, 2, "2026-09-09T02:00:00+09:00"), // ★`ended_at` が NULL のレポート★
    ];
    const reports = [
      taskReport(1, 95),
      // ★id=2 のレポートは作らない（未実施）★
      taskReport(3, 0),
      taskReport(4, 10),
      taskReport(5, 0),
      taskReport(6, 70),
      // ★`clean_timetable_id` が NULL の行を混ぜる（罠1）★ — どの清掃にも結びつかない
      taskReport(null, 100),
      // ★`ended_at` が NULL の行を混ぜる（罠3 / §1-4）★
      taskReport(7, 55, null, 55, 100, "2026-09-09T03:00:00+09:00"),
      // ★`task_area = 0` の行（完了率 0。P-5=B・出発はしている。表5 U2）★
      taskReport(6, 80, "2026-09-14T05:00:00+09:00", 10, 0),
    ];

    const actual = consecutiveInputsFromRows(timetables, reports, now, []);
    const legacy = legacyConsecutiveInputs(timetables, reports, now);

    // ★`scheduled_at` の作り方だけは legacy 側の写しで表現できないので、id と値で突き合わせる★
    expect(actual.map((r) => [r.id, r.facility_id, r.completion_rate])).toEqual(
      legacy.map((r) => [r.id, r.facility_id, r.completion_rate])
    );
    expect(actual.map((r) => r.scheduled_at)).toEqual([
      "2026-09-08T02:00:00+09:00",
      "2026-09-09T02:00:00+09:00",
      "2026-09-10T02:00:00+09:00",
      "2026-09-08T02:00:00+09:00",
      "2026-09-09T02:00:00+09:00",
    ]);
  });
});

describe("指示書AQ — 参照元を `clean_task_reports` に差し替える（期待値表 Q1〜Q10）", () => {
  it("Q3 ★`clean_timetable_id` が NULL のレポートは `IN` に一致せず、その清掃は「未実施」になる★（罠1）", async () => {
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, "2026-09-01T02:00:00+09:00"),
        timetable(2, 1, "2026-09-02T02:00:00+09:00"),
      ],
      [
        taskReport(1, 95),
        // ★本来は id=2 の結果だが `clean_timetable_id` が埋まっていない行★
        //   （指示書AQ 決定5 — TeamWorks が今後埋める前提）
        taskReport(null, 95),
      ]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);

    // ★★行が消えるのではなく「未実施」に化けるのが危険なところ★★（fail-silent）。
    //   ★95% で実施されていても画面には未実施として出る★
    expect(rows.map((r) => [r.id, r.completion_rate])).toEqual([
      [1, 95],
      [2, null],
    ]);
  });

  it("Q3c ★`clean_timetable_id` が 0（予定外。TeamWorks 回答 2026-10-27）のレポートも引き当たらない・0 を `IN` に入れない★", async () => {
    const { prisma, calls } = stubPrisma(
      [
        // ★予定の id は自動採番で 0 にならないが、仮に 0 の予定があっても予定外のレポートを引き当てないこと★
        timetable(0, 1, "2026-09-01T02:00:00+09:00"),
        timetable(2, 1, "2026-09-02T02:00:00+09:00"),
      ],
      [taskReport(0, 95), taskReport(2, 80)]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);

    const inIds = calls.cleanTaskReport.flatMap(
      (call) => (call as { where: { clean_timetable_id: { in: bigint[] } } }).where.clean_timetable_id.in
    );
    expect(inIds).toEqual([BigInt(2)]);
    // ★予定外（0）のレポートの 95% は、どの予定の完了率にもならない★
    expect(rows.map((r) => [r.id, r.completion_rate])).toEqual([
      [0, null],
      [2, 80],
    ]);
  });

  it("Q3b ★NULL のレポートは `latestReportByTimetableId()`（担当C）でも落ちる★（Map のキーにできない）", () => {
    const map = latestReportByTimetableId([
      taskReport(null, 10),
      taskReport(null, 20),
      taskReport(1, 30),
    ]);
    // ★★NULL 同士を1つのグループにまとめないこと★★（まとめると `null` キーで引けてしまう）
    expect(map.size).toBe(1);
    expect(map.get("1")?.clean_ratio).toBe(30);
  });

  it("Q5 ★`ended_at` が NULL の行は `started_at` を順序キーにする★（指示書AQ §1-4）", async () => {
    const { prisma } = stubPrisma(
      [timetable(1, 1, "2026-09-01T02:00:00+09:00")],
      [
        // ★`ended_at` あり（03:00）★
        taskReport(1, 10, "2026-09-01T03:00:00+09:00", 10, 100, "2026-09-01T02:30:00+09:00"),
        // ★`ended_at` なし → `started_at`（05:00）が順序キー★ → こちらが最新
        taskReport(1, 90, null, 90, 100, "2026-09-01T05:00:00+09:00"),
      ]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    // ★`ended_at` だけを見て NULL 行を落とすと 10 になる★
    expect(rows[0].completion_rate).toBe(90);
  });

  it("Q6 ★両方の `ended_at` が NULL なら `started_at` が新しいほう★", async () => {
    const { prisma } = stubPrisma(
      [timetable(1, 1, "2026-09-01T02:00:00+09:00")],
      [
        taskReport(1, 10, null, 10, 100, "2026-09-01T03:00:00+09:00"),
        taskReport(1, 90, null, 90, 100, "2026-09-01T05:00:00+09:00"),
        taskReport(1, 40, null, 40, 100, "2026-09-01T04:00:00+09:00"),
      ]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    expect(rows[0].completion_rate).toBe(90);
  });

  it("Q7 ★順序キーが同着なら入力順の先頭★（`clean_status_histories` 時代と同じ規則）", () => {
    const map = latestReportByTimetableId([
      taskReport(1, 11, "2026-09-01T03:00:00+09:00"),
      taskReport(1, 22, "2026-09-01T03:00:00+09:00"),
    ]);
    expect(map.get("1")?.clean_ratio).toBe(11);
  });

  // ★★順序キーそのものの単体テストはここに書かない★★ — 担当C が
  //   `reportOrderKey()` を ★意図的に export していない★（規則を1箇所に閉じるため。
  //   `lib/db/clean-log-mapping.ts`）。★このファイルは Q5 / Q6 で**挙動として**固定する★。

  it("Q1 / Q2 / Q8 ★未実施はレポートの行が無いことだけで決まる★（`completionRateFromReport()`）", () => {
    // ★★`status`（3/4/5/6）で分岐しないこと★★ — 判定には使わない（D-155 項2 / 指示書AS D12）
    expect(completionRateFromReport(undefined)).toBeNull();
    // ★完了率は面積から（表1。`clean_ratio` 列は読まない）★
    expect(completionRateFromReport({ clean_area: 50, task_area: 100, clean_ratio: 50 })).toBe(50);
    // ★「0%で実施した」を `null` に化けさせない★
    expect(completionRateFromReport({ clean_area: 0, task_area: 100, clean_ratio: 0 })).toBe(0);
    // ★平均側では丸めない★（`CLAUDE.md` §4.3。89.5 は ○ のまま）
    expect(completionRateFromReport({ clean_area: 89.5, task_area: 100, clean_ratio: 89.5 })).toBe(
      89.5
    );
  });

  // ★★表5（指示書AS §4。【未決1】案A。D-156）— `departed` の詰め方★★
  //   | #  | 清掃                       | departed | completion_rate |
  //   | U1 | レポートの行が無い          | false    | null            |
  //   | U2 | ★行がある・task_area = 0★ | ★true★  | ★0.0★（P-5=B）  |
  //   | U3 | 行がある・50/100            | true     | 50.0            |
  //   | U4 | 行がある・100/100           | true     | 100.0           |
  it("表5 U1〜U4: `departed` はレポートの行の有無で詰める（完了率 null とは別）", () => {
    const now = new Date("2026-09-10T03:00:00+09:00");
    const rows = consecutiveInputsFromRows(
      [
        timetable(1, 1, "2026-09-01T02:00:00+09:00"), // U1
        timetable(2, 1, "2026-09-02T02:00:00+09:00"), // U2
        timetable(3, 1, "2026-09-03T02:00:00+09:00"), // U3
        timetable(4, 1, "2026-09-04T02:00:00+09:00"), // U4
      ],
      [taskReport(2, 0, undefined, 10, 0), taskReport(3, 50), taskReport(4, 100)],
      now,
      []
    );
    expect(rows.map((r) => [r.id, r.departed, r.completion_rate])).toEqual([
      [1, false, null],
      [2, true, 0],
      [3, true, 50],
      [4, true, 100],
    ]);
  });
});

describe("段階0（refactor 前）のローカルDB実測値と一致すること", () => {
  it("#1 本清掃の行だけを全件返す（施設9 の実測62行。全体は 538 件）", async () => {
    const { timetables, reports } = facility9Rows();
    // ★事前チェック行と未来の行を混ぜても件数が変わらないこと★
    const withNoise = [
      ...timetables,
      timetable(90001, 9, "2026-09-13T01:30:00+09:00", 60),
      timetable(90002, 9, "2026-10-01T02:30:00+09:00"),
    ];
    const { prisma } = stubPrisma(withNoise, reports);
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);

    expect(rows).toHaveLength(LOCAL_DB_FACILITY_9.length);
    expect(rows).toHaveLength(62);
    // ★段階0 実測の全件数（`db.cleanLogs()` = 538件）はこの施設9の62件を含む★
    expect(LOCAL_DB_TOTAL_MAIN_ROWS).toBeGreaterThan(rows.length);
  });

  it("#2 `computeConsecutiveProblemDays()` の結果が段階0 のスナップショットと完全一致する", async () => {
    const { timetables, reports } = facility9Rows();
    const { prisma } = stubPrisma(timetables, reports);
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    const consecutiveById = computeConsecutiveProblemDays(rows);

    const actual = [...consecutiveById.entries()].sort((a, b) => a[0] - b[0]);
    const expected = LOCAL_DB_FACILITY_9.map(
      ([id, , , days]): [number, number] => [id, days]
    ).sort((a, b) => a[0] - b[0]);
    expect(actual).toEqual(expected);
  });

  it("#3 `facilityPerformance()` の `consecutive_problem_days` が段階0 と一致する", async () => {
    const { timetables, reports } = facility9Rows();
    const { prisma } = stubPrisma(timetables, reports);
    const entries = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(entries);

    for (const expected of LOCAL_DB_FACILITY_PERFORMANCE_9) {
      const range = periodRange(
        expected.period,
        expected.year,
        expected.month,
        expected.week
      );
      expect(range).not.toBeNull();
      const days = consecutiveProblemDaysAtPeriodEnd(
        entries,
        consecutiveByFacilityDate,
        9,
        range as { from: string; to: string }
      );
      expect({ ...expected, consecutive_problem_days: days }).toEqual(expected);
    }
  });

  it("#4 `continuousProblems()` の行が段階0 と一致する（行が出ない期間も含む）", async () => {
    const { timetables, reports } = facility9Rows();
    const { prisma } = stubPrisma(timetables, reports);
    const entries = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(entries);
    const facilityNameById = new Map<number, string>([[9, FACILITY_9_NAME]]);

    for (const expected of LOCAL_DB_CONTINUOUS_PROBLEMS_9) {
      const range = periodRange(
        expected.period,
        expected.year,
        expected.month,
        expected.week
      );
      expect(range).not.toBeNull();
      const items = continuousProblemsInPeriod(
        entries,
        consecutiveByFacilityDate,
        facilityNameById,
        range as { from: string; to: string }
      );
      expect(items).toEqual(expected.item === null ? [] : [expected.item]);
    }
  });
});

describe("合成データ（段階1 期待値表 #5 / #6）", () => {
  it("#5 同一施設・2台 — 1日目にA、2日目にBが問題なら★2日連続★（D-109 A案）", async () => {
    // 施設1 に清掃エリアが2つ（ロボットA: id 1,2 / ロボットB: id 11,12）
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, "2026-09-01T01:00:00+09:00"), // A: 問題あり
        timetable(11, 1, "2026-09-01T02:00:00+09:00"), // B: 正常
        timetable(2, 1, "2026-09-02T01:00:00+09:00"), // A: 正常
        timetable(12, 1, "2026-09-02T02:00:00+09:00"), // B: 問題あり
      ],
      [taskReport(1, 30), taskReport(11, 95), taskReport(2, 95), taskReport(12, 30)]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    const map = computeConsecutiveProblemDays(rows);

    expect(map.get(1)).toBe(1);
    expect(map.get(11)).toBe(1); // ★同じ日の全行が同じ値になる★
    expect(map.get(2)).toBe(2);
    expect(map.get(12)).toBe(2);
  });

  it("#6 清掃予定のない日を挟んでも連続が途切れない（D-026。「連続」はスケジュール存在日の連続）", async () => {
    const { prisma } = stubPrisma(
      [
        timetable(1, 1, "2026-09-01T02:00:00+09:00"),
        // 9/2・9/3 は清掃予定なし（行そのものが無い）
        timetable(2, 1, "2026-09-04T02:00:00+09:00"),
        timetable(3, 1, "2026-09-05T02:00:00+09:00"),
      ],
      [taskReport(1, 30), taskReport(2, 30), taskReport(3, 30)]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
    const map = computeConsecutiveProblemDays(rows);

    expect(map.get(1)).toBe(1);
    expect(map.get(2)).toBe(2);
    expect(map.get(3)).toBe(3);
  });
});

// ---------------------------------------------------------------- 段階6（遡り範囲を有界にする）

/**
 * 指示書AC ★段階6★ の期待値表（7行）。
 *
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | 1 | 連続が上限未満 | 段階5（全期間読み）と同じ数字 |
 * | 2 | 連続が上限を超える | 上限で頭打ち。かつ「以上」と分かる表示（`capped: true`） |
 * | 3 | 清掃予定のない日を挟む | 連続が途切れない（D-026。★カレンダー日で遡ると壊れるところ★） |
 * | 4 | `/detail-log` と `/reports` | 同じ施設・同じ日で同じ数字（D-109 の再発防止） |
 * | 5 | 管理画面で上限を大きくした | クエリの遡り範囲も追随して広がる |
 * | 6 | 管理画面で上限を小さくした | 表示も遡り範囲も小さいほうに追随する |
 * | 7 | 上限を超える連続日数のデータ | `capped=true` が実際に出る（＝「上限 +1日ぶん」読めていることの検出） |
 *
 * ★★#7 が無いと、`+1` を忘れても #1〜#6 は全部緑のままです★★
 * （`rawDays` が上限を超えられなくなるだけで、他の行はどれも落ちない）。
 */

/** `YYYY-MM-DD` に日数を足す（★実行環境のタイムゾーンに依存しない UTC 演算★） */
function jstDatePlus(date: string, days: number): string {
  return new Date(new Date(`${date}T00:00:00Z`).getTime() + days * 86_400_000)
    .toISOString()
    .slice(0, 10);
}

/**
 * 「問題あり（完了率 5% ＝ ×）」の清掃を `days` 日ぶん作る。
 * @param stepDays 清掃予定日の間隔（★1 なら毎日、3 なら2日おきに予定が無い★。D-026 の検証用）
 */
function problemRun(
  facilityId: number,
  lastDate: string,
  days: number,
  startId: number,
  stepDays = 1
): {
  timetables: ConsecutiveTimetableRow[];
  reports: ConsecutiveTaskReportRow[];
  lastId: number;
} {
  const timetables: ConsecutiveTimetableRow[] = [];
  const reports: ConsecutiveTaskReportRow[] = [];
  for (let i = 0; i < days; i++) {
    const date = jstDatePlus(lastDate, -(days - 1 - i) * stepDays);
    const id = startId + i;
    timetables.push(timetable(id, facilityId, `${date}T02:00:00+09:00`));
    reports.push(taskReport(id, 5, `${date}T03:00:00+09:00`));
  }
  return { timetables, reports, lastId: startId + days - 1 };
}

/** その範囲で読んだときの、指定ログの ★丸める前の★ 連続日数と、実際の遡り下限 */
async function rawConsecutiveDays(
  rows: { timetables: ConsecutiveTimetableRow[]; reports: ConsecutiveTaskReportRow[] },
  range: ConsecutiveDbRange,
  logId: number
): Promise<{ days: number; gte: Date | undefined }> {
  const { prisma, calls } = stubPrisma(rows.timetables, rows.reports);
  const entries = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, range);
  const map = computeConsecutiveProblemDays(entries);
  const args = calls.cleanTimetable[0] as { where?: { start_at?: { gte?: Date } } };
  return { days: map.get(logId) ?? 0, gte: args.where?.start_at?.gte };
}

/** 遡り下限（`gte`）として期待される「DBの壁時計値」 */
function expectedGte(dateFrom: string, maxDays: number): Date {
  const from = consecutiveLookbackFrom(dateFrom, maxDays);
  expect(from).not.toBeNull();
  return dbWallClock(`${from as string}T00:00:00+09:00`);
}

describe("consecutiveLookbackFrom / consecutiveLookbackCalendarDays（純粋関数）", () => {
  it("★下限なし（`null`）はそのまま `null`★ — 全期間を読む（単一ID取得の経路）", () => {
    expect(consecutiveLookbackFrom(null, CONSECUTIVE_DAYS_DISPLAY.defaultMax)).toBeNull();
    // 形式が不正な文字列も「指定なし」（検証は `parseCleanLogQuery()` の仕事）
    expect(consecutiveLookbackFrom("2026/09/13", 30)).toBeNull();
  });

  it("★★遡り幅は必ず「上限 +1日ぶん」以上★★（#7 の検出条件。★ここを割ると `capped` が永久に false★）", () => {
    for (const maxDays of [
      CONSECUTIVE_DAYS_DISPLAY.minMax,
      CONSECUTIVE_DAYS_DISPLAY.defaultMax,
      CONSECUTIVE_DAYS_DISPLAY.maxMax,
    ]) {
      expect(consecutiveLookbackCalendarDays(maxDays)).toBeGreaterThanOrEqual(maxDays + 1);
    }
    // ★`plusDays` を 0 にしないこと★（設定値そのものを固定しておく）
    expect(CONSECUTIVE_LOOKBACK.plusDays).toBeGreaterThanOrEqual(1);
    expect(CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay).toBeGreaterThanOrEqual(1);
  });

  it("★カレンダー日で数え、スケジュール存在日より**広く**取る★（D-026 の安全側）", () => {
    // 上限30日 → (30 + 1) × 7 ＝ 217 カレンダー日ぶん遡る
    expect(consecutiveLookbackCalendarDays(30)).toBe(217);
    expect(consecutiveLookbackFrom("2026-09-13", 30)).toBe(jstDatePlus("2026-09-13", -217));
  });
});

describe("段階6 — 連続日数のクエリを有界にする", () => {
  it("#1 連続が上限未満なら、段階5（全期間読み）と同じ数字になる", async () => {
    useSavedConsecutiveDaysMax(undefined); // 既定値 30
    const rows = problemRun(1, "2026-09-13", 5, 10_000);

    const bounded = await rawConsecutiveDays(
      rows,
      { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
      rows.lastId
    );
    const unbounded = await rawConsecutiveDays(
      rows,
      CONSECUTIVE_RANGE_UNBOUNDED,
      rows.lastId
    );

    expect(bounded.days).toBe(5);
    expect(bounded.days).toBe(unbounded.days);
    expect(
      capConsecutiveDays(bounded.days, CONSECUTIVE_DAYS_DISPLAY.defaultMax).capped
    ).toBe(false);
  });

  it("#2 連続が上限を超えたら上限で頭打ちになり、「以上」と分かる表示になる", async () => {
    const maxDays = 3;
    useSavedConsecutiveDaysMax(maxDays);
    const rows = problemRun(1, "2026-09-13", 10, 20_000);

    const { days } = await rawConsecutiveDays(
      rows,
      { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
      rows.lastId
    );
    expect(days).toBe(10); // ★判定に使う生値は頭打ちにしない（CLAUDE.md §4.3）★

    expect(capConsecutiveDays(days, maxDays)).toEqual({ days: 3, capped: true });
  });

  it("#3 ★清掃予定のない日を挟んでも連続が途切れない★（D-026。カレンダー日で遡ると壊れるところ）", async () => {
    const maxDays = 3;
    useSavedConsecutiveDaysMax(maxDays);
    // 3日おきの清掃予定を4回 → 9/04・9/07・9/10・9/13（★スケジュール存在日では4日連続★）
    const rows = problemRun(1, "2026-09-13", 4, 30_000, 3);

    const { days, gte } = await rawConsecutiveDays(
      rows,
      { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
      rows.lastId
    );

    // ★★「上限＋1」をカレンダー日として遡る実装（9/09 まで）だと 2 になる★★
    expect(days).toBe(4);
    // 実際の遡り下限は (3+1)×7 ＝ 28 カレンダー日前（＝ 8/16）
    expect(gte).toEqual(expectedGte("2026-09-13", maxDays));
  });

  it("#4 `/detail-log`（1日）と `/reports`（週・月）で同じ施設・同じ日の数字が一致する", async () => {
    useSavedConsecutiveDaysMax(undefined);
    const rows = problemRun(1, "2026-09-13", 8, 40_000);

    // `/detail-log` … `cleanLogs()` は画面の日付範囲をそのまま渡す
    const detailLog = await rawConsecutiveDays(
      rows,
      { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
      rows.lastId
    );
    // `/reports` … `continuousProblems()` / `facilityPerformance()` は `periodRange()` の範囲
    const weekly = periodRange("weekly", 2026, 9, 3) as { from: string; to: string };
    const monthly = periodRange("monthly", 2026, 9, 0) as { from: string; to: string };
    const reportsWeekly = await rawConsecutiveDays(
      rows,
      { date_from: weekly.from, date_to: weekly.to, facility_ids: null },
      rows.lastId
    );
    const reportsMonthly = await rawConsecutiveDays(
      rows,
      { date_from: monthly.from, date_to: monthly.to, facility_ids: null },
      rows.lastId
    );

    expect(detailLog.days).toBe(8);
    expect(reportsWeekly.days).toBe(8);
    expect(reportsMonthly.days).toBe(8);
  });

  it("#4b 上限を超える連続でも、3経路とも同じ「{上限}日以上」になる（D-109 の再発防止）", async () => {
    const maxDays = 3;
    useSavedConsecutiveDaysMax(maxDays);
    const rows = problemRun(1, "2026-09-13", 60, 45_000);

    const weekly = periodRange("weekly", 2026, 9, 3) as { from: string; to: string };
    const shown = [];
    for (const range of [
      { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
      { date_from: weekly.from, date_to: weekly.to, facility_ids: null },
      CONSECUTIVE_RANGE_UNBOUNDED,
    ]) {
      const { days } = await rawConsecutiveDays(rows, range, rows.lastId);
      shown.push(capConsecutiveDays(days, maxDays));
    }
    // ★生値は経路ごとに違いうる（読める範囲が違う）が、★★画面に出る値は一致する★★
    expect(shown).toEqual([
      { days: 3, capped: true },
      { days: 3, capped: true },
      { days: 3, capped: true },
    ]);
  });

  it("#5 ★管理画面で上限を大きくすると、クエリの遡り範囲も追随して広がる★", async () => {
    const rows = problemRun(1, "2026-09-13", 100, 50_000);
    const range: ConsecutiveDbRange = { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null };

    useSavedConsecutiveDaysMax(3);
    const small = await rawConsecutiveDays(rows, range, rows.lastId);
    useSavedConsecutiveDaysMax(90);
    const large = await rawConsecutiveDays(rows, range, rows.lastId);

    // ★遡り下限が実際に古くなっていること★
    expect(small.gte).toEqual(expectedGte("2026-09-13", 3));
    expect(large.gte).toEqual(expectedGte("2026-09-13", 90));
    expect(large.gte?.getTime()).toBeLessThan(small.gte?.getTime() ?? 0);

    // ★★上限を上げたぶん、実際に読めた行数（＝生値）も増えること★★ —
    //   ★設定だけ大きくしてクエリが古い範囲のままだと、ここが変わらない（静かな不整合。申し送り⑪）★
    expect(small.days).toBe(consecutiveLookbackCalendarDays(3) + 1); // 28日前〜当日 ＝ 29日
    expect(large.days).toBe(100); // 637日ぶん読めるので全部入る
    expect(capConsecutiveDays(large.days, 90)).toEqual({ days: 90, capped: true });
  });

  it("#6 ★管理画面で上限を小さくすると、表示も遡り範囲も小さいほうに追随する★", async () => {
    const rows = problemRun(1, "2026-09-13", 100, 60_000);
    const range: ConsecutiveDbRange = { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null };

    useSavedConsecutiveDaysMax(CONSECUTIVE_DAYS_DISPLAY.defaultMax);
    const before = await rawConsecutiveDays(rows, range, rows.lastId);
    useSavedConsecutiveDaysMax(CONSECUTIVE_DAYS_DISPLAY.minMax);
    const after = await rawConsecutiveDays(rows, range, rows.lastId);

    expect(after.gte?.getTime()).toBeGreaterThan(before.gte?.getTime() ?? 0);
    expect(after.days).toBeLessThan(before.days);
    expect(capConsecutiveDays(after.days, CONSECUTIVE_DAYS_DISPLAY.minMax)).toEqual({
      days: CONSECUTIVE_DAYS_DISPLAY.minMax,
      capped: true,
    });
  });

  it("#7 ★★上限をちょうど1日超える連続で `capped: true` が出る★★（＝「上限 +1日ぶん」読めている証拠）", async () => {
    const maxDays = CONSECUTIVE_DAYS_DISPLAY.minMax; // 3
    useSavedConsecutiveDaysMax(maxDays);
    // ★ちょうど「上限 +1日」ぶんだけ問題が続く★
    const rows = problemRun(1, "2026-09-13", maxDays + 1, 70_000);

    const { days } = await rawConsecutiveDays(
      rows,
      { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
      rows.lastId
    );

    // ★★上限ぴったりまでしか読まない実装だと `days === maxDays` になり、下の `capped` が false に★★
    expect(days).toBe(maxDays + 1);
    expect(capConsecutiveDays(days, maxDays).capped).toBe(true);
  });
});

// ★表10 N1〜N3（指示書BB A6・D16・D-182）★ — `facility_ids` の `null`（絞らない）と `[]`（0行）を取り違えない（罠1）
describe("loadConsecutiveInputs — 施設で絞る（指示書BB 表10 N1〜N3）", () => {
  function timetableWheres(calls: Record<string, unknown[]>) {
    return [...calls.cleanTimetable, ...calls.cleanTimetableHistory].map(
      (c) => (c as { where: Record<string, unknown> }).where
    );
  }

  it("N1: `facility_ids: null` → 予定の `where` に `facility_id` の条件が無い（今と同じ SQL）", async () => {
    const { prisma, calls } = stubPrisma([timetable(1, 1, "2026-09-01T02:00:00+09:00")], []);
    await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, { date_from: null, date_to: null, facility_ids: null });
    const wheres = timetableWheres(calls);
    expect(wheres).toHaveLength(2);
    for (const where of wheres) expect(where).not.toHaveProperty("facility_id");
  });

  it("N2: `facility_ids: [1, 3]` → 2表とも `facility_id: { in: [1n, 3n] }`", async () => {
    const { prisma, calls } = stubPrisma([timetable(1, 1, "2026-09-01T02:00:00+09:00")], []);
    await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, { date_from: null, date_to: null, facility_ids: [1, 3] });
    const wheres = timetableWheres(calls);
    expect(wheres).toHaveLength(2);
    for (const where of wheres) expect(where.facility_id).toEqual({ in: [BigInt(1), BigInt(3)] });
  });

  it("★N3: `facility_ids: []` → `facility_id: { in: [] }`（`null` と取り違えない。罠1）★", async () => {
    const { prisma, calls } = stubPrisma([timetable(1, 1, "2026-09-01T02:00:00+09:00")], []);
    await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, { date_from: null, date_to: null, facility_ids: [] });
    for (const where of timetableWheres(calls)) expect(where.facility_id).toEqual({ in: [] });
  });
});

// ══════════════════════════════════════════════════════════════════════════════
// ★★指示書BG §2-3 — 予定外タスクも連続日数・要レスキュー・連続問題の入力に入れる★★
// ══════════════════════════════════════════════════════════════════════════════
/**
 * 前提（指示書BG §3）: 今 = 2026-10-29 10:00 JST、施設 F1（id 1）。
 *
 * ★表U4（監督が書いた。数字を変えない）★
 * | # | 施設 F1 の日ごとの結果 | 期待 |
 * |---|---|---|
 * | U4-1 | 10-26 予定 ×、10-27 予定外だけ（完了率 50）、10-28 予定 △ | 3日連続 → L3 |
 * | U4-2 | 10-27 に予定 ◎ と 予定外 △ | その日は問題日（OR） |
 * | U4-3 | 10-27 予定外だけ（完了率 95） | 問題日ではない → 連続が切れる |
 *
 * ★表U1 のうち連続日数の入力にかかるもの★（U1-2 / U1-3 / U1-4 / U1-5）も下で固める。
 */
describe("指示書BG §2-3 — 予定外タスクを連続日数の入力に入れる", () => {
  const NOW = new Date("2026-10-29T10:00:00+09:00");
  const F1 = 1;

  /** 予定の行とそのレポート（完了率 `rate`。`null` はレポートなし＝未実施） */
  function scheduled(id: number, jstIso: string, rate: number | null) {
    return {
      timetable: timetable(id, F1, jstIso),
      reports: rate === null ? [] : [taskReport(id, rate, null, rate, 100, jstIso)],
    };
  }

  function inputs(
    runs: { timetable: ConsecutiveTimetableRow; reports: ConsecutiveTaskReportRow[] }[],
    unscheduled: ConsecutiveUnscheduledReportRow[]
  ): ConsecutiveInput[] {
    return consecutiveInputsFromRows(
      runs.map((r) => r.timetable),
      runs.flatMap((r) => r.reports),
      NOW,
      unscheduled
    );
  }

  /** その施設のその日の連続日数（施設 × 日付で引く。予定外の行も同じ値） */
  function daysOn(rows: readonly ConsecutiveInput[], date: string): number | undefined {
    return consecutiveProblemDaysByFacilityDate(rows).get(facilityDateKey(F1, date));
  }

  it("U4-1 10-26 予定 ×、10-27 予定外だけ（50）、10-28 予定 △ → 3日連続 → L3", () => {
    const rows = inputs(
      [scheduled(1, "2026-10-26T21:00:00+09:00", 5), scheduled(3, "2026-10-28T21:00:00+09:00", 40)],
      [unscheduledReport(F1, "2026-10-27T21:00:00+09:00", 50)]
    );
    expect(daysOn(rows, "2026-10-28")).toBe(3);
    expect(judgeEscalationLevel(daysOn(rows, "2026-10-28") ?? 0)).toBe("L3");
    // ★予定のある行は id の Map でも同じ値★（`computeConsecutiveProblemDays()`。予定外の行は id が無いので載らない）
    const byId = computeConsecutiveProblemDays(rows);
    expect(byId.get(3)).toBe(3);
    expect([...byId.keys()].sort()).toEqual([1, 3]);
    // ★予定外の行の日（10-27）も連続2日目★
    expect(daysOn(rows, "2026-10-27")).toBe(2);
  });

  it("U4-2 10-27 に予定 ◎ と予定外 △ → その日は問題日（OR）", () => {
    const rows = inputs(
      [scheduled(2, "2026-10-27T21:00:00+09:00", 95)],
      [unscheduledReport(F1, "2026-10-27T23:00:00+09:00", 40)]
    );
    expect(foldProblemDaysByDate(rows)).toEqual([{ date: "2026-10-27", isProblem: true }]);
    expect(daysOn(rows, "2026-10-27")).toBe(1);
  });

  it("U4-3 10-27 予定外だけ（95）→ 問題日ではない → 連続が切れる", () => {
    const rows = inputs(
      [scheduled(1, "2026-10-26T21:00:00+09:00", 5), scheduled(3, "2026-10-28T21:00:00+09:00", 40)],
      [unscheduledReport(F1, "2026-10-27T21:00:00+09:00", 95)]
    );
    expect(foldProblemDaysByDate(rows).map((d) => [d.date, d.isProblem])).toEqual([
      ["2026-10-26", true],
      ["2026-10-27", false],
      ["2026-10-28", true],
    ]);
    expect(daysOn(rows, "2026-10-28")).toBe(1);
    expect(judgeEscalationLevel(1)).toBe("L1");
  });

  it("U1-2 / U1-4 予定外の `clean_area` 0・`task_area` 0 → 完了率 0・出発済み（未実施ではない）", () => {
    const rows = inputs(
      [],
      [
        unscheduledReport(F1, "2026-10-27T21:00:00+09:00", 0, 100),
        unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 10, 0),
      ]
    );
    expect(rows.map((r) => [r.id, r.unscheduled, r.departed, r.completion_rate])).toEqual([
      [null, true, true, 0],
      [null, true, true, 0],
    ]);
  });

  it("U1-3 `clean_timetable_id` が 0 と NULL → どちらも予定外として同じ扱い", () => {
    const zero = inputs([], [unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 50, 100, 0)]);
    const nul = inputs([], [unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 50, 100, null)]);
    expect(zero).toEqual(nul);
    expect(zero[0]).toEqual({
      id: null,
      unscheduled: true,
      facility_id: F1,
      // ★日付は `started_at`（実際に始めた時刻）★
      scheduled_at: "2026-10-28T21:00:00+09:00",
      completion_rate: 50,
      departed: true,
      clean_area: 50,
      task_area: 100,
    });
  });

  it("予定の id を持つ行が予定外として渡されても入れない（SQL の写しが漏れても二重に数えない）", () => {
    const leaked: ConsecutiveUnscheduledReportRow = {
      ...unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 50),
      clean_timetable_id: BigInt(7),
    };
    expect(inputs([], [leaked])).toEqual([]);
  });

  it("予定外の行は結果待ちにならない（始めたのが今の直前でも入る。予定外は結果が届いている）", () => {
    const rows = inputs([], [unscheduledReport(F1, "2026-10-29T09:30:00+09:00", 80)]);
    expect(rows).toHaveLength(1);
  });

  it("U1-5 loadConsecutiveInputs: 3本目で予定外を読む（`OR`（NULL・0）・削除済みの除外・期間は `started_at`・施設の絞り込み）", async () => {
    const { prisma, calls } = stubPrisma(
      [timetable(1, F1, "2026-10-28T21:00:00+09:00")],
      [taskReport(1, 90)],
      [],
      [
        unscheduledReport(F1, "2026-10-28T23:00:00+09:00", 40),
        // ★他の施設（絞り込みで外れる）★
        unscheduledReport(2, "2026-10-28T23:00:00+09:00", 40),
      ]
    );
    const rows = await loadConsecutiveInputs(prisma, NOW, {
      date_from: "2026-10-28",
      date_to: "2026-10-28",
      facility_ids: [F1],
    });
    expect(calls.cleanTaskReportUnscheduled).toHaveLength(1);
    const args = calls.cleanTaskReportUnscheduled[0] as {
      where: ConsecutiveUnscheduledReportWhere;
      select: Record<string, true>;
    };
    expect(args.where.OR).toEqual([{ clean_timetable_id: null }, { clean_timetable_id: BigInt(0) }]);
    // ★削除済みの施設・清掃エリアは外す（表U1 U1-5）★
    expect(args.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    expect(args.where.facility_id).toEqual({ in: [BigInt(F1)] });
    // ★期間は予定の行と同じ遡り範囲（`start_at` と同じ値）を `started_at` に当てる★
    const timetableWhere = (calls.cleanTimetable[0] as { where: { start_at: unknown } }).where;
    expect(args.where.started_at).toEqual(timetableWhere.start_at);
    // ★列は2本目と同じ6列 ＋ `facility_id`★（`include` は付けない）
    expect(Object.keys(args).sort()).toEqual(["select", "where"]);
    expect(Object.keys(args.select).sort()).toEqual(
      ["clean_area", "clean_ratio", "clean_timetable_id", "ended_at", "facility_id", "started_at", "task_area"].sort()
    );
    // ★施設 F1 の予定1件 ＋ 予定外1件★（施設2 の予定外は絞り込みで外れる）
    expect(rows.map((r) => [r.id, r.unscheduled, r.facility_id])).toEqual([
      [1, false, F1],
      [null, true, F1],
    ]);
  });

  it("loadConsecutiveInputs: 予定が0件でも予定外は読む（予定外だけの施設も連続日数を持つ）", async () => {
    const { prisma, calls } = stubPrisma([], [], [], [unscheduledReport(F1, "2026-10-28T23:00:00+09:00", 40)]);
    const rows = await loadConsecutiveInputs(prisma, NOW, CONSECUTIVE_RANGE_UNBOUNDED);
    // ★2本目（予定の id で引く）は撃たない★
    expect(calls.cleanTaskReport).toEqual([]);
    expect(rows.map((r) => [r.id, r.unscheduled])).toEqual([[null, true]]);
  });
});

/**
 * ★指示書BG §2-3 — 要レスキュー★（`buildRescueRequired()` → `judgeRescue()`。判定は変えない）。
 * - R-1（未実施3日連続）: 予定外は未実施にならない
 * - R-2（L3）: 予定外だけの日も連続を作る（表U4 U4-1）
 * - R-3（急落）: 予定外もその日の完了率の平均に入る
 */
describe("指示書BG §2-3 — 要レスキューに予定外タスクを入れる", () => {
  const NOW = new Date("2026-10-29T10:00:00+09:00");
  const NAMES = [{ id: 1, facility_name: "F1" }];

  function rescue(
    timetables: ConsecutiveTimetableRow[],
    reports: ConsecutiveTaskReportRow[],
    unscheduled: ConsecutiveUnscheduledReportRow[]
  ) {
    return buildRescueRequired(consecutiveInputsFromRows(timetables, reports, NOW, unscheduled), NAMES, "2026-10-29");
  }

  it("R-1: 未実施の日に予定外が走っても未実施の日のまま（予定外は未実施を打ち消さない）→ 3日連続で要レスキュー", () => {
    const items = rescue(
      [
        timetable(1, 1, "2026-10-26T21:00:00+09:00"),
        timetable(2, 1, "2026-10-27T21:00:00+09:00"),
        timetable(3, 1, "2026-10-28T21:00:00+09:00"),
      ],
      [],
      [unscheduledReport(1, "2026-10-27T23:00:00+09:00", 95)]
    );
    expect(items).toHaveLength(1);
    expect(items[0].reasons).toContain("not_executed_streak");
    expect(items[0].not_executed_days).toBe(3);
  });

  it("R-1: 予定外だけの日は「未実施でない日」として未実施の連続を切る（予定外は未実施にならない）", () => {
    const items = rescue(
      [timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(3, 1, "2026-10-28T21:00:00+09:00")],
      [],
      [unscheduledReport(1, "2026-10-27T21:00:00+09:00", 95)]
    );
    // ★10-26 と 10-28 の未実施は問題日だが、10-27（予定外 95）で連続が切れる → L1・未実施1日 → 要レスキューでない★
    expect(items).toEqual([]);
  });

  it("R-2: 表U4 U4-1 の施設は L3 で要レスキュー（予定外だけの日が連続を作る）", () => {
    const items = rescue(
      [timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(3, 1, "2026-10-28T21:00:00+09:00")],
      [
        taskReport(1, 5, null, 5, 100, "2026-10-26T21:00:00+09:00"),
        taskReport(3, 40, null, 40, 100, "2026-10-28T21:00:00+09:00"),
      ],
      [unscheduledReport(1, "2026-10-27T21:00:00+09:00", 50)]
    );
    expect(items).toHaveLength(1);
    expect(items[0].consecutive_problem_days).toBe(3);
    expect(items[0].reasons).toContain("escalation_l3");
  });

  it("R-3: 予定外の完了率もその日の平均に入る（10-27 予定 80 → 10-28 予定 80 ＋ 予定外 20 ＝ 平均 50 → 急落）", () => {
    const items = rescue(
      [timetable(2, 1, "2026-10-27T21:00:00+09:00"), timetable(3, 1, "2026-10-28T21:00:00+09:00")],
      [
        taskReport(2, 80, null, 80, 100, "2026-10-27T21:00:00+09:00"),
        taskReport(3, 80, null, 80, 100, "2026-10-28T21:00:00+09:00"),
      ],
      [unscheduledReport(1, "2026-10-28T23:00:00+09:00", 20)]
    );
    expect(items).toHaveLength(1);
    expect(items[0].reasons).toEqual(["completion_drop"]);
    expect(items[0].previous_completion_rate).toBe(80);
    expect(items[0].completion_rate).toBe(50);
    expect(items[0].evaluated_on).toBe("2026-10-28");
  });

  it("評価日の行が予定外だけでも連続日数を引ける（施設 × 日付で引く）", () => {
    const items = rescue(
      [timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(2, 1, "2026-10-27T21:00:00+09:00")],
      [
        taskReport(1, 5, null, 5, 100, "2026-10-26T21:00:00+09:00"),
        taskReport(2, 5, null, 5, 100, "2026-10-27T21:00:00+09:00"),
      ],
      [unscheduledReport(1, "2026-10-28T21:00:00+09:00", 5)]
    );
    expect(items).toHaveLength(1);
    expect(items[0].evaluated_on).toBe("2026-10-28");
    expect(items[0].consecutive_problem_days).toBe(3);
  });
});

/** ★指示書BG §2-3 — 連続問題施設（レポート）★: 期間内の最終エントリが予定外でも連続日数を引く */
describe("指示書BG §2-3 — 連続問題施設に予定外タスクを入れる", () => {
  it("期間 10-28 の最終エントリ（予定外）から3日連続・L3", () => {
    const now = new Date("2026-10-29T10:00:00+09:00");
    const entries = consecutiveInputsFromRows(
      [timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(2, 1, "2026-10-27T21:00:00+09:00")],
      [taskReport(1, 5, null, 5, 100, "2026-10-26T21:00:00+09:00")],
      now,
      [
        unscheduledReport(1, "2026-10-27T23:00:00+09:00", 50),
        unscheduledReport(1, "2026-10-28T21:00:00+09:00", 40),
      ]
    );
    const items = continuousProblemsInPeriod(
      entries,
      consecutiveProblemDaysByFacilityDate(entries),
      new Map([[1, "F1"]]),
      { from: "2026-10-28", to: "2026-10-28" }
    );
    expect(items).toEqual([
      expect.objectContaining({
        facility_id: 1,
        consecutive_problem_days: 3,
        escalation_level: "L3",
        last_problem_on: "2026-10-28",
        // ★10-27 の予定は未実施（予定外は別の1回）→ 出発失敗の日は1日★
        launch_failed_days: 1,
      }),
    ]);
  });
});
