/**
 * GET /schedules のクエリ処理のテスト。
 *
 * ★fixtures には依存しない★
 *   `lib/fixtures/schedules.ts` は「どの曜日でも active な行が常に合計10件になる」よう
 *   補完ペアで精密に組まれており（ダッシュボード集計 `planned` と清掃ログ10行の一致用）、
 *   ここから参照すると「fixtures を1件足したらこのテストが落ちる」結合を生む。
 *   逆に、このテストの都合で fixtures をいじると**エラーもテスト失敗も出ないまま**
 *   ダッシュボードが壊れる。よって合成データ（`row()`）だけでテストする。
 *
 * ------------------------------------------------------------------------
 * ★期待値表★（実装前に確定させたもの。CLAUDE.md §6.2）
 * ------------------------------------------------------------------------
 * | #  | ケース                        | 入力                              | 期待                                  |
 * |----|-------------------------------|-----------------------------------|---------------------------------------|
 * | 1  | 0件                           | items=[]                          | [] / total=0                          |
 * | 2  | 1件                           | items=[A]                         | [A] そのまま / total=1                |
 * | 3  | 部分一致（施設名）            | q="中央"                          | 中央本店のみヒット                    |
 * | 4  | 大文字小文字違い              | q="8111r"                         | "8111R..." にヒット                   |
 * | 5  | 該当なし                      | q="ZZZ-NOMATCH"                   | []                                    |
 * | 6  | ページング境界                | 5件 / per_page=2                  | p1=[1,2] p2=[3,4] p3=[5] p4=[] total=5|
 * | 7  | active:false も返る           | 有効1 + 無効1                     | 2件（APIはactiveで絞らない）          |
 * | 8  | q の OR                       | q=シリアルの一部                  | 施設名が違う行がヒット                |
 * | 9  | per_page の丸め               | per_page=9999 / 0 / page=-1       | 200 / 1 / 1                           |
 * ------------------------------------------------------------------------
 */
import { describe, expect, it } from "vitest";
import {
  filterSchedules,
  filterSchedulesByQ,
  paginateSchedules,
  parseScheduleQuery,
  sortSchedules,
} from "@/lib/schedules/query";
import type { Schedule } from "@/lib/types";

function row(overrides: Partial<Schedule> = {}): Schedule {
  return {
    id: 1,
    serial_number: "8111R5628060011",
    facility_id: 1,
    facility_name: "メガバリューマート中央本店",
    weekdays: "1111111",
    weekdays_label: "毎日",
    start_time: "02:00",
    end_time: "03:00",
    active: true,
    ...overrides,
  };
}

/** ルートと同じ組み立て順（filter → sort → paginate）。総件数は paginate 前の件数 */
function list(
  items: Schedule[],
  searchParams: Record<string, string> = {}
): { items: Schedule[]; total: number } {
  const parsed = parseScheduleQuery(new URLSearchParams(searchParams));
  if (!parsed.ok) throw new Error("query");
  const sorted = sortSchedules(filterSchedules(items, parsed.query));
  return {
    items: paginateSchedules(sorted, parsed.query.page, parsed.query.perPage),
    total: sorted.length,
  };
}

/**
 * ★group_id / customer_id（指示書AK 段階2）★
 *
 * ★★このファイルには D-053 の「無視する」コメントも既存テストも無かった★★ —
 *   `d-watch-api.yaml` は最初からこの2つを宣言していて、★パーサが黙って捨てていた★。
 *   だからここは「反転」ではなく ★新規追加★（他の5本の兄弟APIと同じ期待値表に揃える）。
 *
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | 2-1 | `group_id=1` | `1`（★値まで見る★） |
 * | 2-2 | `customer_id=2` | `2` |
 * | 2-3 | 未指定 | ★`null`★（絞らない） |
 * | 2-4 | `group_id=0` | ★422★（0 は「グループなし」のセンチネル。有効なIDではない。D-011） |
 * | 2-5 | `group_id=1abc` | ★422★（★`parseInt` の前方一致で `1` にしない★。D-123 で実際に踏んだ罠） |
 * | 2-6 | `group_id=-1` | 422 |
 * | 2-7 | `group_id=1.5` | 422 |
 * | 2-8 | `9007199254740992`（安全整数超） | 422 |
 *
 * ★同時指定は AND★（`d-watch-api.yaml` の `GroupId`）。★どちらか片方を捨てない★ —
 *   ここでは両方が値として残ることだけを確かめる（施設への解決は段階3、反映は段階4）。
 */
