import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
//   ★最初の1本だけがモジュール読み込み（`next/server` / ロガー等）を丸ごと抱え込み、
//   フルスイート実行時に既定のタイムアウト（5秒）を踏んで★不定期に落ちます★
//   （実測: 単体実行 2.8秒 / フルスイート 7.1秒で #1・#2 が不定期に赤）。
//   ★`vi.mock()` は import より先に巻き上げられる★ので、静的 import でもスタブは効きます。
import { GET } from "@/app/api/clean-logs/route";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { db } from "@/lib/db";
import { consecutiveLookbackCalendarDays } from "@/lib/db/consecutive-source";
import { filterFixtureCleanLogs } from "@/lib/db/fixture-clean-logs";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { getPrisma } from "@/lib/db/prisma";
import { logEvent } from "@/lib/logger";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type { CleanLogItem, ListResponse } from "@/lib/types";

/**
 * 指示書AC 段階2 の期待値表のうち、★★API ルートから `db` までを通した経路★★
 * でなければ固定できないもの（★#6b / #6c / #6d / #6e / #7 / #8 / #9★）。
 *
 * ------------------------------------------------------------------------
 * ★★なぜ解決関数の単体テストだけでは不十分なのか★★（指示書AC 期待値表 #6d）
 * ------------------------------------------------------------------------
 * ★ルートが `facility_ids` を `db.cleanLogs()` に渡し忘れていても、
 *   `facilityIdsMatchingName()` の単体テストは緑のままです★。
 *   ★★だからここでは「ルート → `db`（Prisma 実装）→ 発行されたクエリの `where`」まで
 *   通して確かめます★★。
 *
 * ------------------------------------------------------------------------
 * ★★DBには接続しません★★
 * ------------------------------------------------------------------------
 * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
 * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
 * ★`prismaSource` の経路★ を走らせます（`lib/db/__tests__/consecutive-source.test.ts` と
 * 同じ「DBに接続しないでDB実装を試す」作法）。
 *
 * ★★合成データを使う理由★★: ローカルDB（`dwatch_local`）では
 *   - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★（#7 / C-6）
 *   - ★論理削除された施設が0件で、罠7 を検出できない★（#6e。2026-09-13 実測）
 */

// ---------------------------------------------------------------- スタブ（DBの代わり）

/** `clean_timetables` の1行（★`include` 済みの形で持つ★） */
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;
      device: { maker_id: number; model_name: string };
    }[];
  };
}

/**
 * `clean_task_reports` の1行（★清掃実績の取得元。指示書AQ 決定2/3★）。
 * ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す（指示書AQ §1-1）。
 * ★`clean_timetable_id` / `ended_at` は nullable★（実DDL。指示書AQ 落とし穴1/3）
 */
interface StubReport {
  clean_timetable_id: bigint | null;
  clean_ratio: number;
  /** ★生値（意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2）★ */
  status: number;
  started_at: Date;
  ended_at: Date | null;
  clean_area: number;
  task_area: number;
}

interface StubErrorReport {
  clean_timetable_id: bigint | null;
  error_type: string;
  errored_at: Date;
  /** ★DDL では NULL 可（第11回 P-8 b）。読まないこと（指示書AU 段階0-4）★ */
  robot_id?: bigint | null;
}

interface StubFacility {
  id: bigint;
  facility_name: string;
  /** ★論理削除★（`null` 以外なら削除済み） */
  deleted_at: Date | null;
}

interface StubData {
  timetables: StubTimetable[];
  /** ★`clean_timetable_histories` の行（指示書AU 段階3b）。無ければ空★ */
  historyTimetables?: StubTimetable[];
  reports: StubReport[];
  errorReports: StubErrorReport[];
  facilities: StubFacility[];
}

/** `findMany()` に渡された引数の記録（★どんな `where` が発行されたかを検査する★） */
const calls: {
  cleanTimetable: Record<string, unknown>[];
  cleanTimetableHistory: Record<string, unknown>[];
  cleanTaskReport: Record<string, unknown>[];
  robotErrorReport: Record<string, unknown>[];
  facility: Record<string, unknown>[];
} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], robotErrorReport: [], facility: [] };

