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

// ★★ルート本体は**静的 import** すること★★（`app/api/clean-logs/__tests__/route.test.ts` 冒頭の
//   理由と同じ。`it()` の中で `await import()` するとフルスイートでタイムアウトを踏む）
import { GET } from "@/app/api/dashboard/route";
import { CLEAN_TIMETABLE_PRECHECK, D_SEC_ALERT_ERROR_TYPES } from "@/lib/config";
import { db } from "@/lib/db";
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 { ROBOT_ERROR_DS_ALERT_SELECT } from "@/lib/db/robot-error-select";
import { dsAlertWhere } from "@/lib/db/ds-alert-mapping";
import type { DashboardResponse } from "@/lib/types";

/**
 * GET /dashboard が ★その1日ぶんだけ★ を引いていることを固定する（指示書AC 段階2 D-6）。
 * ★`date` 省略（＝直近モード。D-159 / 指示書AV）では、表3 の窓（8日ぶん）に絞られていること★と、
 * 直近モードの `summary` / `problem_logs` / メタの `where` も固定する（ファイル末尾の `describe`）。
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 * ★`?date=`（★値が空★）のときに ★全件読みへ戻らない★ こと★。
 *   `const date = dateRaw ?? todayJst()` と書くと ★`""` は null でも undefined でもない★ ため
 *   ★空文字がそのまま残り★、`db.cleanLogs({ date_from: "", date_to: "" })` が
 *   `dbDateRange("", "")` を通って ★`start_at` の条件ごと消え＝テーブル全件読み★ になります
 *   （本番想定で `clean_timetables` 約26万行）。
 *   ★★レスポンスは後段の日付一致（`logsOfDay`）で空になるので画面からは観測できません★★ —
 *   ★このテストが唯一の検出手段★です。
 *
 * ------------------------------------------------------------------------
 * ★★DBには接続しません★★
 * ------------------------------------------------------------------------
 * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
 * ★`prismaSource` の経路★ を走らせる（段階2 / 段階4 のルートテストと同じ作法）。
 *
 * ★★ただしスタブの入れ方だけが兄弟テストと違います★★ —
 *   兄弟（`/clean-logs` / `/prechecks`）は `vi.mock("@/lib/db/prisma")` で `getPrisma()` を
 *   差し替えていますが、★このルートは3つのデータ源を `Promise.all` で**同時に**引きます★。
 *   `prismaSource` の各メソッドは `await import("./prisma")` の★遅延読み込み★なので
 *   （`lib/db/prisma.ts` 冒頭の理由）、★★同じモジュールへの動的 import が同時に走ると
 *   一部が差し替え前の実体を掴み★★、`DATABASE_URL` 未設定で 500 になります（実測）。
 *   そこで ★`getPrisma()` が最初に見るシングルトン `globalThis.__dwatchPrisma` に
 *   直接スタブを置きます★ — ★実装（`lib/db/prisma.ts`）の公開された仕組みをそのまま使うので
 *   DBにも接続しません★。★`afterEach` で必ず片付けること★。
 */

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

interface StubTimetable {
  id: bigint;
  facility_id: bigint;
  section_id: bigint;
  parent_id: bigint;
  start_at: Date;
  /** ★予定の終了★（直近モードの `cleanLogRunMeta()` が読む。指示書AV 段階4-5。開始＋1時間） */
  end_at: Date;
  precheck: number;
  /** ★`deleted_at` は可視性（`FACILITY_SECTION_VISIBLE_WHERE`）の再現用★（指示書BB U1） */
  facility: { facility_name: string; deleted_at: Date | null };
  section: {
    deleted_at: Date | null;
    robots: {
      serial_number: string;
      device: { maker_id: number; model_name: string };
    }[];
  };
}

/**
 * ★清掃結果の取得元は `clean_task_reports`★（指示書AQ 決定2/3。旧 `clean_status_histories`）。
 *   ★`clean_error` 列は無い★ — 未実施は「行が無い」で表す（指示書AQ §1-1）。
 *   `clean_timetable_id` / `ended_at` は nullable（指示書AQ 罠1 / 罠3）。
 */
