import { describe, expect, test } from "vitest";
import { domainFromInput, registeredDomain, topLevelDomain } from "@/lib/domains/name";
import { domainsHref, filterDomainRows, parseDomainQuery } from "@/lib/domains/query";
import { BUILT_IN_RDAP_SERVERS, parseBootstrap, parseRdapDomain, rdapDomainUrl, rdapServerFor } from "@/lib/domains/rdap";
import { assessDomain, compareDomains, formatDaysLeft, needsAttention, type DomainState } from "@/lib/domains/status";
import { manualExpiryInputValue, parseManualExpiry, validateDomain } from "@/lib/validation/domain";

describe("registered domain from a health API URL", () => {
  test.each([
    ["https://api.calai.twilbp.com/health", "twilbp.com"],
    ["https://vbc.textcall.in/api/status?x=1", "textcall.in"],
    ["https://x.example.co.in/", "example.co.in"], // multi-label public suffix, not "co.in"
    ["https://reverseimagesear.ch", "reverseimagesear.ch"],
    ["https://API.Drawing.TurboFast.IO:8443/h", "turbofast.io"],
    ["https://api.bücher.de/", "xn--bcher-kva.de"], // IDN → punycode, the form registries answer for
  ])("%s → %s", (url, domain) => {
    expect(registeredDomain(url)).toBe(domain);
  });

  test.each(["http://104.248.13.122/health", "http://localhost:3000/ok", "http://[::1]/", "not a url", ""])(
    "%s has no domain to watch",
    (url) => {
      expect(registeredDomain(url)).toBeNull();
    },
  );

  test("TLD of a registered domain", () => {
    expect(topLevelDomain("twilbp.com")).toBe("com");
    expect(topLevelDomain("example.co.in")).toBe("in");
  });
});

describe("RDAP bootstrap", () => {
  const bootstrap = parseBootstrap({
    services: [
      [["com", "net"], ["https://rdap.verisign.com/com/v1/"]],
      [["in"], ["http://rdap.example/in", "https://rdap.nixiregistry.in/rdap"]],
      [["IO"], ["https://registry.io/rdap/"]],
      ["garbage"],
    ],
  });

  test("maps every TLD to its server, preferring HTTPS and adding a trailing slash", () => {
    expect(bootstrap.get("com")).toBe("https://rdap.verisign.com/com/v1/");
    expect(bootstrap.get("net")).toBe("https://rdap.verisign.com/com/v1/");
    expect(bootstrap.get("in")).toBe("https://rdap.nixiregistry.in/rdap/");
  });
  test("IANA's file wins over the built-in list; the built-in list fills the gaps", () => {
    expect(rdapServerFor("io", bootstrap)).toBe("https://registry.io/rdap/");
    expect(rdapServerFor("me", bootstrap)).toBe(BUILT_IN_RDAP_SERVERS.me);
    expect(rdapServerFor("ch", new Map())).toBe("https://rdap.nic.ch/");
  });
  test("unknown TLD → null", () => {
    expect(rdapServerFor("example", bootstrap)).toBeNull();
  });
  test("rejects a file that isn't a bootstrap file", () => {
    expect(() => parseBootstrap({ hello: 1 })).toThrow();
  });
  test("lookup URL", () => {
    expect(rdapDomainUrl("https://rdap.verisign.com/com/v1/", "kamacose.com")).toBe(
      "https://rdap.verisign.com/com/v1/domain/kamacose.com",
    );
  });
});