let data: StubData;

/** 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;
      if ("deleted_at" in c) {
        // ★このスタブは `deleted_at` での絞り込みを**わざと**実装している★ —
        //   罠7（解決用の読み取りに `where` を足してしまう）が起きたら結果が変わるように
        if (c.deleted_at === null && value !== null) 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 applyOrderBy(
  rows: Record<string, unknown>[],
  orderBy: unknown
): Record<string, unknown>[] {
  if (!Array.isArray(orderBy)) return rows;
  return [...rows].sort((a, b) => {
    for (const key of orderBy as Record<string, "asc" | "desc">[]) {
      const [field, dir] = Object.entries(key)[0];
      const av = a[field];
      const bv = b[field];
      const an = av instanceof Date ? av.getTime() : Number(av);
      const bn = bv instanceof Date ? bv.getTime() : Number(bv);
      if (an !== bn) return (an < bn ? -1 : 1) * (dir === "desc" ? -1 : 1);
    }
    return 0;
  });
}

function findMany(
  bucket: keyof typeof calls,
  rows: Record<string, unknown>[],
  args: Record<string, unknown> | undefined
) {
  calls[bucket].push(args ?? {});
  const filtered = rows.filter((row) => matchesWhere(row, args?.where));
  const ordered = applyOrderBy(filtered, args?.orderBy);
  return ordered.map((row) => project(row, args?.select));
}

vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));

// ★事前チェックが3つ以上のときの `data.anomaly`（D-178）を見るため `logEvent` だけ差し替える★
//   （`withRequestId` はそのまま使う。`app/api/clean-area/__tests__/route.test.ts` と同じ作法）
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
        ),
    },
    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);
        return findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], args);
      },
    },
    facility: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("facility", data.facilities 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 = "ドソ・キホーテ戸塚";
/** ★論理削除された施設★（#6e / 罠7） */
const FACILITY_2 = "ドソ・キホーテ四街道";
const FACILITY_3 = "フレッシュタウン大和店";

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}`,
          device: { maker_id: 1, model_name: "CC1" },
        },
      ],
    },
  };
}

/**
 * 実施済みの清掃1回ぶんのレポート行。
 * ★未実施は行を作らないことで表す★（指示書AQ §1-1。旧 `clean_error = 1` の行は作らない）。
 * `started_at` は `ended_at` の1時間前（どちらも壁時計値）。`status` は生値の一例（4）。
 * ★完了率は面積から `runCompletionRate()` で決まる（D-155）★ので、`task_area = 1000` に対して
 * `clean_area = cleanRatio × 10` とし、`clean_ratio` 列にも同じ値を入れる（列は完了率に使っていない。D6）。
 */
function report(timetableId: number, cleanRatio: number, endedJstIso: string): StubReport {
  const endedAt = dbWallClock(endedJstIso);
  return {
    clean_timetable_id: BigInt(timetableId),
    clean_ratio: cleanRatio,
    status: 4,
    started_at: new Date(endedAt.getTime() - 60 * 60 * 1000),
    ended_at: endedAt,
    clean_area: cleanRatio * 10,
    task_area: 1000,
  };
}

function buildData(): StubData {
  return {
    facilities: [
      { id: BigInt(1), facility_name: FACILITY_1, deleted_at: null },
      // ★★論理削除済み★★ — ★清掃ログ側には行が残っている（`cleanLogs()` は
      //   `include: { facility: true }` で `deleted_at` を見ていない）★
      { id: BigInt(2), facility_name: FACILITY_2, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") },
      { id: BigInt(3), facility_name: FACILITY_3, deleted_at: null },
    ],
    timetables: [
      // 施設1: ★3日連続で×★（9/8・9/9・9/10）→ 9/10 の行の連続日数は ★3★
      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"),
      // 事前チェック行（★本清掃の子★）— `status_1h` / `status_30m` の材料
      timetable(1031, 1, FACILITY_1, "2026-09-10T01:00:00+09:00", 60, 103),
      timetable(1032, 1, FACILITY_1, "2026-09-10T01:30:00+09:00", 30, 103),
      // 施設2（★論理削除された施設★）: 9/10 に1件
      timetable(201, 2, FACILITY_2, "2026-09-10T02:00:00+09:00"),
      // 施設3: ★★壁時計 20:00 の清掃★★（#7。★ローカルDBには1件も無い時間帯★）
      timetable(301, 3, FACILITY_3, "2026-09-10T20:00:00+09:00"),
    ],
    reports: [
      report(101, 5, "2026-09-08T03:00:00+09:00"),
      report(102, 5, "2026-09-09T03:00:00+09:00"),
      report(103, 5, "2026-09-10T03:00:00+09:00"),
      report(201, 95, "2026-09-10T03:00:00+09:00"),
      report(301, 80, "2026-09-10T21:00:00+09:00"),
    ],
    errorReports: [
      {
        clean_timetable_id: BigInt(1031),
        error_type: "EmergencyStop",
        errored_at: dbWallClock("2026-09-10T01:05:00+09:00"),
      },
    ],
  };
}

/** ★全行が過去になる基準時刻★（`isFutureSchedule()` の除外が効かない状態にする） */
const NOW = new Date("2026-09-30T00:00:00+09:00");

async function get(search: string): Promise<ListResponse<CleanLogItem>> {
  const res = await GET(new Request(`http://localhost/api/clean-logs${search}`));
  return (await res.json()) as ListResponse<CleanLogItem>;
}

