import { beforeEach, describe, expect, it, vi } from "vitest";

// ★★ルート本体は**静的 import** すること★★（`app/api/prechecks/__tests__/route.test.ts` と同じ理由）
import { GET } from "@/app/api/errors/route";
import { PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import { db as dataSource } from "@/lib/db";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { errorReportListWhere } from "@/lib/db/error-report-mapping";
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import { ROBOT_ERROR_LIST_KEY_SELECT, ROBOT_ERROR_LIST_ROW_SELECT } from "@/lib/db/robot-error-select";
import { toDbJst } from "@/lib/format/date";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, ErrorReportItem, ListResponse } from "@/lib/types";

/**
 * `GET /errors`（Prisma 経路。指示書AW 段階4-4-1）。
 *
 * ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定して
 * `prismaSource.errorReports()` の経路を走らせる（`app/api/prechecks/[id]/status/__tests__/route.test.ts` と同じ作法）。
 *
 * ★スタブの `robotErrorReport.findMany` は★
 *   - ★`guardNullRobotId()` を通す★（`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる）
 *   - ★`select` を見て返す列を絞る★（1段目と2段目で返す形が違う）
 *   - `where` の ★`status.in` / `facility_id.in` / `OR`（期間）/ `error_id.in`★ を評価する（★知らないキーは例外★）
 *
 * ★表1 は監督確定（D-160 / 指示書AW §3）。変えないこと★。表2 は指示書AW で導いたもの（監督確認済み）。
 *   入力は表1 の9行（`facility_id` は E1〜E5 = 1、E6〜E9 = 2）。
 *   ★E7 の `created_at`（10/6 08:00）は9行の中で一番新しい★ → E7 が先頭（`nulls: "last"` の並べ替えなら最後）。
 *   ★E4 = "E-10"・E5 = "E-9" は同じ検知時刻★ → 辞書順で "E-10" が先（数値順なら逆）。
 *   ★E6 の予定は履歴（`clean_timetable_histories`）にだけ置く★。
 */

// ---------------------------------------------------------------- スタブ（DBの代わり）

interface StubErrorReport {
  error_id: string;
  /** ★DDL では NULL 可（第11回 P-8 b）。読まないこと（指示書AU 段階0-4）★ */
  robot_id: bigint | null;
  clean_timetable_id: bigint | null;
  serial_number: string;
  facility_id: bigint;
  facility: { facility_name: string };
  error_type: string;
  error_level: string;
  errored_at: Date | null;
  created_at: Date;
  status: number;
  responded_at: Date | null;
  resolved_at: Date | null;
  comment: string | null;
}

interface StubTimetable {
  id: bigint;
  precheck: number;
}

interface StubFacility {
  id: bigint;
  facility_name: string;
  customer_id: bigint;
  customer: { group_id: bigint };
}

type Args = Record<string, unknown> & { where?: Record<string, unknown>; select?: Record<string, unknown> };

let errorReports: StubErrorReport[];
let mainTimetables: StubTimetable[];
let historyTimetables: StubTimetable[];
let facilityRows: StubFacility[];
/** `robotErrorReport.findMany` の引数の記録（1段目・2段目） */
let errorCalls: Args[];
/** `cleanTimetable.findMany` の引数と、返した id の記録 */
let mainTimetableCalls: { args: Args; returned: string[] }[];
let historyTimetableCalls: Args[];
/** ★2段目で消えたことにする error_id★（1段目と2段目の間に行が消えた。指示書AW 4-15） */
let vanishOnSecondStage: Set<string>;
/** 次の `robotErrorReport.findMany` を例外にする */
let throwOnErrorFindMany: boolean;

vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));

/** DB の既定の照合順序は大小文字を区別しない（ci）— スタブでも再現する（D-4） */
function ciEqual(a: string, b: string): boolean {
  return a.toLowerCase() === b.toLowerCase();
}

interface DateRange {
  gte?: Date;
  lt?: Date;
}