interface StubReport {
  clean_timetable_id: bigint | null;
  clean_ratio: number;
  status: number;
  started_at: Date;
  ended_at: Date | null;
  clean_area: number;
  task_area: number;
}

interface StubData {
  timetables: StubTimetable[];
  reports: StubReport[];
  /** `robot_error_reports` のうち D-Sec アラートの読み取りに返す行（既定は空） */
  dsAlertRows: Record<string, unknown>[];
}

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

let data: StubData;
/** D-Sec アラートの読み取りの `where`（指示書BB 表10 N7） */
let dsAlertWheres: unknown[] = [];

/** 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;
      // ★リレーションの条件（`facility: { deleted_at: null }` など。可視性 U1）★ — 行が結合先を持つときだけ再現する
      //   （持たない行＝結合先を `include` しない読み取りは、従来どおり素通し）
      if (!("in" in c) && !("gte" in c) && !("lt" in c) && value !== null && typeof value === "object" && !(value instanceof Date)) {
        if (!matchesWhere(value as Record<string, unknown>, c)) 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 A2）を見るため `logEvent` だけ差し替える（`withRequestId` はそのまま）★
vi.mock("@/lib/logger", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/logger")>();
  return { ...actual, logEvent: vi.fn() };
});

/** `getPrisma()` が最初に見るシングルトンへ差し込むスタブ（上の「スタブの入れ方」を参照） */
function prismaStub(): typeof globalThis.__dwatchPrisma {
  return {
    cleanTimetable: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
    },
    // ★`clean_timetable_histories`（指示書AU 段階3b）。★このテストでは空★（2表に同じ引数が渡ることだけ見る）
    cleanTimetableHistory: {
      findMany: async (args?: Record<string, unknown>) => findMany("cleanTimetableHistory", [], args),
    },
    cleanTaskReport: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("cleanTaskReport", data.reports as unknown as Record<string, unknown>[], args),
    },
    // ★要レスキュー（指示書AI 段階5）が施設名の解決に使う★
    //   （`loadFacilityNameIndex()`。★`where` を付けない＝全件★）
    facility: {
      findMany: async () => [
        { id: BigInt(1), facility_name: FACILITY_1 },
        { id: BigInt(2), facility_name: FACILITY_2 },
      ],
    },
    // ★このテストで中身は要らない（`db.cleanLogs()` の `where` だけを見る）★。
    //   ★D-Sec アラート（`dsAlerts()`）の読み取りにだけ `dsAlertRows` を返す★（`where` を無視。指示書AX 表1 R10）
    robotErrorReport: {
      findMany: async (args?: Record<string, unknown>) => {
        if (args?.select !== ROBOT_ERROR_DS_ALERT_SELECT) return [];
        dsAlertWheres.push(args.where);
        guardNullRobotId(data.dsAlertRows as unknown as Record<string, unknown>[], args);
        return data.dsAlertRows;
      },
    },
    // ★`clean_schedules`（`schedules()`）。★ダッシュボードはもう読まない★（2026-10-29 案B）— 読まれたら記録に残る（S7）★
    cleanSchedule: {
      findMany: async (args?: Record<string, unknown>) => {
        calls.cleanSchedule.push(args ?? {});
        return [];
      },
    },
    // ★`controlErrorHistory` は置かない★ — D-Sec アラートは `robot_error_reports` から読む（指示書AX。P-10 = A）
  } as unknown as typeof globalThis.__dwatchPrisma;
}

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

/** 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 = "バリューマート北町店";
/** ★対象日に清掃予定が無い施設★（要レスキューの検証用。指示書AI §1-5） */
const FACILITY_2 = "メガバリューマート緑野店";

