import { describe, expect, it } from "vitest";

import { aggregateCleanArea } from "@/lib/clean-area/aggregate";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import {
  cleanAreaStatsFromRows,
  countDuplicatedTimetables,
  loadCleanAreaFacilityMasters,
  loadCleanAreaFacilityStats,
  loadCleanAreaFacilityStatsWithRowCount,
  type CleanAreaDuplicateGroupRow,
  type CleanAreaDuplicateSourcePrisma,
  type CleanAreaFacilityGroupRow,
  type CleanAreaFacilityMasterRow,
  type CleanAreaFacilitySourcePrisma,
  type CleanAreaRateRow,
  type CleanAreaStatsSourcePrisma,
  type CleanAreaWhere,
} from "@/lib/db/clean-area-source";
import type { CleanAreaFacilityMaster } from "@/lib/types";

/**
 * 指示書AN ★段階3★ §6-1 / §6-2（★罠7 / 落とし穴1・3・5★）—
 * 清掃面積の集計材料の読み取り。
 *
 * ★★`deleted_at` で絞らない★★: 面積は履歴ベースで、★論理削除は過去の実績を消さない★。
 *   絞ると内訳から落ちるだけでなく、`aggregateCleanArea()` が
 *   ★マスタに無い施設を `total` からも外す★設計なので ★全体合計そのものが減ります★。
 *   ★★ローカルDBでは検出できません★★（実測で `facilities` 12件のうち論理削除は0件）—
 *   ★だから合成データで固定する★（`facility-group-source.test.ts` と同じ理由）。
 */

/** `findMany()` に渡された引数を控えるスタブ（★DBに接続しない★） */
function stubPrisma(rows: readonly CleanAreaFacilityMasterRow[]): {
  prisma: CleanAreaFacilitySourcePrisma;
  calls: unknown[];
} {
  const calls: unknown[] = [];
  const prisma: CleanAreaFacilitySourcePrisma = {
    facility: {
      async findMany(args) {
        calls.push(args);
        return [...rows];
      },
    },
  };
  return { prisma, calls };
}

function masterRow(
  id: number,
  customerId: number,
  groupId: number,
  groupName: string | null
): CleanAreaFacilityMasterRow {
  return {
    id: BigInt(id),
    facility_name: `施設${id}`,
    customer_id: BigInt(customerId),
    customer: {
      customer_name: `屋号${customerId}`,
      group_id: BigInt(groupId),
      group: groupName === null ? null : { group_name: groupName },
    },
  };
}