function inRange(value: Date | null, range: DateRange): boolean {
  if (value === null) return false;
  if (range.gte && value.getTime() < range.gte.getTime()) return false;
  if (range.lt && value.getTime() >= range.lt.getTime()) return false;
  return true;
}

/** `where` を評価する（★知らないキーは例外★ — ルートが別の条件を組み立てたら気づけるように） */
function matchErrorWhere(row: StubErrorReport, where: Record<string, unknown>): boolean {
  for (const [key, cond] of Object.entries(where)) {
    switch (key) {
      case "status":
        if (!(cond as { in: number[] }).in.includes(row.status)) return false;
        break;
      case "facility_id":
        if (!(cond as { in: bigint[] }).in.some((id) => id === row.facility_id)) return false;
        break;
      case "error_id":
        if (!(cond as { in: string[] }).in.some((id) => ciEqual(id, row.error_id))) return false;
        break;
      case "OR": {
        const ok = (cond as Record<string, unknown>[]).some((c) => {
          if (c.errored_at === null) {
            return row.errored_at === null && inRange(row.created_at, c.created_at as DateRange);
          }
          return inRange(row.errored_at, c.errored_at as DateRange);
        });
        if (!ok) return false;
        break;
      }
      default:
        throw new Error(`[stub] robotErrorReport.findMany の where に想定外のキー: ${key}`);
    }
  }
  return true;
}

/** `select` で返す列を絞る（入れ子の `{ select }` にも対応） */
function project(row: Record<string, unknown>, select: Record<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(select)) {
    if (value === true) out[key] = row[key];
    else if (value && typeof value === "object" && "select" in value) {
      out[key] = project(row[key] as Record<string, unknown>, (value as { select: Record<string, unknown> }).select);
    }
  }
  return out;
}

function timetableFindMany(rows: StubTimetable[], args: Args): StubTimetable[] {
  const ids = ((args.where?.id as { in: bigint[] } | undefined)?.in ?? []).map((id) => id.toString());
  return rows.filter((t) => ids.includes(t.id.toString()));
}

vi.mock("@/lib/db/prisma", () => {
  const read = {
    robotErrorReport: {
      findMany: async (args: Args) => {
        errorCalls.push(args);
        if (throwOnErrorFindMany) throw new Error("[stub] DB が落ちた");
        guardNullRobotId(errorReports as unknown as Record<string, unknown>[], args);
        const isSecondStage = args.where !== undefined && "error_id" in args.where;
        return errorReports
          .filter((row) => matchErrorWhere(row, args.where ?? {}))
          .filter((row) => !(isSecondStage && vanishOnSecondStage.has(row.error_id)))
          .map((row) => project(row as unknown as Record<string, unknown>, args.select ?? {}));
      },
      findUnique: async () => {
        throw new Error("[stub] 一覧で findUnique を呼んではいけない");
      },
    },
    cleanTimetable: {
      findMany: async (args: Args) => {
        const rows = timetableFindMany(mainTimetables, args);
        mainTimetableCalls.push({ args, returned: rows.map((r) => r.id.toString()) });
        return rows;
      },
    },
    cleanTimetableHistory: {
      findMany: async (args: Args) => {
        historyTimetableCalls.push(args);
        return timetableFindMany(historyTimetables, args);
      },
    },
    // ★施設名・分類の解決（`facility.findMany`）はここに置かない★ — 下の `beforeEach` で
    //   `db.facilityNameIndex()` / `db.facilityGroupIndex()` を差し替える（理由はそちらのコメント）
    facility: {
      findMany: async () => {
        throw new Error("[stub] facility.findMany は呼ばない（db の2メソッドを差し替えてある）");
      },
    },
  };
  return {
    getPrisma: async () => read,
    getWritePrisma: async () => {
      throw new Error("[stub] 一覧で書き込み用の接続を取ってはいけない");
    },
  };
});

// ---------------------------------------------------------------- 合成データ（表1）

/** `+09:00` 付きの ISO → DB の壁時計値の `Date`（Prisma が返す形） */
const db = (iso: string): Date => toDbJst(new Date(iso));