describe("RDAP domain response", () => {
  // Shape of the real answer for kamacose.com (rdap.verisign.com).
  const kamacose = {
    objectClassName: "domain",
    ldhName: "KAMACOSE.COM",
    status: ["pending transfer"],
    events: [
      { eventAction: "registration", eventDate: "2020-10-14T13:45:50Z" },
      { eventAction: "expiration", eventDate: "2027-10-14T13:45:50Z" },
      { eventAction: "last changed", eventDate: "2026-10-21T12:40:10Z" },
      { eventAction: "last update of RDAP database", eventDate: "2026-10-22T06:24:35Z" },
    ],
  };

  test("reads registration, expiration, last changed and the RDAP update time", () => {
    const record = parseRdapDomain(kamacose);
    expect(record.registeredAt?.toISOString()).toBe("2020-10-14T13:45:50.000Z");
    expect(record.expiresAt?.toISOString()).toBe("2027-10-14T13:45:50.000Z");
    expect(record.lastChangedAt?.toISOString()).toBe("2026-10-21T12:40:10.000Z");
    expect(record.registryUpdatedAt?.toISOString()).toBe("2026-10-22T06:24:35.000Z");
    expect(record.statuses).toEqual(["pending transfer"]);
  });
  test("event names are matched case-insensitively; fractional seconds and date-only values are fine", () => {
    const record = parseRdapDomain({
      objectClassName: "domain",
      events: [
        { eventAction: "Expiration", eventDate: "2027-09-18T07:41:44.83Z" },
        { eventAction: "registration", eventDate: "2020-09-14" },
      ],
    });
    expect(record.expiresAt?.toISOString()).toBe("2027-09-18T07:41:44.830Z");
    expect(record.registeredAt?.toISOString()).toBe("2020-09-14T00:00:00.000Z");
  });
  test("a registry that publishes no expiry (.ch) gives null, not an error", () => {
    const record = parseRdapDomain({ objectClassName: "domain", events: [{ eventAction: "registration", eventDate: "2020-09-14" }], status: ["active"] });
    expect(record.expiresAt).toBeNull();
    expect(record.lastChangedAt).toBeNull();
  });
  test("bad dates and odd entries are ignored", () => {
    const record = parseRdapDomain({ ldhName: "x.com", events: [{ eventAction: "expiration", eventDate: "soon" }, null, 7], status: [1, "OK "] });
    expect(record.expiresAt).toBeNull();
    expect(record.statuses).toEqual(["ok"]);
  });
  test("an answer that isn't a domain record is rejected", () => {
    expect(() => parseRdapDomain({ errorCode: 400 })).toThrow();
    expect(() => parseRdapDomain(null)).toThrow();
  });
});

describe("expiry status", () => {
  const now = new Date("2026-10-22T09:00:00Z");
  const inDays = (days: number) => new Date(now.getTime() + days * 86_400_000);
  const found = (expiresAt: Date | null, extra: Partial<DomainState> = {}): DomainState => ({
    expiresAt,
    manualExpiresAt: null,
    registryStatuses: [],
    lastLookupResult: "FOUND",
    ...extra,
  });

  test.each([
    [400, "OK"],
    [31, "OK"],
    [30, "WARNING"],
    [8, "WARNING"],
    [7, "CRITICAL"],
    [0.5, "CRITICAL"],
    [-0.01, "EXPIRED"],
    [-30, "EXPIRED"],
  ])("%s days left → %s", (days, status) => {
    expect(assessDomain(found(inDays(days)), now).status).toBe(status);
  });

  test("days left is whole days", () => {
    expect(assessDomain(found(inDays(7.9)), now).daysLeft).toBe(7);
  });
  test("never looked up and no manual date → Not checked yet", () => {
    expect(assessDomain(null, now).status).toBe("PENDING");
    expect(assessDomain(found(null, { lastLookupResult: null }), now).status).toBe("PENDING");
  });
  test("the registry has no record → Not registered", () => {
    expect(assessDomain(found(null, { lastLookupResult: "NOT_FOUND" }), now).status).toBe("NOT_REGISTERED");
  });
  test("no expiry date available → Unknown", () => {
    expect(assessDomain(found(null), now).status).toBe("UNKNOWN");
    expect(assessDomain(found(null, { lastLookupResult: "UNSUPPORTED" }), now).status).toBe("UNKNOWN");
  });
  test("a failed lookup keeps judging by the last known date", () => {
    expect(assessDomain(found(inDays(5), { lastLookupResult: "FAILED" }), now).status).toBe("CRITICAL");
  });
  test("a manual date is used only when the registry has none", () => {
    const manualOnly = assessDomain(found(null, { manualExpiresAt: inDays(20) }), now);
    expect(manualOnly).toMatchObject({ status: "WARNING", expirySource: "manual", daysLeft: 20 });
    const both = assessDomain(found(inDays(200), { manualExpiresAt: inDays(5) }), now);
    expect(both).toMatchObject({ status: "OK", expirySource: "registry" });
  });
  test("risky registry statuses raise the level, ordinary locks don't", () => {
    expect(assessDomain(found(inDays(200), { registryStatuses: ["redemption period"] }), now).status).toBe("CRITICAL");
    expect(assessDomain(found(inDays(200), { registryStatuses: ["pending transfer"] }), now).status).toBe("WARNING");
    expect(assessDomain(found(inDays(3), { registryStatuses: ["pending transfer"] }), now).status).toBe("CRITICAL");
    expect(assessDomain(found(inDays(200), { registryStatuses: ["client renew prohibited", "client transfer prohibited"] }), now).status).toBe("OK");
  });
  test("what the dashboard calls out", () => {
    expect(["NOT_REGISTERED", "EXPIRED", "CRITICAL", "UNKNOWN", "WARNING"].every((s) => needsAttention(s as never))).toBe(true);
    expect(needsAttention("OK")).toBe(false);
    expect(needsAttention("PENDING")).toBe(false);
  });

  test("listing order: problems first, Unknown right after Critical, then nearest expiry", () => {
    const rows = [
      { name: "ok-later.com", ...assessDomain(found(inDays(300)), now) },
      { name: "warn.com", ...assessDomain(found(inDays(20)), now) },
      { name: "ok-sooner.com", ...assessDomain(found(inDays(100)), now) },
      { name: "unknown.ch", ...assessDomain(found(null), now) },
      { name: "critical.me", ...assessDomain(found(inDays(7)), now) },
      { name: "expired.com", ...assessDomain(found(inDays(-2)), now) },
      { name: "gone.com", ...assessDomain(found(null, { lastLookupResult: "NOT_FOUND" }), now) },
    ];
    expect(rows.sort(compareDomains).map((row) => row.name)).toEqual([
      "gone.com",
      "expired.com",
      "critical.me",
      "unknown.ch",
      "warn.com",
      "ok-sooner.com",
      "ok-later.com",
    ]);
  });

  test("days-left wording", () => {
    expect(formatDaysLeft(7)).toBe("in 7 days");
    expect(formatDaysLeft(1)).toBe("in 1 day");
    expect(formatDaysLeft(0)).toBe("today");
    expect(formatDaysLeft(-3)).toBe("3 days ago");
  });
});

