import { beforeEach, describe, expect, it, vi } from "vitest";

// ★ルート本体は静的 import★（`it()` の中で `await import()` すると、最初の1本だけが
//   モジュール読み込みを抱え込んでフルスイート実行時にタイムアウトしうる。
//   `app/api/reports/__tests__/route.test.ts` の実測）
import { GET as GET_CUSTOMERS } from "@/app/api/customers/route";
import { GET as GET_GROUPS } from "@/app/api/groups/route";
import type { ApiError, Customer, Group, ListResponse } from "@/lib/types";

/**
 * 分類マスタAPI 2本（`GET /groups` / `GET /customers`。指示書AK 段階1）の期待値表。
 *
 * ------------------------------------------------------------------------
 * ★★DBには接続しません★★
 * ------------------------------------------------------------------------
 * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
 * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
 * ★`prismaSource` の経路★ を走らせる（`app/api/reports/__tests__/route.test.ts` と同じ作法）。
 * fixtures 側（`USE_FIXTURES=true`）の不変条件は
 * `lib/fixtures/__tests__/masters.test.ts` が見る。
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 *  - ★`group_id = 0` を API に漏らさないこと★（指示書AK 落とし穴3 / D-011）—
 *    ★漏らすと「グループ0」という存在しないホールディングスが画面に出る★
 *  - ★`deleted_at` で絞ること／`inactive` では絞らないこと★（§4-1 #4）—
 *    ★どちらも「件数が静かに変わる」だけで例外は出ない★
 *  - ★セレクトリストを切らないこと★（§4-1 #1）— 51件目以降が消えても誰も気づけない
 *  - ★`customers.contract` を読まないこと★（D-010 / 落とし穴5）
 */

interface StubGroup {
  id: bigint;
  group_name: string;
  inactive: number;
  deleted_at: Date | null;
}

interface StubCustomer {
  id: bigint;
  customer_name: string;
  group_id: bigint;
  inactive: number;
  deleted_at: Date | null;
}

const calls: { group: Record<string, unknown>[]; customer: Record<string, unknown>[] } = {
  group: [],
  customer: [],
};

let data: { groups: StubGroup[]; customers: StubCustomer[] };

function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
  if (!where) return true;
  for (const [field, cond] of Object.entries(where as Record<string, unknown>)) {
    if (row[field] !== cond) return false;
  }
  return true;
}

function findMany(
  bucket: keyof typeof calls,
  rows: Record<string, unknown>[],
  args: Record<string, unknown> | undefined
) {
  calls[bucket].push(args ?? {});
  return rows.filter((row) => matchesWhere(row, args?.where));
}

vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));

vi.mock("@/lib/db/prisma", () => ({
  getPrisma: async () => ({
    group: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("group", data.groups as unknown as Record<string, unknown>[], args),
    },
    customer: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("customer", data.customers as unknown as Record<string, unknown>[], args),
    },
  }),
}));

function group(
  id: number,
  name: string,
  opts: { inactive?: number; deleted?: boolean } = {}
): StubGroup {
  return {
    id: BigInt(id),
    group_name: name,
    inactive: opts.inactive ?? 0,
    deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null,
  };
}

function customer(
  id: number,
  name: string,
  groupId: number,
  opts: { inactive?: number; deleted?: boolean } = {}
): StubCustomer {
  return {
    id: BigInt(id),
    customer_name: name,
    group_id: BigInt(groupId),
    inactive: opts.inactive ?? 0,
    deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null,
  };
}

async function getGroups(qs = ""): Promise<Response> {
  return GET_GROUPS(new Request(`http://localhost/api/groups${qs}`));
}

async function getCustomers(qs = ""): Promise<Response> {
  return GET_CUSTOMERS(new Request(`http://localhost/api/customers${qs}`));
}

beforeEach(() => {
  calls.group = [];
  calls.customer = [];
  data = {
    groups: [
      group(2, "グループB"),
      group(1, "グループA"),
      // ★無効化（inactive=1）でも一覧に出る★ / ★論理削除は出ない★
      group(3, "グループC（無効）", { inactive: 1 }),
      group(4, "グループD（削除済み）", { deleted: true }),
    ],
    customers: [
      customer(20, "屋号B", 1),
      customer(10, "屋号A", 1),
      customer(30, "屋号C", 2),
      // ★group_id=0（ホールディングスなしのセンチネル。D-011）★
      customer(40, "屋号D", 0),
      customer(50, "屋号E（無効）", 1, { inactive: 1 }),
      customer(60, "屋号F（削除済み）", 1, { deleted: true }),
    ],
  };
});