const UNRESOLVED = robotErrorStatusFromResolutionStatus("unresolved");
const IN_PROGRESS = robotErrorStatusFromResolutionStatus("in_progress");
const RESOLVED = robotErrorStatusFromResolutionStatus("resolved");
const PRECHECK_30 = PRECHECK_MINUTES_BEFORE[1];
// ★10 は設定（60/30）に無い値であることが本題なので直書きする★（D-105 項1）
const PRECHECK_10 = 10;

function row(overrides: Partial<StubErrorReport> & Pick<StubErrorReport, "error_id">): StubErrorReport {
  return {
    robot_id: BigInt(501),
    clean_timetable_id: BigInt(100),
    serial_number: "PD-CC1-0001",
    facility_id: BigInt(1),
    facility: { facility_name: "テスト施設A" },
    error_type: "EmergencyStop",
    error_level: "ERROR",
    errored_at: db("2026-10-05T19:00:00+09:00"),
    created_at: db("2026-10-05T19:01:00+09:00"),
    status: UNRESOLVED,
    responded_at: null,
    resolved_at: null,
    comment: null,
    ...overrides,
  };
}

const FACILITY_B = { facility_id: BigInt(2), facility: { facility_name: "テスト施設B" } };

function buildData() {
  errorReports = [
    row({ error_id: "E-1", errored_at: db("2026-10-05T19:05:00+09:00"), created_at: db("2026-10-05T19:06:00+09:00") }),
    row({
      error_id: "E-2",
      clean_timetable_id: BigInt(101),
      error_type: "LowBattery",
      errored_at: db("2026-10-05T19:30:00+09:00"),
      created_at: db("2026-10-05T19:31:00+09:00"),
      status: IN_PROGRESS,
      responded_at: db("2026-10-06T09:00:00+09:00"),
    }),
    row({
      error_id: "E-3",
      errored_at: db("2026-10-05T20:00:00+09:00"),
      created_at: db("2026-10-05T20:01:00+09:00"),
      status: RESOLVED,
      responded_at: db("2026-10-06T09:00:00+09:00"),
      resolved_at: db("2026-10-06T10:00:00+09:00"),
      comment: "対応済み",
    }),
    row({ error_id: "E-10", clean_timetable_id: null, errored_at: db("2026-10-05T21:00:00+09:00"), created_at: db("2026-10-05T21:01:00+09:00") }),
    row({ error_id: "E-9", clean_timetable_id: BigInt(999), errored_at: db("2026-10-05T21:00:00+09:00"), created_at: db("2026-10-05T21:02:00+09:00") }),
    row({ error_id: "E-6", ...FACILITY_B, clean_timetable_id: BigInt(106), errored_at: db("2026-10-05T20:30:00+09:00"), created_at: db("2026-10-05T20:31:00+09:00") }),
    row({ error_id: "E-7", ...FACILITY_B, errored_at: null, created_at: db("2026-10-06T08:00:00+09:00") }),
    row({
      error_id: "E-8",
      ...FACILITY_B,
      robot_id: null,
      serial_number: "0",
      error_type: "SECURITY_ON",
      error_level: "CRITICAL",
      errored_at: db("2026-10-05T21:30:00+09:00"),
      created_at: db("2026-10-05T21:31:00+09:00"),
    }),
    row({ error_id: "E-11", ...FACILITY_B, clean_timetable_id: BigInt(109), errored_at: db("2026-10-05T22:00:00+09:00"), created_at: db("2026-10-05T22:01:00+09:00") }),
  ];
  mainTimetables = [
    { id: BigInt(100), precheck: 0 },
    { id: BigInt(101), precheck: PRECHECK_30 },
    { id: BigInt(109), precheck: PRECHECK_10 },
  ];
  // ★E6 の予定は履歴にだけある★
  historyTimetables = [{ id: BigInt(106), precheck: 0 }];
  facilityRows = [
    { id: BigInt(1), facility_name: "テスト施設A", customer_id: BigInt(1), customer: { group_id: BigInt(1) } },
    { id: BigInt(2), facility_name: "テスト施設B", customer_id: BigInt(2), customer: { group_id: BigInt(1) } },
  ];
}