describe("loadCleanAreaFacilityMasters（指示書AN 段階3 §6-2）", () => {
  it("★★`where` を一切付けない★★（＝ `deleted_at` で絞らない。罠7 の唯一の検出手段）", async () => {
    const { prisma, calls } = stubPrisma([]);
    await loadCleanAreaFacilityMasters(prisma);

    for (const args of calls) {
      expect(Object.keys(args as object)).toEqual(["select"]);
    }
  });

  it("★★クエリは1本だけ★★（施設ごとにループで撃たないこと）", async () => {
    const { prisma, calls } = stubPrisma([
      masterRow(1, 1, 1, "ホールディングス1"),
      masterRow(2, 2, 1, "ホールディングス1"),
    ]);
    await loadCleanAreaFacilityMasters(prisma);
    expect(calls).toHaveLength(1);
  });

  it("読む列はロールアップに要るものだけ（★これ以上増やさない★）", async () => {
    const { prisma, calls } = stubPrisma([]);
    await loadCleanAreaFacilityMasters(prisma);
    const select = (calls[0] as { select: Record<string, unknown> }).select;
    expect(Object.keys(select).sort()).toEqual([
      "customer",
      "customer_id",
      "facility_name",
      "id",
    ]);
    expect(select.customer).toEqual({
      select: {
        customer_name: true,
        group_id: true,
        group: { select: { group_name: true } },
      },
    });
  });

  it("★BigInt を `number` に直して返す★（`NextResponse.json` が BigInt で例外を投げるため）", async () => {
    const { prisma } = stubPrisma([masterRow(7, 3, 2, "ホールディングス2")]);
    const rows = await loadCleanAreaFacilityMasters(prisma);
    expect(rows).toEqual([
      {
        id: 7,
        facility_name: "施設7",
        customer_id: 3,
        customer_name: "屋号3",
        group_id: 2,
        group_name: "ホールディングス2",
      },
    ]);
    expect(typeof rows[0].id).toBe("number");
    expect(typeof rows[0].customer_id).toBe("number");
  });

  it("★★`customers.group_id = 0` は `null` に変換する★★（D-011 / 落とし穴5）", async () => {
    // ★0 は「ホールディングスなし」のセンチネル★ — そのまま出すと
    //   存在しない「グループ0」が画面に出る（`aggregateCleanArea()` は `null` を
    //   「グループなし」1行にまとめる契約）
    const { prisma } = stubPrisma([masterRow(12, 4, 0, null)]);
    const rows = await loadCleanAreaFacilityMasters(prisma);
    expect(rows[0].group_id).toBeNull();
    // ★所属が無いのだから名前も `null`★（センチネルに対応する `groups` の行は存在しない）
    expect(rows[0].group_name).toBeNull();
  });

  it("★論理削除された施設も返す★（返す行を間引かないこと）", async () => {
    const { prisma } = stubPrisma([
      masterRow(1, 1, 1, "ホールディングス1"),
      masterRow(2, 1, 1, "ホールディングス1"),
    ]);
    const rows = await loadCleanAreaFacilityMasters(prisma);
    expect(rows.map((r) => r.id)).toEqual([1, 2]);
  });
});

/** 面積の `groupBy` の1行（★面積の合計だけ★。指示書AS 段階4-1） */
function groupRow(
  facilityId: number,
  clean: number | null,
  task: number | null
): CleanAreaFacilityGroupRow {
  return { facility_id: BigInt(facilityId), _sum: { clean_area: clean, task_area: task } };
}

/** 完了率用に取り寄せる1行（★`clean_ratio` は読まないが `select` に入っている★。D6） */
function rateRow(
  facilityId: number,
  timetableId: number | null,
  cleanArea: number,
  taskArea: number,
  cleanRatio = 0
): CleanAreaRateRow {
  return {
    facility_id: BigInt(facilityId),
    clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
    clean_area: cleanArea,
    task_area: taskArea,
    clean_ratio: cleanRatio,
  };
}

describe("cleanAreaStatsFromRows（指示書AN 段階3 §6-2 / §5-3 A-2・指示書AS 段階4）", () => {
  it("★`facility_id` を `Number()` に直す★（BigInt のままだと施設マスタと突き合わない）", () => {
    const stats = cleanAreaStatsFromRows(
      [groupRow(3, 120.5, 200)],
      [rateRow(3, 31, 80, 100), rateRow(3, 32, 120.5 - 80, 100)],
      new Set(["31", "32"])
    );
    expect(stats).toEqual([
      {
        facility_id: 3,
        clean_area: 120.5,
        task_area: 200,
        executed_count: 2,
        clean_ratio_sum: 80 + 40.5,
        clean_ratio_count: 2,
      },
    ]);
    expect(typeof stats[0].facility_id).toBe("number");
  });

  it("★★`_sum` の `null` は 0 にする★★（A-2。`null` を層をまたいで持ち回らない）", () => {
    const stats = cleanAreaStatsFromRows([groupRow(1, null, null)], [], new Set());
    expect(stats[0].clean_area).toBe(0);
    expect(stats[0].task_area).toBe(0);
    // ★完了率の Σ は常に数値（0）、件数も 0★
    expect(stats[0].clean_ratio_sum).toBe(0);
    expect(stats[0].clean_ratio_count).toBe(0);
    expect(stats[0].executed_count).toBe(0);
  });

  it("0行なら空配列（★行を作らない★）", () => {
    expect(cleanAreaStatsFromRows([], [], new Set())).toEqual([]);
  });

  it("★面積の集計に無い施設の行が来たら例外★（`where` の食い違い。黙って足さない）", () => {
    expect(() =>
      cleanAreaStatsFromRows([groupRow(1, 10, 10)], [rateRow(2, 21, 10, 10)], new Set(["21"]))
    ).toThrow(/facility_id=2/);
  });

  it("★予定外（`clean_timetable_id` NULL）の行は施設が面積集計に無くても例外にしない★（数えないので）", () => {
    expect(() =>
      cleanAreaStatsFromRows([groupRow(1, 10, 10)], [rateRow(2, null, 10, 10)], new Set())
    ).not.toThrow();
  });
});