function timetable(id: number, facilityId: number, jstIso: string): StubTimetable {
  return {
    id: BigInt(id),
    facility_id: BigInt(facilityId),
    section_id: BigInt(facilityId * 10),
    parent_id: BigInt(0),
    start_at: dbWallClock(jstIso),
    // ★開始＋1時間★（壁時計値のまま足す。直近モードの「終わった」の判定に使う。指示書AV 段階4-5）
    end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
    precheck: CLEAN_TIMETABLE_PRECHECK.main,
    facility: { facility_name: FACILITY_1, deleted_at: null },
    section: {
      deleted_at: null,
      robots: [
        {
          serial_number: `8111R56280600${facilityId}`,
          device: { maker_id: 1, model_name: "CC1" },
        },
      ],
    },
  };
}

function report(timetableId: number, cleanRatio: number, jstIso: string): StubReport {
  return {
    clean_timetable_id: BigInt(timetableId),
    clean_ratio: cleanRatio,
    // ★意味を持たない任意の生値★（CSV実測で最頻の値。status は判定に使わない。D-155 項2）。
    //   ★未実施の判定には使われない★（行の有無で決まる。§1-1）
    status: 4,
    started_at: dbWallClock(jstIso),
    ended_at: dbWallClock(jstIso),
    clean_area: 100,
    task_area: 100,
  };
}

function buildData(): StubData {
  return {
    timetables: [
      timetable(101, 1, "2026-09-28T02:00:00+09:00"),
      timetable(102, 1, "2026-09-29T02:00:00+09:00"),
      // ★`NOW` と同じ日（`todayJst()` が返す日）★
      timetable(103, 1, "2026-09-30T02:00:00+09:00"),
      // ★★施設2は 09-27〜09-29 が3日連続で未実施、当日（09-30）には予定が無い★★
      //   （レポートの行を作らない＝未実施。`notExecutedFromReport()`。指示書AQ §1-1）—
      //   ★要レスキューが「当該日のログ」で母集団を絞っていないことの検証用★（指示書AI §1-5）
      timetable(201, 2, "2026-09-27T02:00:00+09:00"),
      timetable(202, 2, "2026-09-28T02:00:00+09:00"),
      timetable(203, 2, "2026-09-29T02:00:00+09:00"),
    ],
    reports: [
      report(101, 95, "2026-09-28T03:00:00+09:00"),
      report(102, 95, "2026-09-29T03:00:00+09:00"),
      report(103, 95, "2026-09-30T03:00:00+09:00"),
    ],
    dsAlertRows: [],
  };
}

/** ★JST の 2026-09-30★（`todayJst()` がこの日を返す） */
const NOW = new Date("2026-09-30T09:00:00+09:00");
const TODAY = "2026-09-30";

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

/** 最後に発行された `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;
}

/** ★その1日ぶんに絞られているか★ を「壁時計値の範囲」で確かめる */
function expectSingleDayRange(where: Record<string, unknown>, date: string): void {
  expectDayRange(where, date, date);
}

/** ★`from`〜`to`（両端を含む）に絞られているか★ を「壁時計値の範囲」で確かめる */
function expectDayRange(where: Record<string, unknown>, from: string, to: string): void {
  expect(where).toHaveProperty("start_at");
  const startAt = where.start_at as { gte: Date; lt: Date };
  // ★★`toDbJst()` を通した壁時計値であること★★（C-6。★生の `parseDate()` ではない★）
  expect(startAt.gte.toISOString()).toBe(`${from}T00:00:00.000Z`);
  expect(startAt.lt.toISOString()).toBe(
    new Date(Date.parse(`${to}T00:00:00.000Z`) + 86_400_000).toISOString()
  );
}

/** ★直近モードの窓（指示書AV 表3 W4。`NOW` = 2026-09-30 09:00 JST）★ */
const LATEST_WINDOW = { from: "2026-09-23", to: TODAY } as const;