describe("parseScheduleQuery — group_id / customer_id（指示書AK 段階2）", () => {
  it("両方を値として読む（AND。★片方を捨てない★）", () => {
    const parsed = parseScheduleQuery(new URLSearchParams({ group_id: "1", customer_id: "2" }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.groupId).toBe(1);
    expect(parsed.query.customerId).toBe(2);
  });

  it("未指定は null（絞らない）", () => {
    const parsed = parseScheduleQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.groupId).toBeNull();
    expect(parsed.query.customerId).toBeNull();
  });

  it.each(["0", "1abc", "-1", "1.5", "9007199254740992"])(
    "group_id=%s は422",
    (value) => {
      const parsed = parseScheduleQuery(new URLSearchParams({ group_id: value }));
      expect(parsed.ok).toBe(false);
      if (parsed.ok) throw new Error("422を期待");
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.details).toEqual({
        group_id: ["1以上の整数で指定してください"],
      });
    }
  );

  it.each(["0", "1abc"])("customer_id=%s も同じ規則で422", (value) => {
    const parsed = parseScheduleQuery(new URLSearchParams({ customer_id: value }));
    expect(parsed.ok).toBe(false);
    if (parsed.ok) throw new Error("422を期待");
    expect(parsed.status).toBe(422);
    expect(parsed.error.error.details).toEqual({
      customer_id: ["1以上の整数で指定してください"],
    });
  });
});

describe("parseScheduleQuery", () => {
  it("省略時は q / facility_name / serial が空、group_id / customer_id は null、page=1、per_page=50", () => {
    const parsed = parseScheduleQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query).toEqual({
      q: "",
      facilityName: "",
      serial: "",
      groupId: null,
      customerId: null,
      page: 1,
      perPage: 50,
    });
  });

  it("q / facility_name / serial は前後の空白を除去する", () => {
    const parsed = parseScheduleQuery(
      new URLSearchParams({
        q: "  中央  ",
        facility_name: "  西通り  ",
        serial: "  8111R  ",
      })
    );
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.q).toBe("中央");
    expect(parsed.query.facilityName).toBe("西通り");
    expect(parsed.query.serial).toBe("8111R");
  });

  /**
   * ★空白だけの入力は「記号だけ」ではなく「未指定」になる★（2026-09-14 に確認）
   *
   * 半角スペース・全角スペース・タブは ★`foldName` の除去対象17文字に含まれる★が、
   * ★`.trim()` の除去対象でもある★（JS の `trim()` は U+3000 もタブも落とす）。
   * → ★`includesFoldName` に届く前に空文字になり、素通し（全件）になる★。
   * ★`・` や `-` は `trim()` では落ちない★ので、記号だけの入力として 0件になる。
   */
  it("★空白だけの入力は trim で空文字＝未指定になる★（記号だけの 0件 とは別の道）", () => {
    const parsed = parseScheduleQuery(
      new URLSearchParams({ q: "　", facility_name: " ", serial: "\t" })
    );
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.q).toBe("");
    expect(parsed.query.facilityName).toBe("");
    expect(parsed.query.serial).toBe("");

    // ★中点・ハイフンは trim で落ちない★ → 記号だけの入力としてフィルタまで届く
    const symbols = parseScheduleQuery(
      new URLSearchParams({ q: "・", facility_name: "-", serial: "_" })
    );
    expect(symbols.ok).toBe(true);
    if (!symbols.ok) throw new Error("query");
    expect(symbols.query.q).toBe("・");
    expect(symbols.query.facilityName).toBe("-");
    expect(symbols.query.serial).toBe("_");
  });

  // 表#9: 不正な page / per_page は 422 にせず丸める（parseDsDeviceQuery と同じ）
  it("per_page は上限200に丸める", () => {
    const parsed = parseScheduleQuery(new URLSearchParams({ per_page: "9999" }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.perPage).toBe(200);
  });

  it("per_page=0 は1に丸める", () => {
    const parsed = parseScheduleQuery(new URLSearchParams({ per_page: "0" }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.perPage).toBe(1);
  });

  it("page=-1 は1に丸める", () => {
    const parsed = parseScheduleQuery(new URLSearchParams({ page: "-1" }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.page).toBe(1);
  });

  it("数値でない page / per_page は既定値に落とす", () => {
    const parsed = parseScheduleQuery(
      new URLSearchParams({ page: "abc", per_page: "abc" })
    );
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.page).toBe(1);
    expect(parsed.query.perPage).toBe(50);
  });
});