/** 最後に発行された `clean_timetables` の本体クエリ（＝ `include` 付きの1本目） */
function mainQueryWhere(): Record<string, unknown> {
  const call = calls.cleanTimetable.find((c) => "include" in c);
  expect(call).toBeDefined();
  return (call as { where: Record<string, unknown> }).where;
}

beforeEach(() => {
  data = buildData();
  calls.cleanTimetable = [];
  calls.cleanTimetableHistory = [];
  calls.cleanTaskReport = [];
  calls.robotErrorReport = [];
  calls.facility = [];
  vi.mocked(logEvent).mockClear();
  // ★`new Date()` を固定する（Date だけを差し替え、タイマーは触らない）★
  vi.useFakeTimers({ toFake: ["Date"] });
  vi.setSystemTime(NOW);
});

afterEach(() => {
  vi.useRealTimers();
});

// ---------------------------------------------------------------- テスト

describe("GET /clean-logs — 日付・施設をSQLに降ろす（指示書AC 段階2）", () => {
  it("#1 範囲なし → 本清掃の全件（★事前チェック行は並ばない★）", async () => {
    const body = await get("");
    expect(body.items.map((i) => i.id)).toEqual([301, 103, 201, 102, 101]);
    expect(body.meta.total).toBe(5);
    // ★本清掃の絞り込みが SQL に降りていること★
    expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
  });

  it("#2 日付範囲を指定すると SQL の `where` に `start_at` が降りる", async () => {
    await get("?date_from=2026-09-09&date_to=2026-09-10");
    const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
    // ★★`toDbJst()` を通した壁時計値であること★★（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("#3 `date_from = date_to` → その日の行だけ", async () => {
    const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
    expect(body.items.map((i) => i.id)).toEqual([301, 103, 201]);
    expect(body.meta.total).toBe(3);
  });

  it("#4 `date_from` がデータの最古日より前 → #1 と同じ件数", async () => {
    const body = await get("?date_from=2000-01-01");
    expect(body.meta.total).toBe(5);
  });

  it("#5 `date_from > date_to` は 422 で弾かれ、`db` まで来ない", async () => {
    const res = await GET(
      new Request("http://localhost/api/clean-logs?date_from=2026-09-10&date_to=2026-09-08")
    );
    expect(res.status).toBe(422);
    // ★★クエリが1本も発行されていないこと★★
    expect(calls.cleanTimetable).toHaveLength(0);
  });

  it("★#7 壁時計 20:00 の清掃が、その日の範囲に**含まれる**★（C-6。★ローカルDBでは検出できない★）", async () => {
    const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
    const evening = body.items.find((i) => i.id === 301);
    expect(evening).toBeDefined();
    expect(evening?.scheduled_at).toBe("2026-09-10T20:00:00+09:00");
    // ★前日・翌日の指定では出てこない★
    expect((await get("?date_from=2026-09-09&date_to=2026-09-09")).items.map((i) => i.id)).not.toContain(301);
    expect((await get("?date_from=2026-09-11&date_to=2026-09-11")).items.map((i) => i.id)).not.toContain(301);
  });

  it("★★#8 期間で絞っても `consecutive_problem_days` が全期間版と完全一致する★★（★C-2 の地雷★）", async () => {
    // ★施設1 は 9/8・9/9・9/10 の3日連続で×★
    const all = await get("");
    const expected = new Map(all.items.map((i) => [i.id, i.consecutive_problem_days]));
    expect(expected.get(103)).toBe(3);

    // ★9/10 の1日だけを指定しても 3 のまま★
    //   ★★期間で絞った行から連続日数を数えると 1 になり、L3 が L1 に化ける★★
    //   （★例外もテスト失敗も出ない fail-silent★。だからこのテストが唯一の検出手段）
    const oneDay = await get("?date_from=2026-09-10&date_to=2026-09-10");
    for (const item of oneDay.items) {
      expect(item.consecutive_problem_days).toBe(expected.get(item.id));
    }
    expect(oneDay.items.find((i) => i.id === 103)?.consecutive_problem_days).toBe(3);
    expect(oneDay.items.find((i) => i.id === 103)?.escalation_level).toBe("L3");
  });

  it("★#8（構造）連続日数用のクエリは「表示期間 ＋ 上限ぶんの遡り」で絞る★（指示書AC 段階6）", async () => {
    await get("?date_from=2026-09-10&date_to=2026-09-10");
    // ★`loadConsecutiveInputs()` が発行する `select` 付きのクエリ★
    const consecutiveCall = calls.cleanTimetable.find(
      (c) => "select" in c && (c.select as Record<string, unknown>).facility_id === true
    );
    expect(consecutiveCall).toBeDefined();

    // ★★表示期間の開始日（9/10）をそのまま下限にしたら連続が途切れる（C-2）★★ —
    //   遡り幅は ★保存された表示上限★ から導く（★定数に写し取らないこと★。申し送り⑪）
    const start = (consecutiveCall as { where: { start_at: { gte: Date; lt: Date } } })
      .where.start_at;
    const lookback = consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax());
    expect(start.gte.toISOString().slice(0, 10)).toBe(
      new Date(Date.parse("2026-09-10T00:00:00Z") - lookback * 86_400_000)
        .toISOString()
        .slice(0, 10)
    );
    // ★上限側は表示期間の末日を含む（翌日 00:00 未満）★
    expect(start.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
  });
});