describe("manual expiry date", () => {
  test("a date is stored at 12:00 UTC", () => {
    expect(parseManualExpiry("2027-09-14")).toEqual({ ok: true, date: new Date("2027-09-14T12:00:00Z") });
  });
  test("empty clears it", () => {
    expect(parseManualExpiry("  ")).toEqual({ ok: true, date: null });
  });
  test.each(["14/09/2027", "2027-02-30", "1999-01-01", "abc"])("%s is rejected", (value) => {
    expect(parseManualExpiry(value).ok).toBe(false);
  });
  test("non-strings are rejected", () => {
    expect(parseManualExpiry(undefined).ok).toBe(false);
  });
});

describe("Add Domain input", () => {
  test.each([
    ["dualnum.com", "dualnum.com"],
    ["  DualNum.COM ", "dualnum.com"],
    ["www.dualnum.com", "dualnum.com"],
    ["api.dualnum.com:8443/health", "dualnum.com"],
    ["https://api.calai.twilbp.com/x?y=1", "twilbp.com"],
    ["shop.example.co.in", "example.co.in"],
  ])("%s → %s", (input, domain) => {
    expect(domainFromInput(input)).toBe(domain);
  });
  test.each(["", "localhost", "104.248.13.122", "two words.com", "http://"])("%j is not a domain", (input) => {
    expect(domainFromInput(input)).toBeNull();
  });

  const form = (values: Partial<{ name: string; registrar: string; manualExpiresOn: string }>) => ({
    name: "",
    registrar: "",
    manualExpiresOn: "",
    ...values,
  });
  test("a valid form is normalised", () => {
    expect(validateDomain(form({ name: "www.Example.com", registrar: "  GoDaddy ", manualExpiresOn: "2027-01-31" }), { requireName: true })).toEqual({
      ok: true,
      data: { name: "example.com", registrar: "GoDaddy", manualExpiresAt: new Date("2027-01-31T12:00:00Z") },
    });
  });
  test("empty optional fields become null", () => {
    expect(validateDomain(form({ name: "example.com" }), { requireName: true })).toMatchObject({ ok: true, data: { registrar: null, manualExpiresAt: null } });
  });
  test("each field reports its own error", () => {
    expect(validateDomain(form({ registrar: "x".repeat(151), manualExpiresOn: "2027-02-30" }), { requireName: true })).toEqual({
      ok: false,
      fieldErrors: { name: "Domain is required", registrar: "Registrar must be 150 characters or fewer", manualExpiresOn: "That date does not exist." },
    });
    expect(validateDomain(form({ name: "localhost" }), { requireName: true })).toMatchObject({ ok: false, fieldErrors: { name: "Enter a domain name such as example.com" } });
  });
  test("Edit doesn't need a name", () => {
    expect(validateDomain(form({ registrar: "Cloudflare" }), { requireName: false }).ok).toBe(true);
  });
  test("a stored manual date round-trips to the date input", () => {
    expect(manualExpiryInputValue(new Date("2027-01-31T12:00:00Z"))).toBe("2027-01-31");
    expect(manualExpiryInputValue(null)).toBe("");
  });
});