describe("GET /groups", () => {
  it("#1 id の昇順で返し、meta は total / page=1 / per_page=total", async () => {
    const res = await getGroups();
    expect(res.status).toBe(200);
    const body = (await res.json()) as ListResponse<Group>;
    expect(body.items.map((g) => g.id)).toEqual([1, 2, 3]);
    expect(body.meta).toEqual({ total: 3, page: 1, per_page: 3 });
  });

  it("#2 deleted_at で絞る（論理削除は出ない）／inactive では絞らない", async () => {
    const body = (await (await getGroups()).json()) as ListResponse<Group>;
    expect(body.items.map((g) => g.group_name)).toContain("グループC（無効）");
    expect(body.items.map((g) => g.group_name)).not.toContain("グループD（削除済み）");
    expect(calls.group[0].where).toEqual({ deleted_at: null });
  });

  it("#3 ★id=0 の行を作らない★（『グループ0』が現れないこと）", async () => {
    const body = (await (await getGroups()).json()) as ListResponse<Group>;
    expect(body.items.some((g) => g.id === 0)).toBe(false);
  });

  it("#4 返すのは id と group_name だけ（inactive / deleted_at を漏らさない）", async () => {
    const body = (await (await getGroups()).json()) as ListResponse<Group>;
    expect(Object.keys(body.items[0]).sort()).toEqual(["group_name", "id"]);
  });

  it("#5 DBが落ちたら 500（共通のエラー形式）", async () => {
    data = { get groups(): StubGroup[] { throw new Error("db down"); }, customers: [] };
    const res = await getGroups();
    expect(res.status).toBe(500);
    const body = (await res.json()) as ApiError;
    expect(body.error.code).toBe("internal_error");
  });
});

describe("GET /customers", () => {
  it("#6 id の昇順で返す（meta は /groups と同じ規則）", async () => {
    const res = await getCustomers();
    expect(res.status).toBe(200);
    const body = (await res.json()) as ListResponse<Customer>;
    expect(body.items.map((c) => c.id)).toEqual([10, 20, 30, 40, 50]);
    expect(body.meta).toEqual({ total: 5, page: 1, per_page: 5 });
  });

  it("#7 ★group_id=0 は null になる★（『グループ0』を API に漏らさない。D-011）", async () => {
    const body = (await (await getCustomers()).json()) as ListResponse<Customer>;
    const d = body.items.find((c) => c.id === 40);
    expect(d?.group_id).toBeNull();
    expect(body.items.some((c) => c.group_id === 0)).toBe(false);
  });

  it("#8 deleted_at で絞る／inactive では絞らない", async () => {
    const body = (await (await getCustomers()).json()) as ListResponse<Customer>;
    expect(body.items.map((c) => c.id)).toContain(50);
    expect(body.items.map((c) => c.id)).not.toContain(60);
    expect(calls.customer[0].where).toEqual({ deleted_at: null });
  });

  it("#9 group_id=1 で傘下だけに絞れる", async () => {
    const body = (await (await getCustomers("?group_id=1")).json()) as ListResponse<Customer>;
    expect(body.items.map((c) => c.id)).toEqual([10, 20, 50]);
    expect(body.meta.total).toBe(3);
  });

  it("#10 ★実在しない group_id は 200・空★（404 にしない）", async () => {
    const res = await getCustomers("?group_id=999");
    expect(res.status).toBe(200);
    const body = (await res.json()) as ListResponse<Customer>;
    expect(body.items).toEqual([]);
    expect(body.meta).toEqual({ total: 0, page: 1, per_page: 0 });
  });

  it("#11 ★group_id=0 は 422★（0 はセンチネルで有効なIDではない）", async () => {
    const res = await getCustomers("?group_id=0");
    expect(res.status).toBe(422);
    const body = (await res.json()) as ApiError;
    expect(body.error.code).toBe("validation_failed");
    expect(body.error.details).toEqual({
      group_id: ["1以上の整数で指定してください"],
    });
  });

  it("#12 ★group_id=1abc は 422★（parseInt の前方一致で 1 にしない。D-123）", async () => {
    const res = await getCustomers("?group_id=1abc");
    expect(res.status).toBe(422);
    // ★422 のときは DB を読まない★
    expect(calls.customer).toHaveLength(0);
  });

  it("#13 group_id= （空）は未指定と同じ（全件）", async () => {
    const body = (await (await getCustomers("?group_id=")).json()) as ListResponse<Customer>;
    expect(body.items).toHaveLength(5);
  });

  it("#14 返すのは id / customer_name / group_id だけ（★contract を読まない★。D-010）", async () => {
    const body = (await (await getCustomers()).json()) as ListResponse<Customer>;
    expect(Object.keys(body.items[0]).sort()).toEqual([
      "customer_name",
      "group_id",
      "id",
    ]);
    // `select` で列を絞っていないので、`where` に contract が混ざっていないことも見ておく
    expect(JSON.stringify(calls.customer[0])).not.toContain("contract");
  });

  /**
   * 指示書AL ★段階3★ §4-4 — ★`db.customers()` に `orderBy` があること★。
   *
   * ★★結果の並びでは確かめられない★★ — このスタブは配列をそのまま返すので、
   * ★`orderBy` を消してもルートの `sortCustomers()` が並べ直して通ってしまう★。
   * ★だから「DBに何を投げたか」を見る★。
   *
   * ★`/customers` 自身はこの `orderBy` に依存していない★（ルートで並べ替えるため）。
   * ★依存しているのは `GET /reports/kpi` の 422★ — `details.customer_ids` は
   * `db.customers()` の並びをそのまま流す（`resolveTargetCustomer()` は並べ替えない）。
   */
  it("★段階3 §4-4★ id の昇順で DB から引く（★候補の並びをDB任せにしない★）", async () => {
    await getCustomers();
    expect(calls.customer[0].orderBy).toEqual({ id: "asc" });
  });
});