describe("施設名 → 施設ID の解決（指示書AC C-1 / D-6）", () => {
  it("★★#6d 記号違いで指定した施設名が解決され、SQL の `where` に施設IDで降りる★★", async () => {
    const body = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);

    // ★★ルートが `facility_ids` を渡していること（＝渡し忘れの検出）★★
    expect(mainQueryWhere().facility_id).toEqual({ in: [1, 2] });
    // ★★そのログがヒットすること★★（`ドソ・キホーテ戸塚` に `ドソキホーテ` で当たる）
    expect(body.items.map((i) => i.id).sort((a, b) => a - b)).toEqual([101, 102, 103, 201]);
    expect(body.items.every((i) => i.facility_name.startsWith("ドソ"))).toBe(true);
  });

  it("★★#6e 論理削除された施設の名前で検索しても、そのログが消えない★★（★罠7★）", async () => {
    const body = await get(`?facility_name=${encodeURIComponent("四街道")}`);

    // ★解決用の読み取りに `where` を足していないこと★（足すと施設2 が解決されない）
    expect(calls.facility).toEqual([{ select: { id: true, facility_name: true } }]);
    expect(mainQueryWhere().facility_id).toEqual({ in: [2] });
    expect(body.items.map((i) => i.id)).toEqual([201]);
    expect(body.meta.total).toBe(1);
  });

  it("★#6e（対照）`deleted_at` で絞ると施設2 が解決できなくなる★（流用してはいけない理由）", async () => {
    // ★解決用の読み取りは論理削除された施設2 も返す★
    expect((await db.facilityNameIndex()).map((f) => f.id)).toEqual([1, 2, 3]);

    // ★対照★: `prismaSource.facilities()` が付けている `where: { deleted_at: null }` を
    //   同じスタブに投げると ★施設2 が消える★ → その名前で検索してもログが引けなくなる
    const prisma = (await getPrisma()) as unknown as {
      facility: {
        findMany(args: unknown): Promise<{ id: bigint }[]>;
      };
    };
    const visible = await prisma.facility.findMany({
      where: { deleted_at: null },
      select: { id: true, facility_name: true },
    });
    expect(visible.map((f) => Number(f.id))).toEqual([1, 3]);
  });

  it("★★#6b 1件も当たらない施設名 → 0件★★（★`in: undefined` に化けていないことの検出。罠1★）", async () => {
    const body = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`);

    // ★★`in: []` が渡っていること（`in: undefined` なら全件になる）★★
    expect(mainQueryWhere().facility_id).toEqual({ in: [] });
    expect(body.items).toEqual([]);
    expect(body.meta.total).toBe(0);
  });

  it("★#6c 施設名を指定しない → `facility_id` の条件を付けない（全件）★", async () => {
    const body = await get("");
    // ★★`in: []` を渡さないこと★★ — 渡すと施設名未指定で0件になる
    expect(mainQueryWhere()).not.toHaveProperty("facility_id");
    expect(body.meta.total).toBe(5);
    // ★施設名が空なら施設マスタを読みにいかない（余計な往復を増やさない）★
    expect(calls.facility).toEqual([]);
  });

  it("★機種・シリアルは据え置き（SQL に降ろさずメモリで絞る）★", async () => {
    const body = await get("?serial=8111R56280600");
    expect(mainQueryWhere()).not.toHaveProperty("section");
    expect(body.meta.total).toBe(5);
  });
});

describe("関連テーブルの引き直し（指示書AC 段階2 D-4 / §E-5）", () => {
  it("★履歴は本清掃の id、エラー報告は事前チェック行の id で引く（全件読みに戻さない）★", async () => {
    await get("?date_from=2026-09-10&date_to=2026-09-10");

    // ★`select` の無い1本 ＝ `cleanLogs()` 本体の引き直し★（`select` 付きは連続日数用。
    //   取得元が `clean_task_reports` になって連続日数用も `where` を持つので、`where` だけでは区別できない）
    const reportCall = calls.cleanTaskReport.find((c) => "where" in c && !("select" in c));
    expect(reportCall).toBeDefined();
    const historyWhere = (
      reportCall as {
        where: { clean_timetable_id: { in: bigint[] } };
      }
    ).where;
    expect(historyWhere.clean_timetable_id.in.map(String).sort()).toEqual(
      ["103", "201", "301"].sort()
    );

    const errorWhere = (
      calls.robotErrorReport[0] as { where: { clean_timetable_id: { in: bigint[] } } }
    ).where;
    expect(errorWhere.clean_timetable_id.in.map(String).sort()).toEqual(
      ["1031", "1032"].sort()
    );
  });

  it("★事前チェック行は `parent_id` で別クエリに分けて引く★", async () => {
    await get("?date_from=2026-09-10&date_to=2026-09-10");
    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,
    });
  });

  it("★本清掃が0件ならクエリを1本も撃たない★（`chunkForInClause([])` が空を返す）", async () => {
    const body = await get("?date_from=2026-01-01&date_to=2026-01-02");
    expect(body.items).toEqual([]);
    expect(calls.robotErrorReport).toEqual([]);
    // ★レポートの引き直しは0本（本清掃・連続日数用とも `in` の塊が0個）★
    expect(calls.cleanTaskReport.filter((c) => "where" in c)).toEqual([]);
  });
});

describe("清掃結果 `task_status` の取得元（指示書AQ §1-1 / §1-2 / §5-2 / 落とし穴1）", () => {
  it("★`clean_task_reports.status` の生値をそのまま返す（変換しない）★", async () => {
    data.reports = data.reports.map((r) =>
      r.clean_timetable_id === BigInt(201) ? { ...r, status: 6 } : r
    );
    const body = await get("");
    expect(body.items.find((i) => i.id === 201)?.task_status).toBe(6);
    expect(body.items.find((i) => i.id === 103)?.task_status).toBe(4);
  });

  it("★レポートの行が無い本清掃 → `task_status` は null・出発失敗・完了率 null★（§1-1）", async () => {
    data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201));
    const body = await get("");
    const item = body.items.find((i) => i.id === 201);
    // ★行ごと消えない（出発失敗として出る）★
    expect(item).toBeDefined();
    expect(item?.task_status).toBeNull();
    expect(item?.departed).toBe(false);
    expect(item?.completion_rate).toBeNull();
  });

  it("★★`clean_timetable_id` が NULL のレポートはどの清掃にも紐づかない★★（落とし穴1）", async () => {
    data.reports = data.reports.map((r) =>
      r.clean_timetable_id === BigInt(201) ? { ...r, clean_timetable_id: null } : r
    );
    const body = await get("");
    const item = body.items.find((i) => i.id === 201);
    expect(item).toBeDefined();
    expect(item?.task_status).toBeNull();
    expect(item?.departed).toBe(false);
    // ★他の清掃の結果を上書きしない★
    expect(body.items.find((i) => i.id === 301)?.completion_rate).toBe(80);
  });
});

describe("★#9 fixtures 版と Prisma 版が同じ引数で同じ結果を返す★（指示書AC D-5 / 禁止事項6）", () => {
  /**
   * ★同じデータに対して★、
   *   - Prisma 版 = ★SQL で絞った結果★
   *   - fixtures 版 = ★全件版 + `filterFixtureCleanLogs()`★
   * を突き合わせる（指示書AC G-3「等価テスト」）。
   * ★`null` と `[]` の両方で比べること★（罠9）。
   */
  it("日付範囲: SQL版 ＝ 全件版 + fixtures 版の絞り込み", async () => {
    const all = await get("");
    const sql = await get("?date_from=2026-09-09&date_to=2026-09-10");
    const viaFixtures = filterFixtureCleanLogs(all.items, {
      id: null,
      date_from: "2026-09-09",
      date_to: "2026-09-10",
      facility_ids: null,
    });
    expect(sql.items).toEqual(viaFixtures);
    expect(sql.items.length).toBeGreaterThan(0);
  });

  it("`facility_ids: null`（施設名なし）: 件数が一致", async () => {
    const all = await get("");
    expect(
      filterFixtureCleanLogs(all.items, {
        id: null,
        date_from: null,
        date_to: null,
        facility_ids: null,
      })
    ).toHaveLength(all.items.length);
  });

  it("★`facility_ids: []`: どちらも0件★（★片方だけ全件に化けないこと★）", async () => {
    const all = await get("");
    const sql = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`);
    const viaFixtures = filterFixtureCleanLogs(all.items, {
      id: null,
      date_from: null,
      date_to: null,
      facility_ids: [],
    });
    expect(sql.items).toEqual([]);
    expect(viaFixtures).toEqual([]);
  });

  it("施設IDで絞った場合も一致", async () => {
    const all = await get("");
    const sql = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);
    const viaFixtures = filterFixtureCleanLogs(all.items, {
      id: null,
      date_from: null,
      date_to: null,
      facility_ids: [1, 2],
    });
    expect(sql.items).toEqual(viaFixtures);
  });
});