/**
 * ★メタ（`cleanLogRunMeta()`）の呼び出し★ ＝ `select` のキーがちょうど `id` / `section_id` / `end_at` の3つ。
 * ★「`select` 付き」だけでは見分けられない★ — 要レスキュー（`loadConsecutiveInputs()` など）も
 * `cleanTimetable.findMany` を `select` 付きで呼ぶ（指示書AV 段階4-5）。
 */
function metaCalls(): Record<string, unknown>[] {
  return calls.cleanTimetable.filter((c) => {
    const select = c.select as Record<string, unknown> | undefined;
    return select !== undefined && Object.keys(select).sort().join(",") === "end_at,id,section_id";
  });
}

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

afterEach(() => {
  // ★★他のテストへ漏らさないこと★★（`getPrisma()` はここを最初に見る）
  globalThis.__dwatchPrisma = undefined;
  vi.useRealTimers();
});

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

describe("GET /dashboard — その1日ぶんだけを引く（指示書AC 段階2 D-6）", () => {
  it("`date` を指定すると SQL の `where` にその1日の `start_at` が降りる", async () => {
    await get("?date=2026-09-29");
    expectSingleDayRange(mainQueryWhere(), "2026-09-29");
  });

  it("★予定は2表から読み、履歴にも同じ引数が渡る★（指示書AU 段階3b。第11回 P-7）", async () => {
    await get("?date=2026-09-29");
    const mainCall = calls.cleanTimetable.find((c) => "include" in c);
    expect(calls.cleanTimetableHistory.find((c) => "include" in c)).toEqual(mainCall);
  });

  it("直近モード（`date` 省略）は表3 W4 の窓（2026-09-23〜2026-09-30）で `start_at` が絞られる（指示書AV）", async () => {
    await get("");
    expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
  });

  it("★★`?date=`（★空文字★）でも直近モードの窓に倒れ、全件読みに戻らない★★", async () => {
    // ★`??` で書くと `""` がそのまま残り、`start_at` のキーごと消えて
    //   ★テーブル全件読み★ になる（本番想定で約26万行）。
    //   ★★レスポンスからは観測しにくい★★ので、★`start_at` の条件が消えないこと★をここで見る
    const body = await get("?date=");
    expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
    // ★当日の行（103）が拾えていること＝空振りしていないこと★
    const { success, failed, not_executed } = body.summary;
    expect(success + failed + not_executed).toBeGreaterThan(0);
  });

  it("★`?date=` と `date` 省略が同じ結果になる★", async () => {
    const omitted = await get("");
    const empty = await get("?date=");
    expect(empty).toEqual(omitted);
  });

  it("形式が不正な `date` は 422 で弾かれ、`db` まで来ない", async () => {
    const res = await GET(new Request("http://localhost/api/dashboard?date=2026/09/30"));
    expect(res.status).toBe(422);
    expect(calls.cleanTimetable).toHaveLength(0);
    expect(calls.cleanTimetableHistory).toHaveLength(0);
  });
});

describe("GET /dashboard — 要レスキュー店舗（指示書AI 段階5）", () => {
  it("★当該日に清掃予定が無い施設も出る（評価日は直前のスケジュール存在日）★", async () => {
    const body = await get(`?date=${TODAY}`);

    // ★施設2は 09-30 に予定が無い★ — 当該日のログで母集団を絞っていたら消える
    expect(body.rescue_required).toHaveLength(1);
    expect(body.rescue_required[0]).toMatchObject({
      facility_id: 2,
      facility_name: FACILITY_2,
      evaluated_on: "2026-09-29",
      reasons: ["not_executed_streak", "escalation_l3"],
      not_executed_days: 3,
      consecutive_problem_days: 3,
    });
  });

  it("★数値カード（summary）は要レスキューの影響を受けない★", async () => {
    const body = await get(`?date=${TODAY}`);

    // ★当日のログは施設1の1行だけ★（施設2の未実施3日は分母に入らない。ユーザー指示 2026-09-15）
    const { success, failed, not_executed } = body.summary;
    expect([success, failed, not_executed]).toEqual([1, 0, 0]);
  });

  it("要レスキューが0件でも `rescue_required` のキーは落とさない", async () => {
    // ★施設2の3日連続が始まる前の日を指定する★（＝どの施設も条件に当たらない）
    const body = await get("?date=2026-09-28");

    expect(body.rescue_required).toEqual([]);
  });
});