describe("filterSchedulesByQ", () => {
  const items: Schedule[] = [
    row({
      id: 1,
      serial_number: "8111R5628060011",
      facility_name: "メガバリューマート中央本店",
    }),
    row({
      id: 2,
      serial_number: "9222K7788990022",
      facility_name: "バリューマート西通り店",
    }),
  ];

  // 表#3
  it("施設名の部分一致でヒットする", () => {
    expect(filterSchedulesByQ(items, "中央").map((s) => s.id)).toEqual([1]);
  });

  // 表#4: 大文字小文字を無視する
  it("大文字小文字を区別しない（8111r で 8111R... にヒット）", () => {
    expect(filterSchedulesByQ(items, "8111r").map((s) => s.id)).toEqual([1]);
  });

  // 表#8: q の中だけが OR（施設名が一致しなくてもシリアルで拾える）
  it("シリアルの一部で、施設名が違う行がヒットする（q は OR）", () => {
    expect(filterSchedulesByQ(items, "7788990").map((s) => s.id)).toEqual([2]);
  });

  it("q が空なら全件返す", () => {
    expect(filterSchedulesByQ(items, "")).toHaveLength(2);
  });

  // 表#5
  it("該当なしなら空配列", () => {
    expect(filterSchedulesByQ(items, "ZZZ-NOMATCH")).toEqual([]);
  });

  // ★記号ゆれ（指示書AG 段階3）★ 5系統すべてに1件ずつ置いている
  //   `ドソ・キホーテ` は中点入りの架空名（fixtures の施設名には記号が1文字も無く
  //   再現できないため合成データで組む。D-6。fixtures は変更しない）。
  it("施設名は中点を打たなくても引ける／シリアルの記号は除かない", () => {
    const symbolItems: Schedule[] = [
      row({
        id: 10,
        facility_name: "ドソ・キホーテ戸塚",
        serial_number: "PUDU-CC1-0001",
      }),
    ];
    expect(filterSchedulesByQ(symbolItems, "ドソキホーテ").map((s) => s.id)).toEqual(
      [10]
    );
    // ★シリアルは記号を除かない（除くと `10001` が誤ヒットする。D-2）★
    expect(filterSchedulesByQ(symbolItems, "10001")).toEqual([]);
    expect(filterSchedulesByQ(symbolItems, "cc1-0001").map((s) => s.id)).toEqual([
      10,
    ]);
  });

  /**
   * ★記号だけの入力（2026-09-14 に反転。案A）と OR の関係★
   *
   * ★反転前は `-` と打つと全件が出ていた★（施設名の照合が素通ししていたため）。
   * ★反転後は「施設名では当たらない・シリアルでは当たる」★になり、
   *   ★ハイフン入りのシリアルの行だけが出る★。★「全件」でも「0件」でもない★のが狙い。
   */
  it("★`-` は施設名では当たらず、ハイフン入りシリアルの行だけが出る（OR）★", () => {
    const mixed: Schedule[] = [
      row({
        id: 20,
        facility_name: "ドソ・キホーテ戸塚",
        serial_number: "PUDU-CC1-0001", // ハイフン入り
      }),
      row({
        id: 21,
        facility_name: "バリューマート西通り店",
        serial_number: "8111R5628060022", // ★記号を持たない★（出てはいけない行）
      }),
    ];

    expect(filterSchedulesByQ(mixed, "-").map((s) => s.id)).toEqual([20]);

    // ★中点は施設名にしかない★ — 施設名が素通ししていないので 0件
    expect(filterSchedulesByQ(mixed, "・")).toEqual([]);
    expect(filterSchedulesByQ(mixed, "　")).toEqual([]);

    // ★空文字（未指定）は反転していない★ — 素通しで全件
    expect(filterSchedulesByQ(mixed, "")).toHaveLength(2);
  });
});