describe("GET /clean-logs — robot_id が NULL のエラー行（指示書AU 段階0-4。第11回 P-8 b）", () => {
  it("★robot_id が NULL の行を混ぜても落ちず、同じ行が出る★（select で robot_id / robot を読まない）", async () => {
    const before = await get("");
    data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null }));
    const res = await GET(new Request("http://localhost/api/clean-logs"));
    expect(res.status).toBe(200);
    const after = (await res.json()) as ListResponse<CleanLogItem>;
    expect(after.items).toEqual(before.items);
  });
});

describe("GET /clean-logs — 予定を2表から読む（指示書AU 段階3b。第11回 P-7）", () => {
  it("★履歴だけの予定が出る・両方にある同じ予定は1件★。2表に同じ引数が渡る", async () => {
    data.historyTimetables = [
      // ★履歴だけの本清掃（9/11）とその事前チェック行★
      timetable(401, 3, FACILITY_3, "2026-09-11T02:00:00+09:00"),
      timetable(4011, 3, FACILITY_3, "2026-09-11T01:00:00+09:00", 60, 401),
      // ★両方にある同じ予定（中身も同じ）★
      timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"),
    ];
    data.reports.push(report(401, 80, "2026-09-11T03:00:00+09:00"));

    const body = await get("");
    expect(body.items.map((i) => i.id)).toEqual([401, 301, 103, 201, 102, 101]);
    expect(body.items.find((i) => i.id === 401)?.completion_rate).toBe(80);

    // ★本清掃の主クエリ（`include` 付き）は2表に同じ引数★
    const mainCall = calls.cleanTimetable.find((c) => "include" in c);
    const historyCall = calls.cleanTimetableHistory.find((c) => "include" in c);
    expect(historyCall).toEqual(mainCall);
    // ★事前チェック行は合わせた後の本清掃の id（履歴の 401 を含む）で引く★
    const precheckHistoryCall = calls.cleanTimetableHistory.find(
      (c) => "where" in c && "parent_id" in (c.where as object)
    ) as { where: { parent_id: { in: bigint[] } } };
    expect(precheckHistoryCall.where.parent_id.in.map(String)).toContain("401");
  });
});