describe("GET /dashboard — 直近モード（D-159 / 指示書AV 段階4-5）", () => {
  // ★期待値は指示書AV で導いた値（§11 Q10 で監督が確定）★:
  //   施設1（エリア10）は 09-30 02:00〜03:00 が一番新しい終わった予定 ＝ 成功（完了率100）。
  //   施設2（エリア20）は 09-29 02:00〜03:00 ＝ レポートが無い ＝ 未実施。
  it("summary は各清掃エリアの直近の清掃（成功1・未実施1）", async () => {
    const body = await get("");
    expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 });
  });

  it("同じデータで `?date=2026-09-30` はその日の行だけ（[1, 0, 0] のまま）", async () => {
    const body = await get(`?date=${TODAY}`);
    const { success, failed, not_executed } = body.summary;
    expect([success, failed, not_executed]).toEqual([1, 0, 0]);
  });

  it("problem_logs は直近モードで施設2の 09-29 の1行だけ。`?date=2026-09-30` では []", async () => {
    const latest = await get("");
    expect(latest.problem_logs.map((r) => r.id)).toEqual([203]);
    expect(latest.problem_logs[0]).toMatchObject({
      facility_id: 2,
      scheduled_at: "2026-09-29T02:00:00+09:00",
      departed: false,
      // ★`capCleanLogs()` を通っている★（指示書AV D13）
      consecutive_problem_days_capped: false,
    });

    const ofDay = await get(`?date=${TODAY}`);
    expect(ofDay.problem_logs).toEqual([]);
  });

  it("★メタの `where` は清掃ログ（`include` 付きの1本目）の `where` と同じ★（4-8）", async () => {
    await get("");
    const meta = metaCalls();
    expect(meta).toHaveLength(1);
    expect(meta[0].where).toEqual(mainQueryWhere());
  });

  it("★E1′: メタが1行ぶん欠けても 200。その行は選ばれず、`data.anomaly` を1回（row_ids つき）★（指示書BB A2）", async () => {
    // ★`cleanLogs()` と `cleanLogRunMeta()` の間に行が増えた状態★ — 施設1の 09-30（103）のメタだけ返さない
    const original = db.cleanLogRunMeta.bind(db);
    const spy = vi
      .spyOn(db, "cleanLogRunMeta")
      .mockImplementation(async (query) => (await original(query)).filter((m) => m.row_key !== "tt-103"));
    try {
      const res = await GET(new Request("http://localhost/api/dashboard"));
      expect(res.status).toBe(200);
      const body = (await res.json()) as DashboardResponse;
      // ★103 が選ばれないので、施設1は1つ前の 09-29（102。成功）が直近になる★
      expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 });
      const events = vi
        .mocked(logEvent)
        .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing");
      expect(events).toHaveLength(1);
      expect(events[0][1]).toEqual({
        event: "data.anomaly",
        route: "/api/dashboard",
        method: "GET",
        metric: "clean_log_run_meta_missing",
        expected: 0,
        actual: 1,
        // ★`row_key` を出す★（指示書BG §2-2。予定外の行は id が無いため。旧: "103"）
        row_ids: ["tt-103"],
      });
    } finally {
      spy.mockRestore();
    }
  });

  it("メタが揃っていれば `clean_log_run_meta_missing` を出さない", async () => {
    await get("");
    const events = vi
      .mocked(logEvent)
      .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing");
    expect(events).toHaveLength(0);
  });

  it("★`?date=2026-09-29` ではメタを読まない★（0回）", async () => {
    await get("?date=2026-09-29");
    expect(metaCalls()).toHaveLength(0);
  });

});

