import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/api/client";
import {
  ALL_OPTION_VALUE,
  appendClassificationParams,
  buildCustomerOptions,
  buildGroupOptions,
  hasGroupChoices,
  isClassificationAvailable,
  loadClassificationMasters,
  normalizeSelection,
  parseOptionValue,
  UNAVAILABLE_MASTERS,
} from "@/lib/masters/options";
import type { Customer, Group, ListResponse } from "@/lib/types";

/**
 * 分類（ホールディングス → 屋号）のカスケード絞り込み（指示書AK 段階7 の下準備）の期待値表。
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 *  - ★マスタの取得に失敗したら絞り込みUIを出さないこと★（`isClassificationAvailable()` が false）。
 *    ★空の `<select>` は D-053 で消した「押しても何も起きないUI」への逆戻り★（落とし穴11）
 *  - ★ホールディングスを変えたら屋号の候補がその傘下だけになること★
 *  - ★傘下でない屋号の選択が残らないこと★ — 残ると AND 条件で必ず0件になり、
 *    ★「0件ヒット」か「絞り込みの壊れ」かを誰も判別できない★（落とし穴1・2 と同じ形）
 *  - ★`0` と `1abc` を ID として通さないこと★（D-011 のセンチネル / D-123 で踏んだ罠）
 */

vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() }));

const apiFetchMock = vi.mocked(apiFetch);

const GROUPS: Group[] = [
  { id: 1, group_name: "ホールディングスA" },
  { id: 2, group_name: "ホールディングスB" },
];

const CUSTOMERS: Customer[] = [
  { id: 10, customer_name: "屋号A1", group_id: 1 },
  { id: 20, customer_name: "屋号A2", group_id: 1 },
  { id: 30, customer_name: "屋号B1", group_id: 2 },
  { id: 40, customer_name: "屋号なし所属", group_id: null },
];

function listOf<T>(items: T[]): ListResponse<T> {
  return { items, meta: { total: items.length, page: 1, per_page: items.length } };
}

beforeEach(() => {
  apiFetchMock.mockReset();
});

// ---------------------------------------------------------------- 取得

describe("loadClassificationMasters", () => {
  it("2本とも成功したら ok: true で中身を返す", async () => {
    apiFetchMock.mockImplementation(async (path: string) =>
      path.startsWith("/api/groups") ? listOf(GROUPS) : listOf(CUSTOMERS)
    );

    const masters = await loadClassificationMasters();

    expect(masters.ok).toBe(true);
    expect(masters.groups).toEqual(GROUPS);
    expect(masters.customers).toEqual(CUSTOMERS);
    expect(isClassificationAvailable(masters)).toBe(true);
    expect(hasGroupChoices(masters)).toBe(true);
  });

  it("★取得は2本だけ★（カスケードのために往復を増やさない）", async () => {
    apiFetchMock.mockImplementation(async (path: string) =>
      path.startsWith("/api/groups") ? listOf(GROUPS) : listOf(CUSTOMERS)
    );

    await loadClassificationMasters();

    expect(apiFetchMock).toHaveBeenCalledTimes(2);
    const paths = apiFetchMock.mock.calls.map((call) => call[0]);
    expect(paths).toContain("/api/groups");
    // ★`?group_id=` を付けて取り直さない★ — 手元で `filterCustomersByGroup()` する
    expect(paths).toContain("/api/customers");
  });

  it("★どちらか1本でも失敗したら available: false 相当になる★（例外は投げない）", async () => {
    apiFetchMock.mockImplementation(async (path: string) => {
      if (path.startsWith("/api/groups")) return listOf(GROUPS);
      throw new Error("boom");
    });

    const masters = await loadClassificationMasters();

    expect(masters).toEqual(UNAVAILABLE_MASTERS);
    expect(masters.ok).toBe(false);
    expect(isClassificationAvailable(masters)).toBe(false);
    expect(hasGroupChoices(masters)).toBe(false);
  });

  it("2本とも失敗しても例外を投げない", async () => {
    apiFetchMock.mockRejectedValue(new Error("boom"));

    await expect(loadClassificationMasters()).resolves.toEqual(
      UNAVAILABLE_MASTERS
    );
  });
});

describe("isClassificationAvailable / hasGroupChoices", () => {
  it("★屋号が0件なら出さない★（「全屋号」しか並ばないセレクトを作らない）", () => {
    expect(
      isClassificationAvailable({ ok: true, groups: GROUPS, customers: [] })
    ).toBe(false);
  });

  it("★ホールディングスが0件でも屋号があれば絞り込み自体は出す★", () => {
    const masters = { ok: true, groups: [], customers: CUSTOMERS };
    expect(isClassificationAvailable(masters)).toBe(true);
    // ただしホールディングスのセレクトは出さない
    expect(hasGroupChoices(masters)).toBe(false);
  });
});

// ---------------------------------------------------------------- 選択肢