describe("GET /clean-logs — 結果待ち `result_pending`（D-173。表1・表2 C1）", () => {
  // ★施設2の 9/10 02:00〜03:00 の清掃（id 201）からレポートを外し、now を動かす★
  beforeEach(() => {
    data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201));
  });

  it("★now=04:59（終了＋120分より前）→ R: `result_pending: true`。出発失敗の値は変えない★", async () => {
    vi.setSystemTime(new Date("2026-09-10T04:59:00+09:00"));
    const body = await get("");
    const item = body.items.find((i) => i.id === 201);
    expect(item).toMatchObject({
      result_pending: true,
      departed: false,
      task_status: null,
      completion_rate: null,
      grade: "×",
    });
    // ★レポートがある行（S）は false★
    expect(body.items.find((i) => i.id === 103)?.result_pending).toBe(false);
    // ★結果待ちは連続日数に数えない（表2 C10）★
    expect(item?.consecutive_problem_days).toBe(0);
    expect(item?.escalation_level).toBeNull();
  });

  it("★now=05:00（終了＋120分ちょうど）→ F: `result_pending: false`（確定した出発失敗。表1 P6）★", async () => {
    vi.setSystemTime(new Date("2026-09-10T05:00:00+09:00"));
    const body = await get("");
    const item = body.items.find((i) => i.id === 201);
    expect(item).toMatchObject({ result_pending: false, departed: false, completion_rate: null });
    expect(item?.consecutive_problem_days).toBe(1);
  });

  it("★清掃中（now=02:30。開始以降・終了前）も結果待ち（表1 P4）★", async () => {
    vi.setSystemTime(new Date("2026-09-10T02:30:00+09:00"));
    const body = await get("");
    expect(body.items.find((i) => i.id === 201)?.result_pending).toBe(true);
  });
});