/** 表1 の並び順（既定。E3 を除く） */
const DEFAULT_ORDER = ["E-7", "E-11", "E-8", "E-10", "E-9", "E-6", "E-2", "E-1"];

async function get(qs = ""): Promise<Response> {
  return GET(new Request(`http://localhost/api/errors${qs ? `?${qs}` : ""}`));
}

async function list(qs = ""): Promise<ListResponse<ErrorReportItem>> {
  const res = await get(qs);
  expect(res.status).toBe(200);
  return (await res.json()) as ListResponse<ErrorReportItem>;
}

const ids = (items: ErrorReportItem[]) => items.map((i) => i.id);

beforeEach(() => {
  buildData();
  // ★施設名・分類の解決は `db` の2メソッドを差し替える（解決の規則 `resolveFacilityIdsBy*()` と
  //   `intersectFacilityIds()` は本物が走る）★。
  //   ★スタブの `facility.findMany` で済ませない理由★: ルートは2本を `Promise.all` で同時に呼び、
  //   それぞれが `await import("./prisma")` する。vitest では ★モックした `@/lib/db/prisma` を同時に動的 import すると
  //   片方に本物のモジュールが返る★（2026-10-06 実測。本物は `DATABASE_URL` が無いと例外 → 500）。
  //   テスト環境だけの挙動で、本番の経路（`/clean-logs` も同じ形）には関係ない。
  vi.spyOn(dataSource, "facilityNameIndex").mockImplementation(async () =>
    facilityRows.map((f) => ({ id: Number(f.id), facility_name: f.facility_name }))
  );
  vi.spyOn(dataSource, "facilityGroupIndex").mockImplementation(async () =>
    facilityRows.map((f) => ({
      id: Number(f.id),
      customer_id: Number(f.customer_id),
      group_id: Number(f.customer.group_id),
    }))
  );
  errorCalls = [];
  mainTimetableCalls = [];
  historyTimetableCalls = [];
  vanishOnSecondStage = new Set();
  throwOnErrorFindMany = false;
  vi.mocked(logApiError).mockClear();
});

// ---------------------------------------------------------------- 表1

