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 } from "@/lib/config";
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;
  facility: { facility_name: string };
  section: {
    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[];
}

/** `findMany()` に渡された引数の記録（★どんな `where` が発行されたかを検査する★） */
const calls: {
  cleanTimetable: Record<string, unknown>[];
  cleanTimetableHistory: Record<string, unknown>[];
  cleanTaskReport: Record<string, unknown>[];
} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [] };

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;
    } 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 }));

/** `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` だけを見る）★
    robotErrorReport: { findMany: async () => [] },
    cleanSchedule: { findMany: async () => [] },
    controlErrorHistory: { findMany: async () => [] },
  } 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 },
    section: {
      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"),
    ],
  };
}

/** ★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 = [];
  globalThis.__dwatchPrisma = prismaStub();
  // ★`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("★`?date=2026-09-29` ではメタを読まない★（0回）", async () => {
    await get("?date=2026-09-29");
    expect(metaCalls()).toHaveLength(0);
  });

  it("★schedule_progress は直近モードと `?date=2026-09-30` で同じ★（当日のまま。4-1）", async () => {
    const latest = await get("");
    const ofDay = await get(`?date=${TODAY}`);
    expect(latest.schedule_progress).toEqual(ofDay.schedule_progress);
  });
});

/**
 * ★結果待ち（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([]);
  });
});