describe("GET /clean-logs — 事前チェックの列は分数ではなく順番で割り当てる（D-178。ユーザー判断 2026-10-16）", () => {
  // ★本清掃 103 の事前チェック行（1031: 60分前・EmergencyStop / 1032: 30分前・エラー無し）の分数だけを差し替える★
  function setPrecheckMinutes(byId: Record<number, number>): void {
    data.timetables = data.timetables.map((t) =>
      byId[Number(t.id)] === undefined ? t : { ...t, precheck: byId[Number(t.id)] }
    );
  }
  async function row103(): Promise<CleanLogItem> {
    const r = (await get("")).items.find((i) => i.id === 103);
    if (!r) throw new Error("清掃 103 が無い");
    return r;
  }
  function precheckAnomalies() {
    return vi
      .mocked(logEvent)
      .mock.calls.filter(
        ([, p]) => p.event === "data.anomaly" && p.metric === "precheck_rounds_per_main_run"
      );
  }

  it("60 / 30（設定どおり）→ 1回目 EmergencyStop・60、2回目 Standby.・30", async () => {
    const r = await row103();
    expect(r).toMatchObject({
      status_1h: "EmergencyStop",
      status_1h_minutes: 60,
      status_30m: "Standby.",
      status_30m_minutes: 30,
    });
    expect(precheckAnomalies()).toHaveLength(0);
  });

  it("★60 / 10（設定と違う分数）でも10分前の結果を落とさない★（旧: 2列目が null で「-」だった）", async () => {
    setPrecheckMinutes({ 1032: 10 });
    expect(await row103()).toMatchObject({
      status_1h: "EmergencyStop",
      status_1h_minutes: 60,
      status_30m: "Standby.",
      status_30m_minutes: 10,
    });
  });

  it("★事前チェックが1回（30分前）だけなら1回目に入り、2回目は null★", async () => {
    data.timetables = data.timetables.filter((t) => Number(t.id) !== 1031);
    expect(await row103()).toMatchObject({
      status_1h: "Standby.",
      status_1h_minutes: 30,
      status_30m: null,
      status_30m_minutes: null,
    });
  });

  it("事前チェックの無い本清掃は4つとも null", async () => {
    const r = (await get("")).items.find((i) => i.id === 101);
    expect(r).toMatchObject({
      status_1h: null,
      status_1h_minutes: null,
      status_30m: null,
      status_30m_minutes: null,
    });
  });

  it("★3つあれば開始に近い2つを残し、捨てた分数を `data.anomaly` に1回出す★（route / method は載せない）", async () => {
    data.timetables.push(timetable(1033, 1, FACILITY_1, "2026-09-10T00:30:00+09:00", 90, 103));
    expect(await row103()).toMatchObject({
      status_1h: "EmergencyStop",
      status_1h_minutes: 60,
      status_30m: "Standby.",
      status_30m_minutes: 30,
    });
    const events = precheckAnomalies();
    expect(events).toHaveLength(1);
    expect(events[0][0]).toBe("warn");
    expect(events[0][1]).toEqual({
      event: "data.anomaly",
      metric: "precheck_rounds_per_main_run",
      expected: 2,
      actual: 3,
      clean_timetable_id: "103",
      dropped_precheck_minutes: [90],
    });
  });

  it("★判定（出発・完了率・判定）は事前チェックの割り当てで変わらない★（D-170）", async () => {
    const before = await row103();
    setPrecheckMinutes({ 1031: 2, 1032: 1 });
    const after = await row103();
    expect(after).toMatchObject({ status_1h_minutes: 2, status_30m_minutes: 1 });
    expect({
      departed: after.departed,
      completion_rate: after.completion_rate,
      grade: after.grade,
      consecutive_problem_days: after.consecutive_problem_days,
    }).toEqual({
      departed: before.departed,
      completion_rate: before.completion_rate,
      grade: before.grade,
      consecutive_problem_days: before.consecutive_problem_days,
    });
  });
});
