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

// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
//   最初の1本だけがモジュール読み込みを丸ごと抱え込み、フルスイート実行時に既定の
//   タイムアウトを踏んで不定期に落ちる（`app/api/clean-logs/__tests__/route.test.ts` の実測）。
import { GET } from "@/app/api/prechecks/route";
import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import { filterFixturePrechecks } from "@/lib/db/fixture-prechecks";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { logEvent } from "@/lib/logger";
import {
  filterPrechecksByDate,
  parsePrecheckQuery,
  type PrecheckQuery,
  type PrecheckSummary,
} from "@/lib/prechecks/query";
import type { PrecheckDbQuery, PrecheckItem, SummaryListResponse } from "@/lib/types";

/**
 * 指示書AC ★段階4★ の期待値表（#1〜#5）。
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 * `prechecks()` が返すのは ★事前チェック異常の行（`severity: "precheck"`）だけ★（ユーザー判断 2026-10-29）。
 *   ★期間の基準は**親の本清掃行**の `start_at`★（`PrecheckDbQuery` の型コメント）。
 *   ★#4 で「親が期間外・エラー行が期間内」★ を固定する。
 *   ★出発失敗（レポートの無い過去の本清掃）は行にならない★（旧: `severity: "launch_failed"`）—
 *   「出発失敗はアラートに出ない」の describe と、清掃詳細ログ側（`app/api/clean-logs/__tests__/route.test.ts`）で固定する。
 *
 * ------------------------------------------------------------------------
 * ★★DBには接続しません★★
 * ------------------------------------------------------------------------
 * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
 * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
 * ★`prismaSource` の経路★ を走らせる（段階2 のテストと同じ作法）。
 *
 * ★★合成データを使う理由★★: ローカルDB（`dwatch_local`）では
 *   - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★（C-6）
 *   - ★事前チェック行の親が日をまたぐケース（#4）が1件も無い★
 */

// ---------------------------------------------------------------- スタブ（DBの代わり）

interface StubTimetable {
  id: bigint;
  facility_id: bigint;
  section_id: bigint;
  parent_id: bigint;
  start_at: Date;
  /** ★予定の終了（結果待ちの判定用。D-173）★。既定は開始の1時間後 */
  end_at: Date;
  precheck: number;
  facility: { facility_name: string };
  section: { robots: { serial_number: string }[] };
}

/**
 * `clean_task_reports` の1行（★旧: `clean_status_histories`。指示書AQ 決定2 / 決定3★）。
 * ★`clean_error` 列は無い★（`prisma/schema.prisma` の `CleanTaskReport`）。
 * ★`ended_at` は nullable、`started_at` は NOT NULL★（順序キー。指示書AQ §1-4）。
 */
interface StubReport {
  clean_timetable_id: bigint | null;
  clean_ratio: number;
  /** ★完了率は面積から出す★（`runCompletionRate()`。D-155）。`main_run_succeeded`（D-171）の判定に使う */
  clean_area: number;
  task_area: number;
  started_at: Date;
  ended_at: Date | null;
}

interface StubErrorReport {
  error_id: string;
  clean_timetable_id: bigint | null;
  error_type: string;
  errored_at: Date | null;
  created_at: Date;
  serial_number: string;
  facility_id: bigint;
  /** ★`deleted_at` は可視性の判定用（指示書BB U1。`ROBOT_ERROR_PRECHECK_ROW_SELECT` が読む）★ */
  facility: { facility_name: string; deleted_at: Date | null };
  section: { deleted_at: Date | null };
  status: number;
  responded_at: Date | null;
  resolved_at: Date | null;
  comment: string | null;
  /** ★DDL では NULL 可（第11回 P-8 b）。読まないこと（指示書AU 段階0-4）★ */
  robot_id?: bigint | null;
}

interface StubData {
  timetables: StubTimetable[];
  /** ★`clean_timetable_histories` の行（指示書AU 段階3b）。無ければ空★ */
  historyTimetables?: StubTimetable[];
  reports: StubReport[];
  errorReports: StubErrorReport[];
}

/** `findMany()` に渡された引数の記録（★どんな `where` が発行されたかを検査する★） */
const calls: {
  cleanTimetable: Record<string, unknown>[];
  cleanTimetableHistory: Record<string, unknown>[];
  cleanTaskReport: Record<string, unknown>[];
  robotErrorReport: Record<string, unknown>[];
} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], robotErrorReport: [] };

let data: StubData;

/**
 * ★true のあいだ、エラー行の `where` を無視して全行を返す★（指示書BB 表3 S2 / 表1 V7）—
 *   SQL の写し（`status IN`・可視性）が外れても、純粋関数の側（正）で外れることを確かめる
 */
let ignoreErrorWhere = false;

/** BigInt / number / null をまたいで id を比較する（`===` では比較できない） */
function sameId(a: unknown, b: unknown): boolean {
  if (a === null || a === undefined || b === null || b === undefined) return false;
  return String(a) === String(b);
}