describe("filterSchedules（q / facility_name / serial は互いに AND）", () => {
  const items: Schedule[] = [
    row({
      id: 1,
      serial_number: "8111R5628060011",
      facility_name: "メガバリューマート中央本店",
    }),
    row({
      id: 2,
      serial_number: "8111R5628060022",
      facility_name: "バリューマート西通り店",
    }),
    row({
      id: 3,
      serial_number: "9222K7788990033",
      facility_name: "バリューマート西通り店",
    }),
  ];

  function query(overrides: Record<string, string> = {}) {
    const parsed = parseScheduleQuery(new URLSearchParams(overrides));
    if (!parsed.ok) throw new Error("query");
    return parsed.query;
  }

  // ★記号ゆれ（指示書AG 段階3）★ ★個別パラメータも q と完全に同じ照合規則である
  //   という約束（罠10）を、個別パラメータ側の経路でも固定する★
  //   q 側にだけ固定があると、個別パラメータ側だけ壊れたときに赤くならない。
  //   `ドソ・キホーテ` は中点入りの架空名（fixtures の施設名には記号が1文字も無く
  //   再現できないため合成データで組む。D-6。fixtures は変更しない）。
  it("facility_name は中点を打たなくても引ける／serial の記号は除かない", () => {
    const symbolItems: Schedule[] = [
      row({
        id: 10,
        facility_name: "ドソ・キホーテ戸塚",
        serial_number: "PUDU-CC1-0001",
      }),
    ];

    expect(
      filterSchedules(symbolItems, query({ facility_name: "ドソキホーテ" })).map(
        (s) => s.id
      )
    ).toEqual([10]);

    // ★serial は記号を除かない（除くと `CC1` の `1` と `0001` が繋がって
    //   `10001` が誤ヒットする。D-2）★
    expect(filterSchedules(symbolItems, query({ serial: "10001" }))).toEqual([]);
    expect(
      filterSchedules(symbolItems, query({ serial: "cc1-0001" })).map((s) => s.id)
    ).toEqual([10]);
  });

  /**
   * ★記号だけの入力（2026-09-14 に反転。案A）★ — 個別パラメータ側でも同じ規則にする。
   * ★反転前は `facility_name=-` で全件が出ていた★。
   * ★serial は `includesFoldPlain` なので反転していない★（記号をそのまま探す）。
   */
  it("★facility_name に記号だけを入れると 0件★／★serial は記号をそのまま探す★", () => {
    const symbolItems: Schedule[] = [
      row({
        id: 10,
        facility_name: "ドソ・キホーテ戸塚",
        serial_number: "PUDU-CC1-0001",
      }),
    ];

    for (const needle of ["・", "-", "_", "ー", "--"]) {
      expect(filterSchedules(symbolItems, query({ facility_name: needle }))).toEqual(
        []
      );
    }

    // ★空文字（未指定）は反転していない★ — 素通し
    expect(
      filterSchedules(symbolItems, query({ facility_name: "" })).map((s) => s.id)
    ).toEqual([10]);

    // ★serial は `includesFoldPlain` のまま★ — `-` は実際に当たる
    expect(
      filterSchedules(symbolItems, query({ serial: "-" })).map((s) => s.id)
    ).toEqual([10]);
  });

  it("facility_name で絞り込む", () => {
    expect(
      filterSchedules(items, query({ facility_name: "西通り" })).map((s) => s.id)
    ).toEqual([2, 3]);
  });

  it("serial で絞り込む（1レコード1シリアルなので横断はしない）", () => {
    expect(
      filterSchedules(items, query({ serial: "9222K" })).map((s) => s.id)
    ).toEqual([3]);
  });

  it("facility_name と serial は AND", () => {
    expect(
      filterSchedules(
        items,
        query({ facility_name: "西通り", serial: "8111R" })
      ).map((s) => s.id)
    ).toEqual([2]);
  });

  it("q は facility_name / serial と AND（q の内部だけが OR）", () => {
    // q="8111R" は 1 と 2 にヒットするが、facility_name="中央" との AND で 1 のみ
    expect(
      filterSchedules(items, query({ q: "8111R", facility_name: "中央" })).map(
        (s) => s.id
      )
    ).toEqual([1]);
  });
});