describe("Domains page filters", () => {
  type Row = Parameters<typeof filterDomainRows>[0][number];
  const row = (name: string, status: Row["status"], apps: string[], extra: { registrar?: string; manual?: boolean } = {}): Row => ({
    name,
    status,
    apps: apps.map((code) => ({ projectCode: code, projectName: `${code} app` })),
    domain: { registrar: extra.registrar ?? null, addedManually: extra.manual ?? false },
  });
  const rows = [
    row("gone.com", "NOT_REGISTERED", ["A-1"]),
    row("past.com", "EXPIRED", ["A-2"]),
    row("soon.me", "CRITICAL", ["A-3", "B-1"], { registrar: "GoDaddy" }),
    row("unknown.ch", "UNKNOWN", ["A-4"]),
    row("month.com", "WARNING", [], { manual: true, registrar: "Cloudflare" }),
    row("safe.com", "OK", ["A-5"]),
    row("new.com", "PENDING", [], { manual: true }),
  ];
  const names = (query: Partial<ReturnType<typeof parseDomainQuery>>) =>
    filterDomainRows(rows, { status: null, source: "all", q: "", ...query }).shown.map((r) => r.name);

  test("status tabs", () => {
    expect(names({ status: "attention" })).toEqual(["gone.com", "past.com", "soon.me", "unknown.ch", "month.com"]);
    expect(names({ status: "expired" })).toEqual(["gone.com", "past.com"]);
    expect(names({ status: "unknown" })).toEqual(["unknown.ch", "new.com"]);
    expect(names({ status: "ok" })).toEqual(["safe.com"]);
  });
  test("source", () => {
    expect(names({ source: "manual" })).toEqual(["month.com", "new.com"]);
    expect(names({ source: "apps" })).toHaveLength(5);
  });
  test("search matches domain, registrar and app code / name, case-insensitively", () => {
    expect(names({ q: "soon" })).toEqual(["soon.me"]);
    expect(names({ q: "cloudflare" })).toEqual(["month.com"]);
    expect(names({ q: "b-1" })).toEqual(["soon.me"]);
    expect(names({ q: "A-5 APP" })).toEqual(["safe.com"]);
  });
  test("tab counts follow the source and search, not the selected tab", () => {
    const { counts, total, shown } = filterDomainRows(rows, { status: "ok", source: "manual", q: "" });
    expect(total).toBe(2);
    expect(shown).toEqual([]);
    expect(counts).toMatchObject({ warning: 1, unknown: 1, ok: 0, attention: 1 });
  });
  test("URL round trip, unknown values ignored, defaults left out", () => {
    const query = parseDomainQuery({ status: "critical", source: "manual", q: "  go  " });
    expect(query).toEqual({ status: "critical", source: "manual", q: "go" });
    expect(domainsHref(query)).toBe("/domains?status=critical&source=manual&q=go");
    expect(domainsHref(query, { status: null, source: "all", q: "" })).toBe("/domains");
    expect(parseDomainQuery({ status: "nope", source: ["apps", "manual"] })).toEqual({ status: null, source: "apps", q: "" });
  });
});