/**
 * ★スケジュール進捗（ユーザー判断 2026-10-29「案B」）★ — 期待値表（`NOW` = 2026-09-30 09:00 JST）:
 * | # | 指定 | 対象日 | 対象日の本清掃 | planned | completed | rate |
 * |---|---|---|---|---|---|---|
 * | S1 | 省略（直近モード） | 09-29（前日） | 102（出発）/ 203（未実施） | 2 | 1 | 50 |
 * | S2 | `?date=2026-09-29` | 09-29 | 同上 | 2 | 1 | 50 |
 * | S3 | `?date=2026-09-30` | 09-30 | 103（出発） | 1 | 1 | 100 |
 * | S4 | `?date=2026-09-30` ＋ 施設3の結果待ち 301 | 09-30 | 103 / 301（結果待ち） | 2 | 1 | 50 |
 * | S5 | `?date=2026-10-01`（予定なし） | 10-01 | なし | 0 | 0 | null |
 * | S6 | 省略 ＋ 施設2が削除済み | 09-29 | 102 だけ（203 は見えない） | 1 | 1 | 100 |
 * | S7 | 省略 ＋ `clean_schedules` に毎日の予定7件 | 09-29 | 102 / 203 | 2 | 1 | 50（★曜日の設定を数えない★） |
 */
describe("GET /dashboard — スケジュール進捗（2026-10-29 案B）", () => {
  it("S1 直近モードの対象日は前日（09-29）。当日（09-30）の行で数えない", async () => {
    const body = await get("");
    expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 2, completed: 1, rate: 50 });
  });

  it("S2 `?date=2026-09-29` は S1 と同じ", async () => {
    const latest = await get("");
    const ofDay = await get("?date=2026-09-29");
    expect(ofDay.schedule_progress).toEqual(latest.schedule_progress);
  });

  it("S3 `?date=2026-09-30` はその日（前日にしない）", async () => {
    const body = await get(`?date=${TODAY}`);
    expect(body.schedule_progress).toEqual({ date: TODAY, planned: 1, completed: 1, rate: 100 });
  });

  it("S4 結果待ちは planned にだけ入る", async () => {
    data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00"));
    const body = await get(`?date=${TODAY}`);
    expect(body.schedule_progress).toEqual({ date: TODAY, planned: 2, completed: 1, rate: 50 });
  });

  it("S5 対象日に予定が無ければ planned 0・rate null", async () => {
    const body = await get("?date=2026-10-01");
    expect(body.schedule_progress).toEqual({ date: "2026-10-01", planned: 0, completed: 0, rate: null });
  });

  it("S6 削除済みの施設の予定は数えない", async () => {
    for (const t of data.timetables) {
      if (t.facility_id === BigInt(2)) t.facility.deleted_at = dbWallClock("2026-09-01T00:00:00+09:00");
    }
    const body = await get("");
    expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 1, completed: 1, rate: 100 });
  });

  it("S7 `clean_schedules`（曜日の設定）は読まない — 予定件数は予定の表の行から", async () => {
    const body = await get("");
    expect(calls.cleanSchedule).toHaveLength(0);
    expect(body.schedule_progress.planned).toBe(2);
    await get(`?date=${TODAY}`);
    expect(calls.cleanSchedule).toHaveLength(0);
  });
});

/**
 * ★結果待ち（D-173。ユーザー判断 2026-10-07）★ — 表2 C6・C7（監督確定）。
 *   `NOW` = 09-30 09:00。★施設3の 09-30 07:30〜08:30 にレポートが無い★ —
 *   ★終了＋`RESULT_WAIT_MINUTES`（120分）＝ 10:30 より前なので結果待ち★（`isResultPending()` 表1 P5 と同じ形）。
 *   ★終了（08:30）≤ 現在（09:00）なので直近の候補になる★（C6。30分待ちはやめた）。
 *   ★`result_pending` はデータソース（`prismaSource.cleanLogs()`）が詰める★ — ここでは数え方と `problem_logs` だけを見る。
 */