describe("sortSchedules", () => {
  it("id の昇順になる", () => {
    const items: Schedule[] = [row({ id: 3 }), row({ id: 1 }), row({ id: 2 })];
    expect(sortSchedules(items).map((s) => s.id)).toEqual([1, 2, 3]);
  });

  it("元の配列を破壊しない", () => {
    const items: Schedule[] = [row({ id: 3 }), row({ id: 1 })];
    sortSchedules(items);
    expect(items.map((s) => s.id)).toEqual([3, 1]);
  });
});

describe("一覧の組み立て（ルートと同じ filter → sort → paginate）", () => {
  // 表#1
  it("0件なら [] / total=0", () => {
    expect(list([])).toEqual({ items: [], total: 0 });
  });

  // 表#2
  it("1件ならそのまま返る", () => {
    const only = row({ id: 7 });
    const result = list([only]);
    expect(result.total).toBe(1);
    expect(result.items).toEqual([only]);
  });

  // 表#7: ★一覧は有効・無効の両方を返す（画面が「有効」列で見せ分ける）★
  it("active:false の行も返る（APIルート側でフィルタしない）", () => {
    const items: Schedule[] = [
      row({ id: 1, active: true }),
      row({ id: 2, active: false }),
    ];
    const result = list(items);
    expect(result.total).toBe(2);
    expect(result.items.map((s) => s.active)).toEqual([true, false]);
  });

  it("active:false だけでも返る（絞り込みで消えない）", () => {
    const result = list([row({ id: 1, active: false })]);
    expect(result.total).toBe(1);
    expect(result.items.map((s) => s.id)).toEqual([1]);
  });

  // 表#6: 最終ページは端数、範囲外は空だが total は全件数のまま
  describe("ページング境界（5件 / per_page=2）", () => {
    const items: Schedule[] = [1, 2, 3, 4, 5].map((id) => row({ id }));

    it("1ページ目は2件", () => {
      expect(list(items, { page: "1", per_page: "2" })).toEqual({
        items: [items[0], items[1]],
        total: 5,
      });
    });

    it("2ページ目は2件", () => {
      expect(list(items, { page: "2", per_page: "2" }).items.map((s) => s.id)).toEqual(
        [3, 4]
      );
    });

    it("最終ページは端数の1件", () => {
      expect(list(items, { page: "3", per_page: "2" }).items.map((s) => s.id)).toEqual(
        [5]
      );
    });

    it("範囲外のページは空だが total は全件数", () => {
      const result = list(items, { page: "4", per_page: "2" });
      expect(result.items).toEqual([]);
      expect(result.total).toBe(5);
    });

    it("絞り込みがあると total も絞り込み後の件数になる", () => {
      const mixed: Schedule[] = [
        row({ id: 1, facility_name: "メガバリューマート中央本店" }),
        row({ id: 2, facility_name: "バリューマート西通り店" }),
        row({ id: 3, facility_name: "バリューマート西通り店" }),
      ];
      const result = list(mixed, { q: "西通り", per_page: "2" });
      expect(result.total).toBe(2);
      expect(result.items.map((s) => s.id)).toEqual([2, 3]);
    });
  });
});

describe("paginateSchedules", () => {
  const items: Schedule[] = [1, 2, 3].map((id) => row({ id }));

  it("per_page が件数より大きければ全件返す", () => {
    expect(paginateSchedules(items, 1, 50)).toHaveLength(3);
  });

  it("0件の配列でも壊れない", () => {
    expect(paginateSchedules([], 3, 10)).toEqual([]);
  });
});