describe("buildGroupOptions", () => {
  it("先頭が「全件」の行で、値は空文字", () => {
    const options = buildGroupOptions(GROUPS, "全ホールディングス");

    expect(options[0]).toEqual({
      value: ALL_OPTION_VALUE,
      label: "全ホールディングス",
    });
    expect(options[0].value).toBe("");
    expect(options.slice(1)).toEqual([
      { value: "1", label: "ホールディングスA" },
      { value: "2", label: "ホールディングスB" },
    ]);
  });

  it("★並べ替えない★（/groups の id 昇順をそのまま使う）", () => {
    const shuffled: Group[] = [
      { id: 2, group_name: "ホールディングスB" },
      { id: 1, group_name: "ホールディングスA" },
    ];

    expect(buildGroupOptions(shuffled, "全").map((o) => o.value)).toEqual([
      "",
      "2",
      "1",
    ]);
  });
});

describe("buildCustomerOptions", () => {
  it("ホールディングス未選択なら全屋号（所属なしも含む）", () => {
    const options = buildCustomerOptions(CUSTOMERS, null, "全屋号");

    expect(options.map((o) => o.value)).toEqual(["", "10", "20", "30", "40"]);
  });

  it("★ホールディングスを選ぶと傘下だけになる★", () => {
    expect(
      buildCustomerOptions(CUSTOMERS, 1, "全屋号").map((o) => o.label)
    ).toEqual(["全屋号", "屋号A1", "屋号A2"]);

    expect(
      buildCustomerOptions(CUSTOMERS, 2, "全屋号").map((o) => o.label)
    ).toEqual(["全屋号", "屋号B1"]);
  });

  it("★実在しないホールディングスなら「全屋号」だけ★（全件に化けない）", () => {
    // ★落とし穴1・2 と同じ形★: 0件を「絞り込みなし」に倒すと全件表示に化ける
    expect(buildCustomerOptions(CUSTOMERS, 999, "全屋号")).toEqual([
      { value: ALL_OPTION_VALUE, label: "全屋号" },
    ]);
  });

  it("★`group_id: null`（所属なし）は傘下に数えない★", () => {
    expect(
      buildCustomerOptions(CUSTOMERS, 1, "全屋号").map((o) => o.value)
    ).not.toContain("40");
  });
});

// ---------------------------------------------------------------- 値の解釈

describe("parseOptionValue", () => {
  const cases: Array<[string, number | null]> = [
    ["", null], // 「全件」の行
    ["   ", null],
    ["1", 1],
    ["42", 42],
    ["0", null], // ★ホールディングスなしのセンチネル。有効なIDではない（D-011）★
    ["1abc", null], // ★parseInt なら 1 として通る（D-123）★
    ["-1", null],
    ["1.5", null],
    ["1e9", null],
    ["all", null],
  ];

  it.each(cases)("%s → %s", (input, expected) => {
    expect(parseOptionValue(input)).toBe(expected);
  });
});

// ---------------------------------------------------------------- カスケードの整合

describe("normalizeSelection", () => {
  it("★ホールディングスを変えて傘下から外れた屋号は選択を外す★", () => {
    expect(
      normalizeSelection({ groupId: 2, customerId: 10 }, CUSTOMERS)
    ).toEqual({ groupId: 2, customerId: null });
  });

  it("傘下に残っている屋号はそのまま", () => {
    const selection = { groupId: 1, customerId: 20 };
    expect(normalizeSelection(selection, CUSTOMERS)).toEqual(selection);
  });

  it("ホールディングス未選択ならどの屋号でも残す", () => {
    const selection = { groupId: null, customerId: 40 };
    expect(normalizeSelection(selection, CUSTOMERS)).toEqual(selection);
  });

  it("屋号未選択なら何もしない", () => {
    const selection = { groupId: 1, customerId: null };
    expect(normalizeSelection(selection, CUSTOMERS)).toEqual(selection);
  });

  it("★マスタ未取得（屋号0件）のときは選択を消さない★", () => {
    // 「傘下に居ない」と「まだ一覧を知らない」を取り違えない
    const selection = { groupId: 1, customerId: 10 };
    expect(normalizeSelection(selection, [])).toEqual(selection);
  });
});

// ---------------------------------------------------------------- クエリ

describe("appendClassificationParams", () => {
  it("両方指定したら両方載る（APIは AND で解釈する）", () => {
    const params = appendClassificationParams(new URLSearchParams("date=2026-09-15"), {
      groupId: 1,
      customerId: 10,
    });

    expect(params.toString()).toBe("date=2026-09-15&group_id=1&customer_id=10");
  });

  it("★未指定のキーは付けない★", () => {
    expect(
      appendClassificationParams(new URLSearchParams(), {
        groupId: null,
        customerId: 10,
      }).toString()
    ).toBe("customer_id=10");

    expect(
      appendClassificationParams(new URLSearchParams(), {
        groupId: null,
        customerId: null,
      }).toString()
    ).toBe("");
  });

  it("★0 を漏らさない★（存在しない「グループ0」で絞り込ませない。落とし穴3）", () => {
    expect(
      appendClassificationParams(new URLSearchParams(), {
        groupId: 0,
        customerId: 0,
      }).toString()
    ).toBe("");
  });
});