/**
 * ★★表4（指示書AS §4）— 総清掃面積（F-08）の施設1件ぶんの集計★★（D-155 項3 / 指示書AS D9）
 *
 * | # | その施設のレポート行 | `clean_area` 合計 | `task_area` 合計 | `executed_count` | `completion_rate`（`aggregateCleanArea()` の結果） |
 * |---|---|---|---|---|---|
 * | F1 | 予定外（`clean_timetable_id` NULL）100/100 の1行だけ | 100 | 100 | 0 | null |
 * | F2 | 本清掃 50/100 ＋ 予定外 100/100 | 150 | 200 | 1 | 50.0 |
 * | F3 | 事前チェック（`precheck ≠ 0`）の予定に紐づく 80/100 の1行だけ | 80 | 100 | 0 | null |
 * | F4 | 予定が見つからない id（dangling）80/100 の1行だけ | 80 | 100 | 0 | null |
 * | F5 | 本清掃 10/0 の1行だけ（`task_area = 0`） | 10 | 0 | 1 | ★0.0★（P-5=B。指示書AU 表3。旧: null） |
 * | F6 | 本清掃 150/100 ＋ 本清掃 0/100 | 150 | 200 | 2 | 50.0（(100.0 + 0.0) ÷ 2） |
 * | F7 | 本清掃の行の `clean_ratio` 列が 12.3、面積は 50/100 | 50 | 100 | 1 | ★50.0★（★`clean_ratio` 列を読んでいないことの確認★） |
 *
 * ★面積の合計は `groupBy`（DB 側。全行）なので、ここでは `groupRows` に合計を置く★。
 * 本清掃の予定 id は 1 / 2、事前チェックの予定 id は 3（`mainTimetableIds` に入らない）、
 * dangling は 9（予定そのものが無いので `mainTimetableIds` に入らない）。
 */