describe("表1（既定・解消も表示・各行の値）", () => {
  it("★既定は E3 以外の8行・並びが表1 のとおり（E7 が先頭・同時刻は \"E-10\" が先）★", async () => {
    const body = await list();
    expect(ids(body.items)).toEqual(DEFAULT_ORDER);
    expect(body.meta).toEqual({ total: 8, page: 1, per_page: 50 });
  });

  it("include_resolved=true で9行（E3 は検知時刻の位置）", async () => {
    const body = await list("include_resolved=true");
    expect(ids(body.items)).toEqual(["E-7", "E-11", "E-8", "E-10", "E-9", "E-6", "E-3", "E-2", "E-1"]);
    expect(body.meta.total).toBe(9);
  });

  it.each([
    ["E-1", "main", null, "2026-10-05T19:05:00+09:00", "PD-CC1-0001", "EmergencyStop"],
    ["E-2", "precheck", PRECHECK_30, "2026-10-05T19:30:00+09:00", "PD-CC1-0001", "LowBattery"],
    ["E-3", "main", null, "2026-10-05T20:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
    ["E-10", "none", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
    ["E-9", "unknown", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
    ["E-6", "main", null, "2026-10-05T20:30:00+09:00", "PD-CC1-0001", "EmergencyStop"],
    ["E-7", "main", null, "2026-10-06T08:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
    ["E-8", "main", null, "2026-10-05T21:30:00+09:00", "0", "SECURITY_ON"],
    ["E-11", "precheck", PRECHECK_10, "2026-10-05T22:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
  ] as const)("%s: schedule_link / precheck_minutes / detected_at / serial_number / error_type", async (id, link, minutes, detectedAt, serial, errorType) => {
    const body = await list("include_resolved=true");
    expect(body.items.find((i) => i.id === id)).toMatchObject({
      schedule_link: link,
      precheck_minutes: minutes,
      detected_at: detectedAt,
      serial_number: serial,
      error_type: errorType,
    });
  });

  it("E3: 解消の日時と備考を返す・BigInt を残さない", async () => {
    const body = await list("include_resolved=true");
    expect(body.items.find((i) => i.id === "E-3")).toEqual({
      id: "E-3",
      detected_at: "2026-10-05T20:00:00+09:00",
      facility_id: 1,
      facility_name: "テスト施設A",
      serial_number: "PD-CC1-0001",
      error_type: "EmergencyStop",
      error_level: "ERROR",
      clean_timetable_id: 100,
      schedule_link: "main",
      precheck_minutes: null,
      resolution_status: "resolved",
      responded_at: "2026-10-06T09:00:00+09:00",
      resolved_at: "2026-10-06T10:00:00+09:00",
      note: "対応済み",
    });
  });

  it("★E6: 本表の findMany は E6 の予定（106）を返さず、履歴から引く★", async () => {
    const body = await list();
    expect(body.items.find((i) => i.id === "E-6")?.schedule_link).toBe("main");
    expect(mainTimetableCalls.flatMap((c) => c.returned)).not.toContain("106");
    expect(historyTimetableCalls.length).toBeGreaterThan(0);
  });

  it("★E8: robot_id が NULL の行があっても読み取りで落ちない（select で robot_id / robot を読まない）★", async () => {
    const body = await list();
    expect(ids(body.items)).toContain("E-8");
  });
});

// ---------------------------------------------------------------- 表2

describe("表2（絞り込み・ページ）", () => {
  it("F3: include_resolved=yes → 422（DB を読まない）", async () => {
    const res = await get("include_resolved=yes");
    expect(res.status).toBe(422);
    expect(((await res.json()) as ApiError).error.code).toBe("validation_failed");
    expect(errorCalls).toHaveLength(0);
  });

  it("F4: error_type=secur → E8 だけ", async () => {
    const body = await list("error_type=secur");
    expect(ids(body.items)).toEqual(["E-8"]);
    expect(body.meta.total).toBe(1);
  });

  it("F5: error_type が空白だけ → F1 と同じ", async () => {
    expect(ids((await list("error_type=%20%20")).items)).toEqual(DEFAULT_ORDER);
  });

  it("★F6: date_from = date_to = E7 の created_at の日付 → E7 が入る（SQL 側も created_at で絞る）★", async () => {
    const body = await list("date_from=2026-10-06&date_to=2026-10-06");
    expect(ids(body.items)).toEqual(["E-7"]);
  });

  it("期間 10/5 → E7 以外の7行", async () => {
    const body = await list("date_from=2026-10-05&date_to=2026-10-05");
    expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-7"));
  });

  it("F7: date_from > date_to → 422（parseCleanLogQuery() と同じ文言）", async () => {
    const res = await get("date_from=2026-10-06&date_to=2026-10-05");
    expect(res.status).toBe(422);
    expect(((await res.json()) as ApiError).error.message).toBe("date_fromはdate_to以前の日付を指定してください");
  });

  it("★F8: 分類で絞って施設が0件 → 0件（全件にならない）・2段目と予定のクエリを撃たない★", async () => {
    const body = await list("customer_id=99");
    expect(body.items).toEqual([]);
    expect(body.meta.total).toBe(0);
    expect(errorCalls).toHaveLength(1);
    expect(errorCalls[0].where).toMatchObject({ facility_id: { in: [] } });
    expect(mainTimetableCalls).toHaveLength(0);
    expect(historyTimetableCalls).toHaveLength(0);
  });

  it("★F9: 施設名が当たり分類も当たるが積集合が空 → 0件（AND）★", async () => {
    const body = await list(`facility_name=${encodeURIComponent("テスト施設A")}&customer_id=2`);
    expect(body.items).toEqual([]);
    expect(body.meta.total).toBe(0);
  });

  it("施設名で絞る → 施設B の行だけ（並びは保つ）", async () => {
    const body = await list(`facility_name=${encodeURIComponent("施設B")}`);
    expect(ids(body.items)).toEqual(["E-7", "E-11", "E-8", "E-6"]);
  });

  it("F10: per_page=3&page=2 → 並び順の4〜6行目・meta.total = 8", async () => {
    const body = await list("per_page=3&page=2");
    expect(ids(body.items)).toEqual(["E-10", "E-9", "E-6"]);
    expect(body.meta).toEqual({ total: 8, page: 2, per_page: 3 });
  });

  it("★F11: page=99 → items: []・meta.total = 8（404 にしない）・2段目を撃たない★", async () => {
    const body = await list("page=99");
    expect(body.items).toEqual([]);
    expect(body.meta.total).toBe(8);
    expect(errorCalls).toHaveLength(1);
  });

  it("★F12: status = 3 の行が混ざっても、既定でも「解消済みも表示」でも出ない・例外にならない★", async () => {
    errorReports.push(row({ error_id: "E-X", status: 3, errored_at: db("2026-10-06T09:00:00+09:00") }));
    expect(ids((await list()).items)).not.toContain("E-X");
    expect(ids((await list("include_resolved=true")).items)).not.toContain("E-X");
  });
});

// ---------------------------------------------------------------- 2段階で読む（D8）

describe("2段階で読む（指示書AW D8）", () => {
  it("★1段目の where は errorReportListWhere() の戻りと同じ（ルートが別の条件を組み立てていない）★", async () => {
    await list("date_from=2026-10-05&date_to=2026-10-06&customer_id=1&error_type=x&page=2&per_page=3");
    expect(errorCalls[0].where).toEqual(
      errorReportListWhere({
        include_resolved: false,
        facility_ids: [1],
        date_from: "2026-10-05",
        date_to: "2026-10-06",
        error_type: "x",
        page: 2,
        per_page: 3,
      })
    );
  });

  it("★1段目の select は ROBOT_ERROR_LIST_KEY_SELECT、2段目は ROBOT_ERROR_LIST_ROW_SELECT★", async () => {
    await list("per_page=3");
    expect(errorCalls).toHaveLength(2);
    expect(errorCalls[0].select).toEqual(ROBOT_ERROR_LIST_KEY_SELECT);
    expect(errorCalls[1].select).toEqual(ROBOT_ERROR_LIST_ROW_SELECT);
    // ★2段目はそのページの error_id だけ★
    expect(errorCalls[1].where).toEqual({ error_id: { in: ["E-7", "E-11", "E-8"] } });
  });

  it("★0件のとき2段目と予定のクエリを撃たない★", async () => {
    const body = await list("error_type=no-such-type");
    expect(body).toEqual({ items: [], meta: { total: 0, page: 1, per_page: 50 } });
    expect(errorCalls).toHaveLength(1);
    expect(mainTimetableCalls).toHaveLength(0);
    expect(historyTimetableCalls).toHaveLength(0);
  });

  it("予定のないページ（E4 だけ）では予定のクエリを撃たない", async () => {
    const body = await list("per_page=1&page=4");
    expect(ids(body.items)).toEqual(["E-10"]);
    expect(mainTimetableCalls).toHaveLength(0);
    expect(historyTimetableCalls).toHaveLength(0);
  });

  it("★1段目と2段目の間に消えた行は飛ばす（例外にしない）・meta.total は1段目の件数のまま★", async () => {
    vanishOnSecondStage = new Set(["E-11"]);
    const body = await list();
    expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-11"));
    expect(body.meta.total).toBe(8);
  });

  it("DB の例外 → 500「データの取得に失敗しました」", async () => {
    throwOnErrorFindMany = true;
    const res = await get();
    expect(res.status).toBe(500);
    expect(((await res.json()) as ApiError).error).toMatchObject({
      code: "internal_error",
      message: "データの取得に失敗しました",
    });
    expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
  });
});