describe("GET /dashboard — 結果待ち（表2 C6・C7。D-173）", () => {
  beforeEach(() => {
    data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00"));
  });

  it("C6 直近モード: 施設3の結果待ちは pending に数え、未実施・成功率の分母に入れない", async () => {
    const body = await get("");
    expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 1, success_rate: 50 });
  });

  it("C7 problem_logs に結果待ちの行を出さない（確定した出発失敗 203 は今までどおり出す）", async () => {
    const body = await get("");
    expect(body.problem_logs.map((r) => r.id)).toEqual([203]);
  });

  it("C5・C7 `?date=2026-09-30` でも同じ（pending 1・problem_logs は空）", async () => {
    const body = await get(`?date=${TODAY}`);
    expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 0, pending: 1, success_rate: 100 });
    expect(body.problem_logs).toEqual([]);
  });
});

/**
 * ★D-Sec アラートの想定外の状態でダッシュボードを落とさない★（指示書AX 表1 R10・D3）。
 *   `db.dsAlerts()` はダッシュボードも呼ぶ。`status` 3 の行を状態の変換に通すと例外 → 全体が500になる。
 *   ★スタブは where を無視して返す★（SQL の `status IN` が効かなかった状態。正は純粋関数）。
 */
describe("GET /dashboard — D-Sec アラート（指示書AX 表1 R10）", () => {
  it("★status 3 の D-S_CONTROL_FAILURE 行（robot_id NULL）が返っても 200・バナーは空★", async () => {
    data.dsAlertRows = [
      {
        error_id: "R-10",
        robot_id: null,
        clean_timetable_id: null,
        serial_number: "DFAS010001",
        facility_id: BigInt(1),
        facility: { facility_name: FACILITY_1, deleted_at: null },
        section: { section_name: "1F売り場", deleted_at: null },
        error_type: D_SEC_ALERT_ERROR_TYPES[0],
        errored_at: dbWallClock("2026-09-30T02:00:00+09:00"),
        created_at: dbWallClock("2026-09-30T02:01:00+09:00"),
        status: 3,
        responded_at: null,
        resolved_at: null,
        comment: null,
      },
    ];
    const res = await GET(new Request(`http://localhost/api/dashboard?date=${TODAY}`));
    expect(res.status).toBe(200);
    const body = (await res.json()) as DashboardResponse;
    expect(body.security_banner).toEqual([]);
  });
});

describe("GET /dashboard — 削除済みの施設・清掃エリア（指示書BB U1・表1 V4/V5）", () => {
  it("★V4: 清掃ログ・直近のメタの `where` に可視性が入る★（V5 の予定件数も同じ清掃ログから数える。2026-10-29 案B）", async () => {
    await get("");
    expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    for (const m of metaCalls()) expect(m.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    // ★予定件数は `clean_schedules` を読まない★（スケジュール進捗の describe の S6・S7）
    expect(calls.cleanSchedule).toHaveLength(0);
  });
});

describe("GET /dashboard — 存在しない日付は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/dashboard?date=2026-02-31"));
    const format = await GET(new Request("http://localhost/api/dashboard?date=2026/02/03"));
    expect(bad.status).toBe(422);
    expect(format.status).toBe(422);
    expect(await bad.json()).toEqual(await format.json());
  });
});

// ★表10 N7（指示書BB A6・D17）★ — ダッシュボードの D-Sec は解消でないものだけ読む（バナーは解消でないものしか見ない）
describe("GET /dashboard — D-Sec アラートは未解消だけ読む（指示書BB 表10 N7）", () => {
  it("★`dsAlerts({ open_only: true })` — SQL の status は未解消・対応中（0/1）だけ★", async () => {
    await get("");
    expect(dsAlertWheres).toHaveLength(1);
    expect(dsAlertWheres[0]).toEqual(dsAlertWhere({ open_only: true }));
    expect((dsAlertWheres[0] as { status: { in: number[] } }).status.in).toEqual([0, 1]);
  });
});