/**
 * Prisma の `where` のうち、★本作業が使う分だけ★ を再現する。
 *
 * ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★（★罠1★）。
 *   ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。
 */
function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
  if (!where) return true;
  for (const [field, cond] of Object.entries(where as Record<string, unknown>)) {
    const value = row[field];
    if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
      const c = cond as Record<string, unknown>;
      if ("in" in c) {
        if (c.in === undefined) {
          throw new Error(
            `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
          );
        }
        if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
      }
      if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
      if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
    } else if (typeof cond === "bigint" || typeof value === "bigint") {
      if (!sameId(value, cond)) return false;
    } else if (value !== cond) {
      return false;
    }
  }
  return true;
}

function project(row: Record<string, unknown>, select: unknown): Record<string, unknown> {
  if (!select) return row;
  const out: Record<string, unknown> = {};
  for (const key of Object.keys(select as Record<string, unknown>)) out[key] = row[key];
  return out;
}

function findMany(
  bucket: keyof typeof calls,
  rows: Record<string, unknown>[],
  args: Record<string, unknown> | undefined
) {
  calls[bucket].push(args ?? {});
  return rows
    .filter((row) => matchesWhere(row, args?.where))
    .map((row) => project(row, args?.select));
}

vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));

// ★`data.anomaly`（指示書BB U2・U3）を見るため `logEvent` だけ差し替える（`withRequestId` はそのまま）★
vi.mock("@/lib/logger", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/logger")>();
  return { ...actual, logEvent: vi.fn() };
});

vi.mock("@/lib/db/prisma", () => ({
  getPrisma: async () => ({
    cleanTimetable: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
    },
    // ★`clean_timetable_histories`（指示書AU 段階3b）。既定は空★
    cleanTimetableHistory: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany(
          "cleanTimetableHistory",
          (data.historyTimetables ?? []) as unknown as Record<string, unknown>[],
          args
        ),
    },
    // ★清掃結果の取得元は `clean_task_reports`★（旧: `cleanStatusHistory`。指示書AQ 決定2 / 決定3）
    cleanTaskReport: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("cleanTaskReport", data.reports as unknown as Record<string, unknown>[], args),
    },
    robotErrorReport: {
      findMany: async (args?: Record<string, unknown>) => {
        // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる（指示書AU 段階0-4）★
        guardNullRobotId(data.errorReports as unknown as Record<string, unknown>[], args);
        if (ignoreErrorWhere) {
          // ★`clean_timetable_id IN` だけ残し、それ以外（`status IN`・可視性）を無視する★
          const where = (args?.where ?? {}) as Record<string, unknown>;
          return findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], {
            ...args,
            where: { clean_timetable_id: where.clean_timetable_id },
          });
        }
        return findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], args);
      },
    },
  }),
}));

// ---------------------------------------------------------------- 合成データ

/** DB に入っているのと同じ「壁時計値としての `Date`」（D-115。他のテストと同じヘルパー） */
function dbWallClock(jstIso: string): Date {
  return new Date(jstIso.replace("+09:00", "Z"));
}

/** ★実在の店名を使わない★（`CLAUDE.md` §7 / `docs/fixtures.md`） */
const FACILITY_1 = "バリューマート北町店";
const FACILITY_2 = "メガバリューマート緑野店";

const [FIRST_ROUND_MINUTES, SECOND_ROUND_MINUTES] = PRECHECK_MINUTES_BEFORE;

function timetable(
  id: number,
  facilityId: number,
  facilityName: string,
  jstIso: string,
  precheck: number = CLEAN_TIMETABLE_PRECHECK.main,
  parentId = 0
): StubTimetable {
  return {
    id: BigInt(id),
    facility_id: BigInt(facilityId),
    section_id: BigInt(facilityId * 10),
    parent_id: BigInt(parentId),
    start_at: dbWallClock(jstIso),
    end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
    precheck,
    facility: { facility_name: facilityName },
    section: { robots: [{ serial_number: `8111R56280600${facilityId}` }] },
  };
}

/**
 * ★レポートの行がある ＝ 実施済み★（指示書AQ §1-1。★行が無いときだけ未実施＝出発失敗★）。
 * ★`status` は持たせない★ — 値の意味は受領済み（第10回 N-2）だが、判定には使わない（D-155 項2。指示書AQ §1-2）。
 */
function report(
  timetableId: number,
  startedJst: string,
  endedJst: string | null,
  // ★既定は 80%（○）★ — 判定が成功になる値（D-171 の `main_run_succeeded`）
  cleanArea = 80,
  taskArea = 100
): StubReport {
  return {
    clean_timetable_id: BigInt(timetableId),
    clean_ratio: 80,
    clean_area: cleanArea,
    task_area: taskArea,
    started_at: dbWallClock(startedJst),
    ended_at: endedJst === null ? null : dbWallClock(endedJst),
  };
}

function errorReport(
  errorId: string,
  timetableId: number,
  facilityId: number,
  facilityName: string,
  erroredAtJst: string
): StubErrorReport {
  return {
    error_id: errorId,
    clean_timetable_id: BigInt(timetableId),
    error_type: "EmergencyStop",
    errored_at: dbWallClock(erroredAtJst),
    created_at: dbWallClock(erroredAtJst),
    serial_number: `8111R56280600${facilityId}`,
    facility_id: BigInt(facilityId),
    facility: { facility_name: facilityName, deleted_at: null },
    section: { deleted_at: null },
    // ★0 = 未解消★（`resolutionStatusFromRobotErrorStatus()`）
    status: 0,
    responded_at: null,
    resolved_at: null,
    comment: null,
  };
}

/**
 * ★★#4 の肝★★: `104` は ★9/11 00:30★ の本清掃で、その事前チェック行 `1041` と
 *   そのエラー行は ★9/10 23:30 / 23:35★（＝ ★日をまたぐ★）。
 *   ★★`errored_at` や事前チェック行の `start_at` で絞ると 9/10 に現れてしまう★★。
 */
function buildData(): StubData {
  return {
    timetables: [
      // --- 本清掃行 ---
      // ★レポートなし（＝出発失敗）★ — ★アラートには出ない★（2026-10-29。清掃詳細ログには出発失敗として出る）
      timetable(101, 1, FACILITY_1, "2026-09-08T02:00:00+09:00"),
      timetable(102, 1, FACILITY_1, "2026-09-09T02:00:00+09:00"), // 正常
      // ★レポートなし（＝出発失敗）★ — アラートには出ない
      timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"),
      timetable(104, 1, FACILITY_1, "2026-09-11T00:30:00+09:00"), // 正常（#4 の親）
      // ★★壁時計 20:00 の清掃★★（C-6。★ローカルDBには1件も無い時間帯★）。レポートなし（出発失敗）だが、事前チェックのエラー E5 の親
      timetable(301, 2, FACILITY_2, "2026-09-10T20:00:00+09:00"),
      // ★未来の予定★（レポートなし）— 旧: `isFutureSchedule()` を外すと出発失敗として並んだ。いまは出発失敗そのものを出さない
      timetable(901, 1, FACILITY_1, "2026-10-05T02:00:00+09:00"),

      // --- 事前チェック行（本清掃の子） ---
      timetable(1021, 1, FACILITY_1, "2026-09-09T01:00:00+09:00", FIRST_ROUND_MINUTES, 102),
      timetable(1022, 1, FACILITY_1, "2026-09-09T01:30:00+09:00", SECOND_ROUND_MINUTES, 102),
      // ★★#4: 親（104）は 9/11、この行は 9/10★★
      timetable(1041, 1, FACILITY_1, "2026-09-10T23:30:00+09:00", FIRST_ROUND_MINUTES, 104),
      // ★壁時計 20:00 の清掃（301）の事前チェック（C-6）★
      timetable(3011, 2, FACILITY_2, "2026-09-10T19:00:00+09:00", FIRST_ROUND_MINUTES, 301),
    ],
    // ★103 の行は置かない★（旧: `clean_error=1` の履歴。`clean_task_reports` には
    //   未実施を表す列が無いので、未実施は「行が無い」でしか表せない。指示書AQ §1-1）
    reports: [
      report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00"),
      // ★`ended_at` が NULL の行★（nullable。指示書AQ §1-4）— ★行がある以上は実施済み★で、
      //   `main_run_succeeded` の材料として読めること
      report(104, "2026-09-11T00:30:00+09:00", null),
    ],
    errorReports: [
      errorReport("E1", 1021, 1, FACILITY_1, "2026-09-09T01:05:00+09:00"),
      // ★★#4: エラー行の時刻は 9/10 だが、親の本清掃は 9/11★★
      errorReport("E2", 1041, 1, FACILITY_1, "2026-09-10T23:35:00+09:00"),
      // ★★本清掃行を指すエラー行★★ — ★拾わない★（D-104）ので
      //   ★1件も出てはいけない★（出発失敗の代わりに本清掃のエラーが出ていないことの検出）
      errorReport("E3", 103, 1, FACILITY_1, "2026-09-10T02:10:00+09:00"),
      // ★壁時計 20:00 の清掃（301）の事前チェックのエラー（C-6）★
      errorReport("E5", 3011, 2, FACILITY_2, "2026-09-10T19:05:00+09:00"),
    ],
  };
}

/** ★9月の行が全部「過去」になる基準時刻★（10/05 の行だけが未来として残る） */
const NOW = new Date("2026-09-30T00:00:00+09:00");

async function get(search: string): Promise<SummaryListResponse<PrecheckItem, PrecheckSummary>> {
  const res = await GET(new Request(`http://localhost/api/prechecks${search}`));
  return (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
}

/**
 * 本清掃行を引くクエリ（＝ `where.precheck` を持つ1本目）。
 * ★2026-10-29 から `include` を持たない★（`select: { id, start_at }`）ので、`where.precheck` で見分ける
 */
function isMainQuery(c: Record<string, unknown>): boolean {
  return "where" in c && "precheck" in (c.where as object);
}

function mainQuery(): Record<string, unknown> {
  const call = calls.cleanTimetable.find(isMainQuery);
  expect(call).toBeDefined();
  return call as Record<string, unknown>;
}

function mainQueryWhere(): Record<string, unknown> {
  return (mainQuery() as { where: Record<string, unknown> }).where;
}

/** `severity` ごとの件数（★`precheck` 以外が混ざっていないことの検出に使う★） */
function bySeverity(items: PrecheckItem[]): Record<string, number> {
  const out: Record<string, number> = {};
  for (const item of items) out[item.severity] = (out[item.severity] ?? 0) + 1;
  return out;
}

function memoryQuery(partial: Partial<PrecheckQuery>): PrecheckQuery {
  return {
    dateFrom: null,
    dateTo: null,
    status: "all",
    // ★絞り込まない（指示書AK 段階2 で足った2つ。`null` = 条件なし）★
    groupId: null,
    customerId: null,
    page: 1,
    perPage: 50,
    ...partial,
  };
}

beforeEach(() => {
  data = buildData();
  ignoreErrorWhere = false;
  vi.mocked(logEvent).mockClear();
  calls.cleanTimetable = [];
  calls.cleanTimetableHistory = [];
  calls.cleanTaskReport = [];
  calls.robotErrorReport = [];
  // ★`new Date()` を固定する（Date だけを差し替え、タイマーは触らない）★
  vi.useFakeTimers({ toFake: ["Date"] });
  vi.setSystemTime(NOW);
});

afterEach(() => {
  vi.useRealTimers();
});

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

describe("GET /prechecks — 日付をSQLに降ろす（指示書AC 段階4）", () => {
  it("#1 範囲なし → 事前チェック異常の全件（★出発失敗の行は無い★）", async () => {
    const body = await get("?status=all");
    // ★detected_at の降順（D-052）★
    expect(body.items.map((i) => `${i.severity}:${i.id}`)).toEqual([
      "precheck:E2",
      "precheck:E5",
      "precheck:E1",
    ]);
    expect(body.meta.total).toBe(3);
    expect(body.summary).toEqual({ unresolved: 3, resolved: 0, total: 3 });
    // ★本清掃の絞り込みが SQL に降りていること★
    expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
    // ★範囲なしのときは `start_at` のキーごと付けない★
    expect(mainQueryWhere()).not.toHaveProperty("start_at");
  });

  it("★D-104: 本清掃行を指すエラー行は出さない★（出発失敗の代わりにもしない）", async () => {
    const body = await get("?status=all");
    expect(body.items.map((i) => i.id)).not.toContain("E3");
  });

  it("#2 日付範囲を指定すると SQL の `where` に `start_at` が降りる（★`toDbJst()` を通す★）", async () => {
    await get("?date_from=2026-09-09&date_to=2026-09-10&status=all");
    const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
    // ★★壁時計値であること★★（C-6。★生の `parseDate()`（真の瞬間）ではない★）
    expect(startAt.gte.toISOString()).toBe("2026-09-09T00:00:00.000Z");
    expect(startAt.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
  });

  it("★★#2 SQL版 ＝ 全件版 + `filterPrechecksByDate()`（完全一致）★★", async () => {
    const all = await get("?status=all");
    const sql = await get("?date_from=2026-09-09&date_to=2026-09-10&status=all");
    const memory = filterPrechecksByDate(
      all.items,
      memoryQuery({ dateFrom: "2026-09-09", dateTo: "2026-09-10" })
    );
    // ★件数だけでなく中身まで比べる★（指示書AC G-3）
    expect(sql.items).toEqual(memory);
    expect(sql.items.length).toBeGreaterThan(0);
  });

  it("★★#3 `severity` の内訳が全件版と一致する★★（★事前チェック異常だけ★）", async () => {
    const all = await get("?status=all");
    expect(bySeverity(all.items)).toEqual({ precheck: 3 });

    for (const [from, to] of [
      ["2026-09-08", "2026-09-08"],
      ["2026-09-09", "2026-09-09"],
      ["2026-09-10", "2026-09-10"],
      ["2026-09-11", "2026-09-11"],
      ["2026-09-09", "2026-09-11"],
    ]) {
      const sql = await get(`?date_from=${from}&date_to=${to}&status=all`);
      const memory = filterPrechecksByDate(
        all.items,
        memoryQuery({ dateFrom: from, dateTo: to })
      );
      expect(bySeverity(sql.items)).toEqual(bySeverity(memory));
      expect(sql.items).toEqual(memory);
    }
  });

  it("★★#4 親の本清掃行が期間外・エラー行が期間内 → 親の `start_at` で判定される★★", async () => {
    // ★E2 のエラー行と事前チェック行は 9/10、親の本清掃（104）は 9/11★
    const sep11 = await get("?date_from=2026-09-11&date_to=2026-09-11&status=all");
    expect(sep11.items.map((i) => i.id)).toEqual(["E2"]);
    // ★`scheduled_at` は親の値（＝ `filterPrechecksByDate()` が見る値）★
    expect(sep11.items[0].scheduled_at).toBe("2026-09-11T00:30:00+09:00");
    // ★`detected_at` は 9/10 のまま（★絞り込みの基準ではない★）★
    expect(sep11.items[0].detected_at).toBe("2026-09-10T23:35:00+09:00");

    // ★★9/10 を指定しても E2 は出ない★★ —
    //   ★`errored_at` や事前チェック行の `start_at` で絞っていると、ここで E2 が混ざる★
    const sep10 = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
    expect(sep10.items.map((i) => i.id)).not.toContain("E2");
    // ★9/10 は E5（親 301 は 9/10 20:00）だけ★。出発失敗（103・301）は行にならない
    expect(sep10.items.map((i) => i.id)).toEqual(["E5"]);
  });

  it("★#4（構造）事前チェック行は親の本清掃の id（`parent_id`）で引く★", async () => {
    await get("?date_from=2026-09-11&date_to=2026-09-11&status=all");
    const precheckCall = calls.cleanTimetable.find(
      (c) => "where" in c && "parent_id" in (c.where as object)
    );
    expect(precheckCall).toBeDefined();
    expect((precheckCall as { select: object }).select).toEqual({
      id: true,
      parent_id: true,
      precheck: true,
    });
    // ★エラー行は事前チェック行の id でしか引かない（全件読みに戻さない）★
    const errorWhere = (
      calls.robotErrorReport[0] as { where: { clean_timetable_id: { in: bigint[] } } }
    ).where;
    expect(errorWhere.clean_timetable_id.in.map(String)).toEqual(["1041"]);
  });

  it("★壁時計 20:00 の清掃が、その日の範囲に**含まれる**★（C-6。★ローカルDBでは検出できない★）", async () => {
    // ★E5 の親（301）は 9/10 20:00★
    const sep10 = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
    expect(sep10.items.map((i) => i.id)).toContain("E5");
    expect(sep10.items.find((i) => i.id === "E5")?.scheduled_at).toBe("2026-09-10T20:00:00+09:00");
    const sep11 = await get("?date_from=2026-09-11&date_to=2026-09-11&status=all");
    expect(sep11.items.map((i) => i.id)).not.toContain("E5");
  });

  it("★レポートは行になったエラーの親の本清掃の id だけで引く（全件読みに戻さない）★", async () => {
    await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
    const reportWhere = (
      calls.cleanTaskReport.find((c) => "where" in c) as {
        where: { clean_timetable_id: { in: bigint[] } };
      }
    ).where;
    // ★9/10 の本清掃は 103・301 だが、行（E5）の親は 301 だけ★（旧: 出発失敗の判定のため 103 も引いていた）
    expect(reportWhere.clean_timetable_id.in.map(String)).toEqual(["301"]);
  });

  it("★行が0件ならレポートを引かない★（出発失敗の判定のための読み取りは無い）", async () => {
    // 9/8 の本清掃（101）は事前チェックのエラーが無い
    const body = await get("?date_from=2026-09-08&date_to=2026-09-08&status=all");
    expect(body.items).toEqual([]);
    expect(calls.cleanTimetable.length).toBeGreaterThan(0);
    expect(calls.cleanTaskReport).toEqual([]);
  });

  it("★本清掃が0件ならクエリを1本も撃たない★（`chunkForInClause([])` が空を返す）", async () => {
    const body = await get("?date_from=2026-01-01&date_to=2026-01-02&status=all");
    expect(body.items).toEqual([]);
    expect(calls.robotErrorReport).toEqual([]);
    expect(calls.cleanTaskReport).toEqual([]);
  });

  it("★summary は日付範囲で絞ったあとの件数になる★（D-054。SQL に降ろしても変わらない）", async () => {
    const body = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all");
    // ★9/10 は E5 の1件だけ★（旧: 出発失敗 103・301 の2件。2026-10-29 から数えない）
    expect(body.summary).toEqual({ unresolved: 1, resolved: 0, total: 1 });
    expect(body.meta.total).toBe(1);
  });
});

/**
 * ★出発失敗はアラートに出さない★（ユーザー判断 2026-10-29「2で進めてください」）。
 * ★過去の本清掃でレポートが無い（101・103・301）は、行・summary・meta.total のどれにも出ない★。
 * 清掃詳細ログには出発失敗として出ること（同じ判定 `notExecutedFromReport()`）は
 * `app/api/clean-logs/__tests__/route.test.ts` の「出発失敗はアラートに出ない」で固定する。
 */
describe("GET /prechecks — 出発失敗はアラートに出さない（2026-10-29）", () => {
  const LAUNCH_FAILED_IDS = ["101", "103", "301", "901"];

  it("★レポートの無い過去の本清掃は行にならない（summary・meta.total にも数えない）★", async () => {
    const body = await get("?status=all");
    const ids = body.items.map((i) => i.id);
    for (const id of LAUNCH_FAILED_IDS) expect(ids).not.toContain(id);
    expect(body.items.every((i) => i.severity === "precheck")).toBe(true);
    // ★件数は事前チェック異常の3件だけ★
    expect(body.summary.total).toBe(3);
    expect(body.meta.total).toBe(3);
  });

  it("★事前チェックのエラーが無ければ、出発失敗の清掃があっても0件★", async () => {
    data.errorReports = [];
    const body = await get("?status=all");
    expect(body.items).toEqual([]);
    expect(body.summary).toEqual({ unresolved: 0, resolved: 0, total: 0 });
    expect(body.meta.total).toBe(0);
  });

  it("★結果待ちの時刻でも、待ち時間を過ぎた後でも出さない★（旧: 終了＋120分を過ぎると出発失敗として出した。D-173）", async () => {
    for (const now of ["2026-09-10T04:59:00+09:00", "2026-09-10T05:00:00+09:00"]) {
      vi.setSystemTime(new Date(now));
      const ids = (await get("?status=all")).items.map((i) => i.id);
      for (const id of LAUNCH_FAILED_IDS) expect(ids).not.toContain(id);
    }
  });

  it("★親の本清掃が出発失敗の事前チェック異常は、行として出る（main_run_succeeded は false）★", async () => {
    // E5 の親 301 はレポートなし（出発失敗）
    const body = await get("?status=all");
    const e5 = body.items.find((i) => i.id === "E5");
    expect(e5).toBeDefined();
    expect(e5?.main_run_succeeded).toBe(false);
    expect(e5?.severity).toBe("precheck");
  });
});

describe("★#5 fixtures 版と Prisma 版が同じ引数で同じ結果を返す★（禁止事項6）", () => {
  /**
   * ★同じデータに対して★、
   *   - Prisma 版 = ★SQL で絞った結果★
   *   - fixtures 版 = ★全件版 + `filterFixturePrechecks()`★
   * を突き合わせる（指示書AC G-3「等価テスト」）。
   */
  it("日付範囲: SQL版 ＝ 全件版 + fixtures 版の絞り込み", async () => {
    const all = await get("?status=all");
    const sql = await get("?date_from=2026-09-09&date_to=2026-09-11&status=all");
    const viaFixtures = filterFixturePrechecks(all.items, {
      date_from: "2026-09-09",
      date_to: "2026-09-11",
      facility_ids: null,
    });
    expect(sql.items).toEqual(viaFixtures);
    expect(sql.items.length).toBeGreaterThan(0);
  });

  it("範囲なし: 件数が一致", async () => {
    const all = await get("?status=all");
    expect(
      filterFixturePrechecks(all.items, { date_from: null, date_to: null, facility_ids: null })
    ).toHaveLength(all.items.length);
  });

  /**
   * ★★空文字のクエリ（`?date_to=`）で fixtures 版と Prisma 版が食い違わないこと★★（禁止事項6）。
   *
   * `parsePrecheckQuery()` が生値をそのまま下へ渡していた頃、`""` は
   *   - Prisma 版 … `dbDateRange("")` → `parseDate("")` が `null` → ★条件が付かず全件★
   *   - fixtures 版 … `query.date_to !== null` が真・`"2026-09-10" > ""` も真 → ★全行除外で0件★
   * となり、★同じ入力で件数が変わっていました★（★例外もテスト失敗も出ない★）。
   *
   * ★ルートと同じ経路で組み立てる★ — `parsePrecheckQuery()` の戻り値をそのまま
   * `PrecheckDbQuery` に詰め、fixtures 版の絞り込みへ渡す（ルートは `db.prechecks()` に
   * 同じ値を渡している）。★テスト側で `null` を書き足さないこと★（それでは穴を再現できない）。
   */
  describe("★空文字のクエリ（`?date_from=` / `?date_to=`）★", () => {
    function dbQueryFromSearch(search: string): PrecheckDbQuery {
      const parsed = parsePrecheckQuery(new URLSearchParams(search));
      expect(parsed.ok).toBe(true);
      if (!parsed.ok) throw new Error("query");
      return {
        date_from: parsed.query.dateFrom,
        date_to: parsed.query.dateTo,
        facility_ids: null,
      };
    }

    for (const search of ["date_to=", "date_from=", "date_from=&date_to="]) {
      it(`\`?${search}\`: SQL版 ＝ fixtures 版 ＝ 全件`, async () => {
        const all = await get("?status=all");
        const sql = await get(`?${search}&status=all`);
        const viaFixtures = filterFixturePrechecks(all.items, dbQueryFromSearch(search));

        // ★空文字は「指定なし」＝全件★（★片方だけ0件に化けないこと★）
        expect(sql.items).toEqual(all.items);
        expect(viaFixtures).toEqual(all.items);
        expect(viaFixtures).toHaveLength(sql.items.length);
        expect(all.items.length).toBeGreaterThan(0);
      });
    }

    it("★空文字は `start_at` のキーごと付かない（範囲なしと同じ SQL）★", async () => {
      await get("?date_from=&date_to=&status=all");
      expect(mainQueryWhere()).not.toHaveProperty("start_at");
    });
  });
});

describe("GET /prechecks — robot_id が NULL のエラー行（指示書AU 段階0-4。第11回 P-8 b）", () => {
  it("★robot_id が NULL の行を混ぜても落ちず、同じ行が出る★（select で robot_id / robot を読まない）", async () => {
    const before = await get("?status=all");
    data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null }));
    const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
    expect(res.status).toBe(200);
    const after = (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
    expect(after.items).toEqual(before.items);
    expect(after.items.map((i) => i.id)).toContain("E1");
  });
});

describe("GET /prechecks — 予定を2表から読む（指示書AU 段階3b。第11回 P-7）", () => {
  it("★履歴だけの予定の事前チェックのエラーも出る★。2表に同じ引数が渡る", async () => {
    data.historyTimetables = [
      // ★履歴だけの本清掃（レポートなし → 出発失敗）★ — アラートには出ない（2026-10-29）
      timetable(501, 1, FACILITY_1, "2026-09-12T02:00:00+09:00"),
      // ★履歴だけの本清掃（正常）とその事前チェック行（エラーあり）★
      timetable(502, 1, FACILITY_1, "2026-09-13T02:00:00+09:00"),
      timetable(5021, 1, FACILITY_1, "2026-09-13T01:00:00+09:00", FIRST_ROUND_MINUTES, 502),
    ];
    data.reports.push(report(502, "2026-09-13T02:00:00+09:00", "2026-09-13T03:00:00+09:00"));
    data.errorReports.push(errorReport("E9", 5021, 1, FACILITY_1, "2026-09-13T01:05:00+09:00"));

    const body = await get("?status=all");
    const ids = body.items.map((i) => i.id);
    expect(ids).toContain("E9");
    expect(ids).not.toContain("501");
    expect(ids).not.toContain("502");

    const mainCall = calls.cleanTimetable.find(isMainQuery);
    expect(mainCall).toBeDefined();
    expect(calls.cleanTimetableHistory.find(isMainQuery)).toEqual(mainCall);
  });
});

/**
 * ★`main_run_succeeded`（ユーザー指示 2026-10-07。D-171 の期待値表 M1〜M5）★ — Prisma 版の経路。
 * ★親の本清掃のレポートは `prechecks()` が行の親の id でまとめて1回引く★（N+1 にしない）。
 * （M6「出発失敗の行は常に false」は、2026-10-29 に出発失敗の行を外したので無い）
 */
describe("GET /prechecks — main_run_succeeded（D-171）", () => {
  it("親の本清掃が ◎○ なら true、△・レポートなし（出発失敗）は false", async () => {
    // E1 の親 102 は 95%（◎。M1）、E2 の親 104 は 50%（△。M3）
    data.reports = [
      report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 95, 100),
      report(104, "2026-09-11T00:30:00+09:00", null, 50, 100),
    ];
    // ★M4: 親の本清掃にレポートが無い事前チェックのエラー★（親 101 そのものは行にならない）
    data.timetables.push(
      timetable(1011, 1, FACILITY_1, "2026-09-08T01:00:00+09:00", FIRST_ROUND_MINUTES, 101)
    );
    data.errorReports.push(errorReport("E4", 1011, 1, FACILITY_1, "2026-09-08T01:05:00+09:00"));

    const body = await get("?status=all");
    const byKey = new Map(body.items.map((i) => [`${i.severity}:${i.id}`, i.main_run_succeeded]));
    expect(byKey.get("precheck:E1")).toBe(true);
    expect(byKey.get("precheck:E2")).toBe(false);
    expect(byKey.get("precheck:E4")).toBe(false);
    // ★出発失敗（101）は行にならない（2026-10-29）★
    expect(body.items.map((i) => i.id)).not.toContain("101");
  });

  it("M2: ○（72%）は true / M5: task_area 0（×）は false", async () => {
    data.reports = [report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 72, 100)];
    expect((await get("?status=all")).items.find((i) => i.id === "E1")?.main_run_succeeded).toBe(true);

    data.reports = [report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 500, 0)];
    expect((await get("?status=all")).items.find((i) => i.id === "E1")?.main_run_succeeded).toBe(false);
  });

  it("★レポートは本清掃の id でまとめて1回だけ引く★（N+1 にしない）", async () => {
    await get("?status=all");
    expect(calls.cleanTaskReport).toHaveLength(1);
  });
});