describe("表4（指示書AS）— 総清掃面積の施設1件ぶんの集計", () => {
  const MAIN = new Set(["1", "2"]);
  const MASTER: CleanAreaFacilityMaster[] = [
    {
      id: 1,
      facility_name: "施設1",
      customer_id: 10,
      customer_name: "屋号10",
      group_id: null,
      group_name: null,
    },
  ];

  it.each<[string, CleanAreaRateRow[], number, number, number, number | null]>([
    ["F1 予定外 100/100 の1行だけ", [rateRow(1, null, 100, 100)], 100, 100, 0, null],
    ["F2 本清掃 50/100 ＋ 予定外 100/100", [rateRow(1, 1, 50, 100), rateRow(1, null, 100, 100)], 150, 200, 1, 50.0],
    ["F3 事前チェックの予定に紐づく 80/100 の1行だけ", [rateRow(1, 3, 80, 100)], 80, 100, 0, null],
    ["F4 予定が見つからない id（dangling）80/100 の1行だけ", [rateRow(1, 9, 80, 100)], 80, 100, 0, null],
    ["F5 本清掃 10/0 の1行だけ（task_area = 0 は 0%。P-5=B）", [rateRow(1, 1, 10, 0)], 10, 0, 1, 0.0],
    ["F6 本清掃 150/100 ＋ 本清掃 0/100", [rateRow(1, 1, 150, 100), rateRow(1, 2, 0, 100)], 150, 200, 2, 50.0],
    ["F7 clean_ratio 列 12.3・面積 50/100", [rateRow(1, 1, 50, 100, 12.3)], 50, 100, 1, 50.0],
  ])("%s", (_name, reports, cleanSum, taskSum, executed, completion) => {
    // ★面積の合計は全行（予定外を含む）★ — DB の `groupBy` が返すものをここで作る
    const groups = [
      groupRow(
        1,
        reports.reduce((sum, r) => sum + r.clean_area, 0),
        reports.reduce((sum, r) => sum + r.task_area, 0)
      ),
    ];
    const stats = cleanAreaStatsFromRows(groups, reports, MAIN);
    expect(stats).toHaveLength(1);
    expect(stats[0].clean_area).toBe(cleanSum);
    expect(stats[0].task_area).toBe(taskSum);
    expect(stats[0].executed_count).toBe(executed);
    const result = aggregateCleanArea(stats, MASTER, "facility");
    expect(result.items[0].completion_rate).toBe(completion);
    expect(result.total.completion_rate).toBe(completion);
    expect(result.total.executed_count).toBe(executed);
  });
});

/**
 * ★★指示書AQ（集計元を `clean_task_reports` に差し替える）の期待値表（5行）★★
 *
 * | #  | 入力 | 期待 |
 * |----|---|---|
 * | A1 | 集計 | ★`clean_task_reports` の `groupBy(["facility_id"])`★ を撃つ（★`clean_status_histories` ではない★） |
 * | A2 | 集計 | `_sum` は `clean_area` / `task_area` の2列（★面積の合計★。§1-3）。★完了率・実施回数は `findMany` で取り寄せた行から★（指示書AS 段階4。旧: `clean_ratio` の `_sum` と `_count._all`） |
 * | A3 | 番人 | `where` に ★`clean_timetable_id: { not: null }`★ が入る（★罠1★） |
 * | A4 | 番人 | `having` で 2行以上だけを返させる（★絞らないと全 `clean_timetable_id` が返る★） |
 * | A5 | 番人 | 集計と ★同じ `where`★ を土台にする（期間・施設を数え直さない。§6-3） |
 */
