import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createLiveCrmRepo, createLiveRadarRepo } from "./radarRepos";
import { ApiError, saveTokens } from "./http";
import { legacyAcceptedOpportunity, legacyDismissedOpportunity, legacyRadarOpportunity, legacyRadarWatch } from "../fixtures/radarLegacy";

function installLocalStorage() {
  const store = new Map<string, string>();
  const shim: Storage = {
    get length() {
      return store.size;
    },
    clear: () => store.clear(),
    getItem: (key) => store.get(key) ?? null,
    key: (index) => [...store.keys()][index] ?? null,
    removeItem: (key) => void store.delete(key),
    setItem: (key, value) => void store.set(key, String(value)),
  };
  Object.defineProperty(globalThis, "localStorage", {
    value: shim,
    configurable: true,
    writable: true,
  });
}

function envelope(code: number, data: unknown, status = 200): Response {
  return new Response(JSON.stringify({ code, message: "", data, error: null }), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

describe("demand-radar live repositories", () => {
  const originalFetch = globalThis.fetch;
  let lastUrl = "";

  beforeEach(() => {
    installLocalStorage();
    saveTokens({ access_token: "access-1", refresh_token: "refresh-1" });
    lastUrl = "";
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  // A route that exists without an implementation answers 501010. Swallowing that into an empty
  // list would make an unbuilt feature look like "you have no data", which is the one failure mode
  // the backend shells were written to avoid.
  it("propagates the not-ready code instead of returning empty results", async () => {
    globalThis.fetch = (async () => envelope(501010, null, 501)) as typeof fetch;

    const radar = createLiveRadarRepo();
    await expect(radar.getToday()).rejects.toBeInstanceOf(ApiError);
    await expect(radar.getToday()).rejects.toMatchObject({ code: 501010 });
    await expect(createLiveCrmRepo().listContacts()).rejects.toMatchObject({ code: 501010 });
  });

  it("maps a watch list with its quota fields", async () => {
    globalThis.fetch = (async (url: string) => {
      lastUrl = String(url);
      return envelope(102000, {
        list: [
          {
            id: "w1",
            terms: ["水電 推薦"],
            exclude_terms: ["徵才"],
            regions: ["TPE"],
            status: "active",
            created_at: 1,
            updated_at: 2,
          },
        ],
        pagination: { page: 1, pageSize: 20, total: 1 },
        active_count: 1,
        max_active: 3,
        profile_exists: true,
      });
    }) as unknown as typeof fetch;

    const res = await createLiveRadarRepo().listWatches(1, 20, "active");
    expect(lastUrl).toContain("/api/v1/radar/watches?page=1&pageSize=20&status=active");
    expect(res.total).toBe(1);
    expect(res.max_active).toBe(3);
    expect(res.profile_exists).toBe(true);
    expect(res.list[0]).toMatchObject({ id: "w1", status: "active", terms: ["水電 推薦"] });
  });

  it("uses the separate purge endpoint for archived watches", async () => {
    globalThis.fetch = (async (url: string, init?: RequestInit) => {
      lastUrl = String(url);
      expect(init?.method).toBe("DELETE");
      return envelope(102000, { ok: true, message: "archived watch deleted" });
    }) as unknown as typeof fetch;

    await createLiveRadarRepo().deleteArchivedWatch("watch archived");
    expect(lastUrl).toContain("/api/v1/radar/watches/watch%20archived/purge");
  });

  it("reads legacy radar rows as generic and without a guessed product", async () => {
    globalThis.fetch = (async (url: string) => {
      lastUrl = String(url);
      if (lastUrl.includes("/watches")) {
        return envelope(102000, {
          list: [legacyRadarWatch],
          pagination: { page: 1, pageSize: 20, total: 1 },
          active_count: 1,
          max_active: 3,
          profile_exists: true,
        });
      }
      return envelope(102000, {
          list: [legacyRadarOpportunity, legacyAcceptedOpportunity, legacyDismissedOpportunity],
        pagination: { page: 1, pageSize: 20, total: 1 },
      });
    }) as unknown as typeof fetch;

    const radar = createLiveRadarRepo();
    const watches = await radar.listWatches();
    const opportunities = await radar.listOpportunities();
    expect(watches.list[0]).toMatchObject({ context_mode: "generic" });
    expect(watches.list[0].brand_id).toBeUndefined();
    expect(opportunities.list[0]).toMatchObject({
      id: "legacy-opportunity",
      product_matches: [],
      review_state: "pending",
    });
    expect(opportunities.list[0].primary_product_id).toBeUndefined();
    expect(opportunities.list[1].review_state).toBe("completed");
    expect(opportunities.list[2]).toMatchObject({ review_state: "removed", removal_reason: undefined });
  });

  // exists=false is the signal that no profile was ever saved, and the watch page keys its
  // gate on it, so a missing field must not read as "profile ready".
  it("keeps service profile absence distinguishable from an empty profile", async () => {
    globalThis.fetch = (async () =>
      envelope(102000, { services: [], cases: [], forbidden: [], faq: [], service_areas: [] })) as typeof fetch;

    const profile = await createLiveRadarRepo().getServiceProfile();
    expect(profile.exists).toBe(false);
    expect(profile.services).toEqual([]);
    expect(profile.updated_at).toBeUndefined();
  });

  it("omits the follow-up filter when it is not set", async () => {
    globalThis.fetch = (async (url: string) => {
      lastUrl = String(url);
      return envelope(102000, { list: [], pagination: { total: 0 }, stage_counts: [] });
    }) as unknown as typeof fetch;

    const crm = createLiveCrmRepo();
    await crm.listContacts({ stage: "engaged" });
    expect(lastUrl).not.toContain("follow_up");

    await crm.listContacts({ follow_up: false });
    expect(lastUrl).toContain("follow_up=false");
  });

  it("sends contact search and uses DELETE for safe list removal", async () => {
    let lastMethod = "";
    globalThis.fetch = (async (url: string, init?: RequestInit) => {
      lastUrl = String(url);
      lastMethod = init?.method || "GET";
      if (lastMethod === "DELETE") return envelope(102000, { ok: true });
      return envelope(102000, { list: [], pagination: { total: 0 }, stage_counts: [] });
    }) as unknown as typeof fetch;

    const crm = createLiveCrmRepo();
    await crm.listContacts({ query: "Alice Chen", page: 2, pageSize: 20 });
    expect(lastUrl).toContain("query=Alice%20Chen");
    expect(lastUrl).toContain("page=2&pageSize=20");

    await crm.deleteContact("contact / 1");
    expect(lastMethod).toBe("DELETE");
    expect(lastUrl).toContain("/api/v1/crm/contacts/contact%20%2F%201");
  });

  it("posts import rows and maps per-row status back", async () => {
    let lastBody: unknown;
    globalThis.fetch = (async (url: string, init?: RequestInit) => {
      lastUrl = String(url);
      lastBody = init?.body ? JSON.parse(String(init.body)) : undefined;
      return envelope(102000, {
        results: [
          { url: "https://www.threads.net/@a/post/1", opportunity_id: "o1", status: "qualified", intent_band: "high", intent_score: 88 },
          { url: "https://www.threads.net/@a/post/2", status: "failed", error: "網址格式不正確" },
        ],
      });
    }) as unknown as typeof fetch;

    const res = await createLiveRadarRepo().importOpportunities([
      { url: "https://www.threads.net/@a/post/1", text: "找水電師傅" },
      { url: "bad", text: "" },
    ]);

    expect(lastUrl).toContain("/api/v1/radar/import");
    expect(lastBody).toMatchObject({
      items: [
        { url: "https://www.threads.net/@a/post/1", text: "找水電師傅" },
        { url: "bad", text: "" },
      ],
    });
    expect(res.results).toHaveLength(2);
    expect(res.results[0]).toMatchObject({ opportunity_id: "o1", status: "qualified", intent_band: "high", intent_score: 88 });
    expect(res.results[1]).toMatchObject({ status: "failed", error: "網址格式不正確" });
  });
});