/** `data.anomaly` のうち、指定の metric のものだけ */
function anomalies(metric: string) {
  return vi
    .mocked(logEvent)
    .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === metric)
    .map(([, p]) => p);
}

describe("GET /prechecks — 対応状況が 0/1/2 以外のエラー行（指示書BB U3・表3 S1/S2）", () => {
  it("★S2: `status` 3 の行は SQL の写し（`status IN (0,1,2)`）で外れる★", async () => {
    data.errorReports.push({ ...errorReport("E-ODD", 1022, 1, FACILITY_1, "2026-09-09T01:35:00+09:00"), status: 3 });
    const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
    expect(res.status).toBe(200);
    const where = calls.robotErrorReport[0].where as Record<string, unknown>;
    expect(where.status).toEqual({ in: [0, 1, 2] });
  });

  it("★S2: スタブが `where` を無視しても、純粋関数の側で外れる（200・出ない・summary に数えない・ログ1回）★", async () => {
    const before = await get("?status=all");
    vi.mocked(logEvent).mockClear();
    data.errorReports.push({ ...errorReport("E-ODD", 1022, 1, FACILITY_1, "2026-09-09T01:35:00+09:00"), status: 3 });
    ignoreErrorWhere = true;

    const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
    expect(res.status).toBe(200);
    const after = (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
    expect(after.items.map((i) => i.id)).not.toContain("E-ODD");
    expect(after.items).toEqual(before.items);
    expect(after.summary).toEqual(before.summary);
    expect(anomalies("robot_error_status_unknown")).toEqual([
      { event: "data.anomaly", metric: "robot_error_status_unknown", expected: "0/1/2", actual: 1 },
    ]);
  });

  it("S1: `status` 0/1/2 の行は今どおり出る（外した件数が0ならログを出さない）", async () => {
    ignoreErrorWhere = true;
    const body = await get("?status=all");
    expect(body.items.map((i) => i.id)).toContain("E1");
    expect(anomalies("robot_error_status_unknown")).toHaveLength(0);
  });
});

/**
 * ★本清掃の予定は `id` と `start_at` だけ読む★（2026-10-29）— 旧: 出発失敗の行の施設名・シリアルのため
 *   `facility` / `section.robots` を include し、有効なロボットが1台でない清掃エリアのログ（指示書BB U2）を出していた。
 */
describe("GET /prechecks — 本清掃の予定はロボット・施設を読まない（2026-10-29）", () => {
  it("★本清掃の予定のクエリは `select: { id, start_at }`（include なし）。2表とも同じ★", async () => {
    await get("?status=all");
    expect(mainQuery()).not.toHaveProperty("include");
    expect(mainQuery().select).toEqual({ id: true, start_at: true });
    expect(calls.cleanTimetableHistory.find(isMainQuery)).toEqual(mainQuery());
  });

  it("★清掃エリアのロボットが0台でも、事前チェック異常の行は今どおり（シリアルはエラー行の生値・ログなし）★", async () => {
    for (const t of data.timetables) {
      if (Number(t.facility_id) === 1) t.section = { robots: [] };
    }
    const res = await GET(new Request("http://localhost/api/prechecks?status=all"));
    expect(res.status).toBe(200);
    const body = (await res.json()) as SummaryListResponse<PrecheckItem, PrecheckSummary>;
    // ★事前チェック異常の行は `robot_error_reports.serial_number` の生値のまま（null にならない）★
    expect(body.items.find((i) => i.id === "E1")?.serial_number).toBe("8111R562806001");
    expect(anomalies("active_robots_per_section")).toHaveLength(0);
  });
});

describe("GET /prechecks — 削除済みの施設・清掃エリアの行は出さない（指示書BB U1・表1 V7/V8）", () => {
  const DELETED = dbWallClock("2026-09-01T00:00:00+09:00");

  it("★V8: 予定（2表とも）の `where` に施設・清掃エリアの可視性が入る★（事前チェックの親の本清掃）", async () => {
    await get("?status=all");
    expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    const historyMain = calls.cleanTimetableHistory.find(isMainQuery) as { where: Record<string, unknown> };
    expect(historyMain.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
  });

  it("V7: エラー行の SQL（写し）にも `status IN` は入るが、可視性は純粋関数で外す（列を select する）", async () => {
    await get("?status=all");
    const select = calls.robotErrorReport[0].select as Record<string, { select: Record<string, unknown> }>;
    expect(select.facility.select).toMatchObject({ deleted_at: true });
    expect(select.section.select).toMatchObject({ deleted_at: true });
  });

  it.each([
    ["施設削除", { facility: { facility_name: FACILITY_1, deleted_at: DELETED } }],
    ["エリア削除", { section: { deleted_at: DELETED } }],
  ] as const)("★V7（%s）: スタブが where を無視しても、そのエラー行は出ない・summary に数えない★", async (_label, patch) => {
    const before = await get("?status=all");
    data.errorReports = data.errorReports.map((r) => (r.error_id === "E1" ? { ...r, ...patch } : r));
    ignoreErrorWhere = true;

    const after = await get("?status=all");
    expect(after.items.map((i) => i.id)).toEqual(before.items.map((i) => i.id).filter((id) => id !== "E1"));
    expect(after.summary.total).toBe(before.summary.total - 1);
  });
});

describe("GET /prechecks — 存在しない日付は422（指示書BB 表8 Q6）", () => {
  it("★`2026-02-31` は形式違い（`2026/02/03`）と同じ code・message・details の422★", async () => {
    const bad = await GET(new Request("http://localhost/api/prechecks?date_from=2026-02-31"));
    const format = await GET(new Request("http://localhost/api/prechecks?date_from=2026/02/03"));
    expect(bad.status).toBe(422);
    expect(format.status).toBe(422);
    expect(await bad.json()).toEqual(await format.json());
  });
});