describe("指示書AQ — 集計元を `clean_task_reports` に差し替える（A1〜A5）", () => {
  const WHERE: CleanAreaWhere = {
    started_at: { gte: new Date("2026-09-01T00:00:00Z"), lt: new Date("2026-09-08T00:00:00Z") },
    facility_id: { in: [BigInt(1), BigInt(2)] },
  };

  /**
   * 面積の `groupBy`・完了率用の `findMany`・予定の引き直しを控えるスタブ（★DBに接続しない★）。
   * ★`cleanTimetable.findMany` は `precheck` が本清掃のときだけ、`mainIds` に入っている id を返す★。
   */
  function statsStub(
    rows: readonly CleanAreaFacilityGroupRow[],
    reports: readonly CleanAreaRateRow[] = [],
    mainIds: readonly number[] = [],
    // ★`clean_timetable_histories` にある本清掃の id（指示書AU 段階3b。既定は空）★
    historyMainIds: readonly number[] = []
  ): {
    prisma: CleanAreaStatsSourcePrisma;
    calls: unknown[];
    findManyCalls: unknown[];
    timetableCalls: { where: { id: { in: bigint[] }; precheck: number } }[];
    historyCalls: { where: { id: { in: bigint[] }; precheck: number } }[];
  } {
    const calls: unknown[] = [];
    const findManyCalls: unknown[] = [];
    const timetableCalls: { where: { id: { in: bigint[] }; precheck: number } }[] = [];
    const historyCalls: { where: { id: { in: bigint[] }; precheck: number } }[] = [];
    return {
      calls,
      findManyCalls,
      timetableCalls,
      historyCalls,
      prisma: {
        cleanTaskReport: {
          async groupBy(args) {
            calls.push(args);
            return [...rows];
          },
          async findMany(args) {
            findManyCalls.push(args);
            return [...reports];
          },
        },
        cleanTimetable: {
          async findMany(args) {
            // ★影チェック（`precheck` なしで id を引き直す。`lib/db/timetable-source.ts`）は記録しない★
            if (args.where.precheck === undefined) {
              return args.where.id.in.filter((id) => mainIds.includes(Number(id))).map((id) => ({ id }));
            }
            timetableCalls.push(args);
            return args.where.precheck === CLEAN_TIMETABLE_PRECHECK.main
              ? args.where.id.in.filter((id) => mainIds.includes(Number(id))).map((id) => ({ id }))
              : [];
          },
        },
        cleanTimetableHistory: {
          async findMany(args) {
            historyCalls.push(args);
            return args.where.precheck === CLEAN_TIMETABLE_PRECHECK.main
              ? args.where.id.in.filter((id) => historyMainIds.includes(Number(id))).map((id) => ({ id }))
              : [];
          },
        },
      },
    };
  }

  function duplicateStub(rows: readonly CleanAreaDuplicateGroupRow[]): {
    prisma: CleanAreaDuplicateSourcePrisma;
    calls: { where: unknown; having: unknown }[];
  } {
    const calls: { where: unknown; having: unknown }[] = [];
    return {
      calls,
      prisma: {
        cleanTaskReport: {
          async groupBy(args) {
            calls.push({ where: args.where, having: args.having });
            return [...rows];
          },
        },
      },
    };
  }

  it("A1 / A2 ★面積2列を `_sum` し、完了率・実施回数は取り寄せた本清掃の行から★（指示書AS 段階4 / D9）", async () => {
    const { prisma, calls, findManyCalls, timetableCalls } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 900, task_area: 1000 } }],
      [
        // ★`clean_ratio` 列は面積比とわざと食い違う値★（列を読んでいないことの確認。D6）
        rateRow(1, 11, 300, 300, 10),
        rateRow(1, 12, 150, 300, 20),
        rateRow(1, 13, 450, 400, 30),
      ],
      [11, 12, 13]
    );
    const stats = await loadCleanAreaFacilityStats(prisma, WHERE);

    expect(calls).toEqual([
      {
        by: ["facility_id"],
        where: WHERE,
        _sum: { clean_area: true, task_area: true },
      },
    ]);
    // ★★完了率用の取り寄せは同じ `where` に `{ not: null }` を足しただけ★★（5-10）
    expect(findManyCalls).toEqual([
      {
        where: { ...WHERE, clean_timetable_id: { not: null } },
        select: {
          facility_id: true,
          clean_timetable_id: true,
          clean_area: true,
          task_area: true,
          clean_ratio: true,
        },
      },
    ]);
    // ★★予定の引き直しは `precheck = CLEAN_TIMETABLE_PRECHECK.main`★★（`0` を直書きしない）
    expect(timetableCalls).toEqual([
      {
        where: { id: { in: [BigInt(11), BigInt(12), BigInt(13)] }, precheck: CLEAN_TIMETABLE_PRECHECK.main },
        select: { id: true },
      },
    ]);
    // ★`executed_count` は「本清掃に紐づくレポートの行数」★（予定外を含まない）。
    //   1回ごとの完了率は 100.0 / 50.0 / 100.0（450/400 は上限で 100）
    expect(stats).toEqual([
      {
        facility_id: 1,
        clean_area: 900,
        task_area: 1000,
        executed_count: 3,
        clean_ratio_sum: 250,
        clean_ratio_count: 3,
      },
    ]);
  });

  it("★履歴（`clean_timetable_histories`）だけにある予定に紐づくレポートも、完了率・実施回数に入る★（指示書AU 段階3b-1 の 12）", async () => {
    const { prisma, timetableCalls, historyCalls } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 150, task_area: 200 } }],
      [rateRow(1, 11, 100, 100), rateRow(1, 21, 50, 100)],
      [11],
      [21]
    );
    const stats = await loadCleanAreaFacilityStats(prisma, WHERE);
    // ★両表に同じ引数（`where: { id: { in }, precheck }`）★
    expect(historyCalls).toEqual(timetableCalls);
    // ★旧（本表だけ）なら 21 は「本清掃に紐づかない行」で、実施1回・完了率 100 になっていた★
    expect(stats[0]).toMatchObject({ executed_count: 2, clean_ratio_sum: 150, clean_ratio_count: 2 });
  });

  it("★取り寄せた行が0件なら `cleanTimetable.findMany` を撃たない★（`chunkForInClause()` の塊0個）", async () => {
    const { prisma, timetableCalls } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 10, task_area: 10 } }],
      []
    );
    const stats = await loadCleanAreaFacilityStats(prisma, WHERE);
    expect(timetableCalls).toEqual([]);
    expect(stats[0].executed_count).toBe(0);
  });

  it("★`loadCleanAreaFacilityStatsWithRowCount()` は取り寄せた行数を返す★（D-156 論点2 のログ用）", async () => {
    const { prisma } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 60, task_area: 300 } }],
      [rateRow(1, 11, 30, 100), rateRow(1, 12, 30, 200)],
      [11]
    );
    const load = await loadCleanAreaFacilityStatsWithRowCount(prisma, WHERE);
    // ★行数は取り寄せた全行（本清掃に紐づくかどうかに関係なく）★
    expect(load.rate_rows).toBe(2);
    // ★id=12 は本清掃でない（`mainIds` に無い）ので実施回数に入らない★
    expect(load.stats[0].executed_count).toBe(1);
  });

  it("A3 ★★番人の `where` に `clean_timetable_id: { not: null }` が入る★★（罠1。落とすと毎回偽陽性）", async () => {
    const { prisma, calls } = duplicateStub([]);
    await countDuplicatedTimetables(prisma, WHERE);

    // ★★NULL を除かないと、`GROUP BY` が NULL 行を1つの巨大グループにまとめ、
    //   `having > 1` に必ず当たって「重複1件」を毎回報告します★★（偽陽性）。
    //   ★毎回出る警告は読まれなくなり、本物の重複を見逃すようになる★
    expect(calls[0].where).toEqual({
      ...WHERE,
      clean_timetable_id: { not: null },
    });
  });

  it("A4 ★`having` で2行以上だけを返させる★（絞らないと全 `clean_timetable_id` が返る）", async () => {
    const { prisma, calls } = duplicateStub([]);
    await countDuplicatedTimetables(prisma, WHERE);
    expect(calls[0].having).toEqual({ clean_timetable_id: { _count: { gt: 1 } } });
  });

  it("A5 ★集計と同じ `where` を土台にする★（§6-3。期間・施設を数え直さない）", async () => {
    const { prisma: statsPrisma, calls: statsCalls } = statsStub([]);
    const { prisma: dupPrisma, calls: dupCalls } = duplicateStub([]);
    await loadCleanAreaFacilityStats(statsPrisma, WHERE);
    await countDuplicatedTimetables(dupPrisma, WHERE);

    const statsWhere = (statsCalls[0] as { where: CleanAreaWhere }).where;
    expect(statsWhere.started_at).toEqual((dupCalls[0].where as CleanAreaWhere).started_at);
    expect(statsWhere.facility_id).toEqual((dupCalls[0].where as CleanAreaWhere).facility_id);
  });

  it("★返すのは「2行以上あった `clean_timetable_id`」の行数そのもの★", async () => {
    const { prisma } = duplicateStub([
      { clean_timetable_id: BigInt(11) },
      { clean_timetable_id: BigInt(22) },
    ]);
    expect(await countDuplicatedTimetables(prisma, WHERE)).toBe(2);
  });
});
