import { describe, expect, it } from "vitest";
import {
  filterDsDevicesByQ,
  filterDsDevicesByStatus,
  parseDsDeviceQuery,
  sortDsDevices,
  summarizeDsDevices,
} from "@/lib/ds-devices/query";
import type { DsDevice } from "@/lib/types";

function row(overrides: Partial<DsDevice>): DsDevice {
  return {
    serial_number: "DS-001",
    facility_id: 1,
    facility_name: "メガバリューマート中央本店",
    connection_status: "ok",
    last_reported_at: "2026-08-27T09:00:00+09:00",
    note: null,
    ...overrides,
  };
}

describe("parseDsDeviceQuery", () => {
  it("省略時は status=all になる", () => {
    const parsed = parseDsDeviceQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.status).toBe("all");
    expect(parsed.query.q).toBe("");
  });

  it("statusが不正な値なら422", () => {
    const parsed = parseDsDeviceQuery(new URLSearchParams({ status: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.code).toBe("validation_failed");
    }
  });

  // ★どのパラメータが不正か呼び出し側に伝わること（details）を固定する★
  it("status不正のdetailsは選択肢を列挙する", () => {
    const parsed = parseDsDeviceQuery(new URLSearchParams({ status: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.error.error.message).toBe("statusの値が不正です");
      expect(parsed.error.error.details).toEqual({
        status: ["all / error / ok のいずれかを指定してください"],
      });
    }
  });

  /**
   * ★group_id / customer_id（指示書AK 段階2）★
   *
   * ★★元は「渡してもパース結果に含まれない（D-053）」だったテストを反転させたもの★★ —
   *   ★`it` ごと消さないこと★。1本も残っていないと、次に誰かが「受け取って捨てる」
   *   実装に戻しても気づけない（指示書AK 落とし穴8）。
   *
   * | # | 入力 | 期待 |
   * |---|---|---|
   * | 2-1 | `group_id=1` | `1`（★`toHaveProperty` ではなく値まで見る★） |
   * | 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 |
   */
  it("group_id / customer_id を値として読む（D-053 解除。指示書AK 段階2）", () => {
    const parsed = parseDsDeviceQuery(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("group_id / customer_id 未指定は null（絞らない）", () => {
    const parsed = parseDsDeviceQuery(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 = parseDsDeviceQuery(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 = parseDsDeviceQuery(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以上の整数で指定してください"],
    });
  });

  it("page/per_pageの既定値と上限", () => {
    const parsed = parseDsDeviceQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.page).toBe(1);
    expect(parsed.query.perPage).toBe(50);

    const capped = parseDsDeviceQuery(new URLSearchParams({ per_page: "9999" }));
    expect(capped.ok).toBe(true);
    if (!capped.ok) throw new Error("query");
    expect(capped.query.perPage).toBe(200);
  });

  it("qは前後の空白を除去する", () => {
    const parsed = parseDsDeviceQuery(new URLSearchParams({ q: "  中央  " }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.q).toBe("中央");
  });

  /**
   * ★空白だけの入力は「記号だけ」ではなく「未指定」になる★（2026-09-14 に確認）
   *
   * 半角スペース・全角スペース・タブは ★`foldName` の除去対象17文字に含まれる★が、
   * ★`.trim()` の除去対象でもある★（JS の `trim()` は U+3000 もタブも落とす）。
   * そのため ★`includesFoldName` に届く前に空文字になり、素通し（全件）になる★。
   * ★`・` や `-` は `trim()` では落ちない★ので、記号だけの入力として 0件になる。
   * ★この違いはパーサ側で決まる★ので、`lib/format/search-text.ts` を読んでも分からない。
   */
  it("★空白だけの q は trim で空文字＝未指定になる★（記号だけの 0件 とは別の道）", () => {
    for (const raw of [" ", "　", "\t", "  \t　 "]) {
      const parsed = parseDsDeviceQuery(new URLSearchParams({ q: raw }));
      expect(parsed.ok).toBe(true);
      if (!parsed.ok) throw new Error("query");
      expect(parsed.query.q).toBe("");
    }

    // ★中点・ハイフンは trim で落ちない★ → 記号だけの入力としてフィルタまで届く
    for (const raw of ["・", "-", "_", "ー"]) {
      const parsed = parseDsDeviceQuery(new URLSearchParams({ q: raw }));
      expect(parsed.ok).toBe(true);
      if (!parsed.ok) throw new Error("query");
      expect(parsed.query.q).toBe(raw);
    }
  });
});

describe("filterDsDevicesByQ", () => {
  const items: DsDevice[] = [
    row({ serial_number: "DS-001", facility_name: "メガバリューマート中央本店" }),
    row({ serial_number: "DS-002", facility_name: "バリューマート西通り店" }),
  ];

  it("施設名の部分一致で絞り込む", () => {
    expect(filterDsDevicesByQ(items, "中央").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);
  });

  it("シリアルの部分一致で絞り込む", () => {
    expect(filterDsDevicesByQ(items, "DS-002").map((d) => d.serial_number)).toEqual([
      "DS-002",
    ]);
  });

  it("大文字小文字を区別しない", () => {
    expect(filterDsDevicesByQ(items, "ds-001").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);
  });

  it("q が空なら全件返す", () => {
    expect(filterDsDevicesByQ(items, "")).toHaveLength(2);
  });

  it("q=ZZZ-NOMATCH で0件（docs/fixtures.mdの再現条件）", () => {
    expect(filterDsDevicesByQ(items, "ZZZ-NOMATCH")).toHaveLength(0);
  });

  // ★記号ゆれ（指示書AG 段階3）★ 5系統すべてに1件ずつ置いている
  //   `ドソ・キホーテ` は中点入りの架空名（fixtures の施設名には記号が1文字も無く
  //   再現できないため合成データで組む。D-6。fixtures は変更しない）。
  it("施設名は中点を打たなくても引ける／シリアルの記号は除かない", () => {
    const symbolItems: DsDevice[] = [
      row({ serial_number: "DS-001", facility_name: "ドソ・キホーテ戸塚" }),
    ];
    expect(
      filterDsDevicesByQ(symbolItems, "ドソキホーテ").map((d) => d.serial_number)
    ).toEqual(["DS-001"]);
    // ★シリアルは記号を除かない★（除くと `DS001` になり、桁の境界が消える。D-2）
    expect(filterDsDevicesByQ(symbolItems, "DS001")).toHaveLength(0);
  });

  /**
   * ★記号だけの入力（2026-09-14 に反転。案A）と OR の関係★
   *
   * ★反転前は `-` と打つと全件が出ていた★（`includesFoldName` が素通ししていたため、
   *   施設名の側がすべての行に当たっていた）。
   * ★反転後は「施設名では当たらない・シリアルでは当たる」★になり、
   *   ★ハイフン入りのシリアルの行だけが出る★。これが狙った結果
   *   （★「全件」でも「0件」でもない★ことを、この OR の経路で固定する）。
   */
  it("★`-` は施設名では当たらず、ハイフン入りシリアルの行だけが出る（OR）★", () => {
    const mixed: DsDevice[] = [
      // ハイフン入りのシリアル（`includesFoldPlain` で当たる）
      row({ serial_number: "DS-001", facility_name: "ドソ・キホーテ戸塚" }),
      // ★記号を持たないシリアル★（施設名にもシリアルにも `-` が無い ＝ 出てはいけない行）
      row({ serial_number: "DS002", facility_name: "バリューマート西通り店" }),
    ];

    expect(filterDsDevicesByQ(mixed, "-").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);

    // ★中点は施設名にしかないが、`foldPlain` は記号を残すのでシリアル側にも当たらない★
    //   ＝ 記号だけの入力で施設名が素通ししていないことの裏取り（0件になる）
    expect(filterDsDevicesByQ(mixed, "・")).toHaveLength(0);
    expect(filterDsDevicesByQ(mixed, "　")).toHaveLength(0);

    // ★空文字（未指定）は反転していない★ — 素通しで全件
    expect(filterDsDevicesByQ(mixed, "")).toHaveLength(2);
  });
});

describe("filterDsDevicesByStatus", () => {
  const items: DsDevice[] = [
    row({ serial_number: "DS-001", connection_status: "ok" }),
    row({ serial_number: "DS-002", connection_status: "error" }),
    row({ serial_number: "DS-003", connection_status: "error" }),
  ];

  it("2状態すべてをstatus=allで保持できる", () => {
    expect(filterDsDevicesByStatus(items, "all")).toHaveLength(3);
  });

  it("status=error は異常のみ（通信断は異常に含まれる。D-065）", () => {
    expect(
      filterDsDevicesByStatus(items, "error").map((d) => d.serial_number)
    ).toEqual(["DS-002", "DS-003"]);
  });

  it("status=ok は正常のみ", () => {
    expect(filterDsDevicesByStatus(items, "ok").map((d) => d.serial_number)).toEqual([
      "DS-001",
    ]);
  });
});

describe("sortDsDevices", () => {
  it("異常→正常の順になる", () => {
    const items: DsDevice[] = [
      row({ serial_number: "DS-003", connection_status: "ok" }),
      row({ serial_number: "DS-002", connection_status: "error" }),
      row({ serial_number: "DS-001", connection_status: "error" }),
    ];
    expect(sortDsDevices(items).map((d) => d.connection_status)).toEqual([
      "error",
      "error",
      "ok",
    ]);
  });

  it("同一状態内はserial_numberの昇順になる", () => {
    const items: DsDevice[] = [
      row({ serial_number: "DS-003", connection_status: "ok" }),
      row({ serial_number: "DS-001", connection_status: "ok" }),
      row({ serial_number: "DS-002", connection_status: "ok" }),
    ];
    expect(sortDsDevices(items).map((d) => d.serial_number)).toEqual([
      "DS-001",
      "DS-002",
      "DS-003",
    ]);
  });
});

describe("summarizeDsDevices", () => {
  it("total === ok + error になる", () => {
    const items: DsDevice[] = [
      row({ serial_number: "DS-001", connection_status: "ok" }),
      row({ serial_number: "DS-002", connection_status: "ok" }),
      row({ serial_number: "DS-003", connection_status: "error" }),
      row({ serial_number: "DS-004", connection_status: "error" }),
    ];
    const summary = summarizeDsDevices(items);
    expect(summary).toEqual({ total: 4, ok: 2, error: 2 });
    expect(summary.ok + summary.error).toBe(summary.total);
  });

  it("0件でも壊れない", () => {
    expect(summarizeDsDevices([])).toEqual({ total: 0, ok: 0, error: 0 });
  });

  // NOTE: 「total - ok を error として、未知の3状態目を黙って異常扱いする」バグを検出する
  // ケースを追加したかったが、DsConnectionStatus は "ok" | "error" の
  // 閉じたunion（lib/types/index.ts。D-065 で2状態に確定）で、3つ目の値を持つ DsDevice は
  // any キャスト無しでは作れない（CLAUDE.md で any 禁止）。よって summarizeDsDevices 側は
  // total - ok ではなく error を明示的に数える実装にすることで対処し、テストは
  // 「ok + error === total」を固定するのみに留める。3状態目が実際に増える時は、
  // 型を先に拡張してからこのテストにケースを足すこと。
});

describe("summaryはq/statusフィルタから独立している（D-054と同じ規則、D-055の説明対象）", () => {
  const items: DsDevice[] = [
    row({ serial_number: "DS-001", facility_name: "メガバリューマート中央本店", connection_status: "ok" }),
    row({ serial_number: "DS-002", facility_name: "バリューマート西通り店", connection_status: "error" }),
    row({ serial_number: "DS-003", facility_name: "バリューマート北町店", connection_status: "error" }),
  ];

  it("qやstatusを変えてもsummaryは同一（全デバイスに対する集計）", () => {
    // route.ts と同じ組み立て順: summaryは全件（フィルタ前）に対して計算する
    const summaryBase = summarizeDsDevices(items);
    const summaryAfterQ = summarizeDsDevices(items);
    const summaryAfterStatus = summarizeDsDevices(items);

    expect(summaryBase).toEqual({ total: 3, ok: 1, error: 2 });
    expect(summaryBase).toEqual(summaryAfterQ);
    expect(summaryBase).toEqual(summaryAfterStatus);

    // 一覧側はq/statusで変わる
    expect(filterDsDevicesByQ(items, "西通り")).toHaveLength(1);
    expect(filterDsDevicesByStatus(items, "ok")).toHaveLength(1);
  });
});
