/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import {
  backfillClockLabelColors,
  buildClocksRowAriaLabel,
  buildLocalizedTimeZoneMap,
  buildNextClockZones,
  decorateDefaultZones,
  formatDateTimeAttr,
  formatTime,
  getCityAbbreviation,
  getCityFromTimeZone,
  getClockFormDerivedState,
  getDefaultTimeZones,
  getLocalizedTimeZoneName,
  getTimeZoneAbbreviation,
  isValidTimeZone,
  isValidPaletteName,
  parseClockZonesPref,
  removeClockZoneAtIndex,
  shouldUse12HourTimeFormat,
} from "content-src/components/Widgets/Clocks/ClocksHelpers";

const originalResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;

function withLocalTz(tz, fn) {
  const stubbed = Intl.DateTimeFormat.prototype.resolvedOptions;
  Intl.DateTimeFormat.prototype.resolvedOptions = function () {
    const opts = originalResolvedOptions.call(this);
    return { ...opts, timeZone: tz };
  };
  try {
    fn();
  } finally {
    Intl.DateTimeFormat.prototype.resolvedOptions = stubbed;
  }
}

describe("getDefaultTimeZones", () => {
  it("returns IANA zone strings without decoration", () => {
    withLocalTz("Europe/Paris", () => {
      const result = getDefaultTimeZones();
      expect(result).toEqual([
        "Europe/Paris",
        "Europe/Berlin",
        "Australia/Sydney",
        "America/New_York",
      ]);
      expect(result.every(z => typeof z === "string")).toBe(true);
    });
  });

  it("dedupes when the local TZ matches a fixed zone (Berlin)", () => {
    withLocalTz("Europe/Berlin", () => {
      expect(getDefaultTimeZones()).toEqual([
        "Europe/Berlin",
        "Australia/Sydney",
        "America/New_York",
        "America/Los_Angeles",
      ]);
    });
  });

  it("dedupes when the local TZ matches a fixed zone (New York)", () => {
    withLocalTz("America/New_York", () => {
      expect(getDefaultTimeZones()).toEqual([
        "America/New_York",
        "Europe/Berlin",
        "Australia/Sydney",
        "America/Los_Angeles",
      ]);
    });
  });

  it("always returns exactly four zones", () => {
    withLocalTz("Asia/Tokyo", () => {
      expect(getDefaultTimeZones()).toHaveLength(4);
    });
    withLocalTz("Europe/Berlin", () => {
      expect(getDefaultTimeZones()).toHaveLength(4);
    });
  });

  it("falls back to the fixed set when Intl throws", () => {
    const original = Intl.DateTimeFormat;
    Intl.DateTimeFormat = function () {
      throw new Error("Intl unavailable");
    };
    try {
      const result = getDefaultTimeZones();
      expect(result).toEqual([
        "Europe/Berlin",
        "Australia/Sydney",
        "America/New_York",
        "America/Los_Angeles",
      ]);
    } finally {
      Intl.DateTimeFormat = original;
    }
  });
});

describe("decorateDefaultZones", () => {
  it("returns null label and labelColor for all zones", () => {
    const decorated = decorateDefaultZones([
      "Europe/Berlin",
      "Australia/Sydney",
    ]);
    expect(decorated).toHaveLength(2);
    expect(decorated[0]).toEqual({
      timeZone: "Europe/Berlin",
      label: null,
      labelColor: null,
    });
    expect(decorated[1]).toEqual({
      timeZone: "Australia/Sydney",
      label: null,
      labelColor: null,
    });
  });

  it("returns an empty array when given an empty array", () => {
    expect(decorateDefaultZones([])).toEqual([]);
  });
});

describe("backfillClockLabelColors", () => {
  it("adds colors only for labeled clocks that are missing one", () => {
    const randomStub = jest.spyOn(Math, "random").mockReturnValue(0);
    try {
      expect(
        backfillClockLabelColors([
          {
            timeZone: "America/New_York",
            city: "Boston",
            label: "Office",
            labelColor: null,
          },
          {
            timeZone: "Europe/Berlin",
            city: "Berlin",
            label: "Home",
            labelColor: "purple",
          },
          {
            timeZone: "Asia/Tokyo",
            city: "Tokyo",
            label: null,
            labelColor: null,
          },
        ])
      ).toEqual([
        {
          timeZone: "America/New_York",
          city: "Boston",
          label: "Office",
          labelColor: "cyan",
        },
        {
          timeZone: "Europe/Berlin",
          city: "Berlin",
          label: "Home",
          labelColor: "purple",
        },
        {
          timeZone: "Asia/Tokyo",
          city: "Tokyo",
          label: null,
          labelColor: null,
        },
      ]);
    } finally {
      randomStub.mockRestore();
    }
  });
});

describe("isValidTimeZone", () => {
  it("accepts valid IANA time zones and rejects invalid values", () => {
    expect(isValidTimeZone("Europe/Berlin")).toBe(true);
    expect(isValidTimeZone("Invalid/Zone")).toBe(false);
    expect(isValidTimeZone("")).toBe(false);
    expect(isValidTimeZone(null)).toBe(false);
  });
});

describe("getClockFormDerivedState", () => {
  const supportedTimeZones = [
    "Europe/Berlin",
    "Australia/Sydney",
    "America/New_York",
  ];

  it("resolves a selected time zone and enables save when a slot is open", () => {
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "Ber",
        clockSelectedTimeZone: "Europe/Berlin",
        isEditingClock: false,
        supportedTimeZones,
      })
    ).toMatchObject({
      canAddSelectedClock: true,
      resolvedClockTimeZone: "Europe/Berlin",
      showLocationDropdown: false,
    });
  });

  it("resolves exact city queries without requiring a selected result", () => {
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "new york",
        clockSelectedTimeZone: "",
        isEditingClock: false,
        supportedTimeZones,
      })
    ).toMatchObject({
      canAddSelectedClock: true,
      resolvedClockTimeZone: "America/New_York",
      showLocationDropdown: true,
    });
  });

  it("shows filtered results for unresolved partial queries", () => {
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "syd",
        clockSelectedTimeZone: "",
        isEditingClock: false,
        supportedTimeZones,
      })
    ).toMatchObject({
      canAddSelectedClock: false,
      filteredTimeZones: ["Australia/Sydney"],
      resolvedClockTimeZone: "",
      showLocationDropdown: true,
    });
  });

  it("keeps the dropdown open with no filtered results for an unmatched query", () => {
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "zzz",
        clockSelectedTimeZone: "",
        isEditingClock: false,
        supportedTimeZones,
      })
    ).toMatchObject({
      canAddSelectedClock: false,
      filteredTimeZones: [],
      resolvedClockTimeZone: "",
      showLocationDropdown: true,
    });
  });

  it("allows edits even when no add slots are open", () => {
    expect(
      getClockFormDerivedState({
        canAddClock: false,
        clockSearchQuery: "Berlin",
        clockSelectedTimeZone: "",
        isEditingClock: true,
        supportedTimeZones,
      }).canAddSelectedClock
    ).toBe(true);
  });

  it("filters by the localized zone name when a map is provided", () => {
    const localizedTimeZoneMap = new Map([
      ["Europe/Berlin", "Central European Time"],
      ["Australia/Sydney", "Eastern Australia Time"],
      ["America/New_York", "Eastern Time"],
    ]);
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "central european",
        clockSelectedTimeZone: "",
        isEditingClock: false,
        localizedTimeZoneMap,
        supportedTimeZones,
      }).filteredTimeZones
    ).toEqual(["Europe/Berlin"]);
  });

  it("resolves an exact-match localized name when only one zone produces it", () => {
    const localizedTimeZoneMap = new Map([
      ["America/New_York", "Eastern Time"],
    ]);
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "Eastern Time",
        clockSelectedTimeZone: "",
        isEditingClock: false,
        localizedTimeZoneMap,
        supportedTimeZones,
      })
    ).toMatchObject({
      resolvedClockTimeZone: "America/New_York",
      canAddSelectedClock: true,
    });
  });

  it("does not auto-resolve an ambiguous localized name; dropdown stays open", () => {
    const supportedWithDuplicates = [
      "America/Detroit",
      "America/New_York",
      "America/Toronto",
    ];
    const localizedTimeZoneMap = new Map([
      ["America/Detroit", "Eastern Time"],
      ["America/New_York", "Eastern Time"],
      ["America/Toronto", "Eastern Time"],
    ]);
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "Eastern Time",
        clockSelectedTimeZone: "",
        isEditingClock: false,
        localizedTimeZoneMap,
        supportedTimeZones: supportedWithDuplicates,
      })
    ).toMatchObject({
      resolvedClockTimeZone: "",
      showLocationDropdown: true,
      canAddSelectedClock: false,
    });
  });

  it("still resolves the exact IANA id or city even if the localized name is ambiguous", () => {
    const supportedWithDuplicates = [
      "America/Detroit",
      "America/New_York",
      "America/Toronto",
    ];
    const localizedTimeZoneMap = new Map([
      ["America/Detroit", "Eastern Time"],
      ["America/New_York", "Eastern Time"],
      ["America/Toronto", "Eastern Time"],
    ]);
    expect(
      getClockFormDerivedState({
        canAddClock: true,
        clockSearchQuery: "new york",
        clockSelectedTimeZone: "",
        isEditingClock: false,
        localizedTimeZoneMap,
        supportedTimeZones: supportedWithDuplicates,
      }).resolvedClockTimeZone
    ).toBe("America/New_York");
  });
});

describe("getLocalizedTimeZoneName", () => {
  // Single-result Intl.DateTimeFormat mock for unit tests that only
  // need one call per assertion; takes the parts[] array directly.
  const withMockedIntl = (parts, fn) => {
    const Original = Intl.DateTimeFormat;
    Intl.DateTimeFormat = function () {
      return { formatToParts: () => parts };
    };
    try {
      fn();
    } finally {
      Intl.DateTimeFormat = Original;
    }
  };

  it("returns the timeZoneName part from Intl.DateTimeFormat.formatToParts", () => {
    withMockedIntl(
      [
        { type: "literal", value: "12:34 " },
        { type: "timeZoneName", value: "Eastern Time" },
      ],
      () => {
        expect(getLocalizedTimeZoneName("America/New_York", "en-US")).toBe(
          "Eastern Time"
        );
      }
    );
  });

  it("falls back to the IANA id when no timeZoneName part is present", () => {
    withMockedIntl([{ type: "literal", value: "noise" }], () => {
      expect(getLocalizedTimeZoneName("Europe/Berlin", "en-US")).toBe(
        "Europe/Berlin"
      );
    });
  });

  it("falls back to the IANA id when Intl.DateTimeFormat throws", () => {
    const Original = Intl.DateTimeFormat;
    Intl.DateTimeFormat = function () {
      throw new Error("unsupported option");
    };
    try {
      expect(getLocalizedTimeZoneName("America/New_York", "en-US")).toBe(
        "America/New_York"
      );
    } finally {
      Intl.DateTimeFormat = Original;
    }
  });
});

describe("buildLocalizedTimeZoneMap", () => {
  it("returns a Map keyed by the input zones", () => {
    const map = buildLocalizedTimeZoneMap(
      ["Europe/Berlin", "America/New_York"],
      "en-US"
    );
    expect(map).toBeInstanceOf(Map);
    expect(map.size).toBe(2);
    expect(map.has("Europe/Berlin")).toBe(true);
    expect(map.has("America/New_York")).toBe(true);
  });

  it("returns an empty map for an empty list", () => {
    expect(buildLocalizedTimeZoneMap([], "en-US").size).toBe(0);
  });
});

describe("buildNextClockZones", () => {
  const berlin = { timeZone: "Europe/Berlin", label: null, labelColor: null };
  const sydney = {
    timeZone: "Australia/Sydney",
    label: null,
    labelColor: null,
  };
  const tokyo = { timeZone: "Asia/Tokyo", label: null, labelColor: null };

  it("appends a new zone when editingClockIndex is null", () => {
    expect(buildNextClockZones([berlin, sydney], null, tokyo)).toEqual([
      berlin,
      sydney,
      tokyo,
    ]);
  });

  it("replaces the zone at the given index", () => {
    expect(buildNextClockZones([berlin, sydney], 0, tokyo)).toEqual([
      tokyo,
      sydney,
    ]);
    expect(buildNextClockZones([berlin, sydney], 1, tokyo)).toEqual([
      berlin,
      tokyo,
    ]);
  });

  it("does not mutate the original array", () => {
    const zones = [berlin, sydney];
    buildNextClockZones(zones, null, tokyo);
    expect(zones).toHaveLength(2);
  });
});

describe("removeClockZoneAtIndex", () => {
  const berlin = { timeZone: "Europe/Berlin", label: null, labelColor: null };
  const sydney = {
    timeZone: "Australia/Sydney",
    label: null,
    labelColor: null,
  };
  const tokyo = { timeZone: "Asia/Tokyo", label: null, labelColor: null };

  it("removes the element at the given index", () => {
    expect(removeClockZoneAtIndex([berlin, sydney, tokyo], 0)).toEqual([
      sydney,
      tokyo,
    ]);
    expect(removeClockZoneAtIndex([berlin, sydney, tokyo], 1)).toEqual([
      berlin,
      tokyo,
    ]);
    expect(removeClockZoneAtIndex([berlin, sydney, tokyo], 2)).toEqual([
      berlin,
      sydney,
    ]);
  });

  it("does not mutate the original array", () => {
    const zones = [berlin, sydney];
    removeClockZoneAtIndex(zones, 0);
    expect(zones).toHaveLength(2);
  });
});

describe("parseClockZonesPref", () => {
  it("preserves stored clock order, labels, colors, and duplicate zones", () => {
    const prefValue = JSON.stringify([
      {
        timeZone: "America/New_York",
        city: "Boston",
        label: "Office",
        labelColor: "cyan",
      },
      {
        timeZone: "America/New_York",
        label: "Family",
        labelColor: "green",
      },
      {
        timeZone: "Asia/Tokyo",
        label: "",
        labelColor: "not-a-palette",
      },
    ]);

    expect(parseClockZonesPref(prefValue)).toEqual([
      {
        timeZone: "America/New_York",
        city: "Boston",
        label: "Office",
        labelColor: "cyan",
      },
      {
        timeZone: "America/New_York",
        label: "Family",
        labelColor: "green",
      },
      {
        timeZone: "Asia/Tokyo",
        label: null,
        labelColor: null,
      },
    ]);
  });

  it("accepts string time zone entries", () => {
    expect(parseClockZonesPref(JSON.stringify(["Europe/Berlin"]))).toEqual([
      {
        timeZone: "Europe/Berlin",
        label: null,
        labelColor: null,
      },
    ]);
  });

  it("drops invalid entries and caps the result at four clocks", () => {
    const prefValue = JSON.stringify([
      { timeZone: "Invalid/NotAZone" },
      { timeZone: "Europe/Berlin" },
      { timeZone: "Australia/Sydney" },
      { timeZone: "America/New_York" },
      { timeZone: "America/Los_Angeles" },
      { timeZone: "Asia/Tokyo" },
    ]);

    expect(parseClockZonesPref(prefValue).map(clock => clock.timeZone)).toEqual(
      [
        "Europe/Berlin",
        "Australia/Sydney",
        "America/New_York",
        "America/Los_Angeles",
      ]
    );
  });

  it("returns null for missing, malformed, or empty pref data", () => {
    expect(parseClockZonesPref("")).toBeNull();
    expect(parseClockZonesPref("{")).toBeNull();
    expect(
      parseClockZonesPref(JSON.stringify({ timeZone: "Europe/Berlin" }))
    ).toBeNull();
    expect(parseClockZonesPref(JSON.stringify([{ timeZone: "" }]))).toBeNull();
  });
});

describe("shouldUse12HourTimeFormat", () => {
  it("returns true when prefValue is '12'", () => {
    expect(
      shouldUse12HourTimeFormat({ prefValue: "12", locale: "en-GB" })
    ).toBe(true);
  });

  it("returns false when prefValue is '24'", () => {
    expect(
      shouldUse12HourTimeFormat({ prefValue: "24", locale: "en-US" })
    ).toBe(false);
  });

  it("falls back to locale default when prefValue is empty", () => {
    expect(shouldUse12HourTimeFormat({ prefValue: "", locale: "en-US" })).toBe(
      true
    );
    expect(shouldUse12HourTimeFormat({ prefValue: "", locale: "en-GB" })).toBe(
      false
    );
  });

  it("treats undefined prefValue the same as empty", () => {
    expect(
      shouldUse12HourTimeFormat({ prefValue: undefined, locale: "en-US" })
    ).toBe(true);
  });
});

describe("getCityFromTimeZone", () => {
  it("returns the last IANA segment with underscores as spaces", () => {
    expect(getCityFromTimeZone("America/Los_Angeles")).toBe("Los Angeles");
    expect(getCityFromTimeZone("Europe/Berlin")).toBe("Berlin");
    expect(getCityFromTimeZone("Asia/Tokyo")).toBe("Tokyo");
  });

  it("handles single-segment and empty inputs", () => {
    expect(getCityFromTimeZone("UTC")).toBe("UTC");
    expect(getCityFromTimeZone("")).toBe("");
    expect(getCityFromTimeZone(null)).toBe("");
    expect(getCityFromTimeZone(undefined)).toBe("");
  });
});

describe("getCityAbbreviation", () => {
  it("returns the IATA code for cities in the curated map", () => {
    expect(getCityAbbreviation("New York")).toBe("NYC");
    expect(getCityAbbreviation("Los Angeles")).toBe("LAX");
    expect(getCityAbbreviation("Tokyo")).toBe("TYO");
    expect(getCityAbbreviation("Hong Kong")).toBe("HKG");
    expect(getCityAbbreviation("Zurich")).toBe("ZRH");
    // Regression: Seoul's IATA *city* code is SEL; ICN is Incheon airport
    // only. Mapping to ICN would display the airport code for a Seoul clock.
    expect(getCityAbbreviation("Seoul")).toBe("SEL");
  });

  it("falls back to first-3-chars-uppercased for unknown cities", () => {
    expect(getCityAbbreviation("Berlin")).toBe("BER");
    expect(getCityAbbreviation("Paris")).toBe("PAR");
    expect(getCityAbbreviation("Sydney")).toBe("SYD");
    expect(getCityAbbreviation("Ulaanbaatar")).toBe("ULA");
  });

  it("strips whitespace from multi-word cities before slicing", () => {
    // Ensures multi-word IANA cities not in the map don't emit a trailing
    // space (e.g. "St Johns" would slice to "ST " without this guard).
    expect(getCityAbbreviation("St Johns")).toBe("STJ");
    expect(getCityAbbreviation("Addis Ababa")).toBe("ADD");
    expect(getCityAbbreviation("Cape Town")).toBe("CAP");
  });

  it("resolves both legacy and canonical IANA city names to the same IATA code", () => {
    // tzdata renamed Kiev->Kyiv, Calcutta->Kolkata, Saigon->Ho Chi Minh;
    // depending on OS tzdata version, either spelling can surface from
    // Intl.DateTimeFormat().resolvedOptions().timeZone, so the map has both.
    expect(getCityAbbreviation("Kiev")).toBe("IEV");
    expect(getCityAbbreviation("Kyiv")).toBe("IEV");
    expect(getCityAbbreviation("Calcutta")).toBe("CCU");
    expect(getCityAbbreviation("Kolkata")).toBe("CCU");
    expect(getCityAbbreviation("Saigon")).toBe("SGN");
    expect(getCityAbbreviation("Ho Chi Minh")).toBe("SGN");
  });

  it("handles short and empty inputs", () => {
    expect(getCityAbbreviation("Rio")).toBe("RIO");
    expect(getCityAbbreviation("NY")).toBe("NY");
    expect(getCityAbbreviation("")).toBe("");
    expect(getCityAbbreviation(null)).toBe("");
  });
});

describe("isValidPaletteName", () => {
  it("accepts all known palette names", () => {
    const knownNames = [
      "cyan",
      "green",
      "yellow",
      "purple",
      "red",
      "orange",
      "blue",
      "pink",
      "violet",
      "neutral",
    ];
    knownNames.forEach(name => {
      expect(isValidPaletteName(name)).toBe(true);
    });
  });

  it("rejects unknown, malformed, or non-string inputs", () => {
    expect(isValidPaletteName("not-a-palette")).toBe(false);
    expect(isValidPaletteName("violet extra-class")).toBe(false);
    expect(isValidPaletteName("")).toBe(false);
    expect(isValidPaletteName(null)).toBe(false);
    expect(isValidPaletteName(undefined)).toBe(false);
    expect(isValidPaletteName(42)).toBe(false);
  });
});

describe("getTimeZoneAbbreviation", () => {
  it("falls back to the zone id for invalid zones", () => {
    const bogus = "Invalid/NotAZone";
    expect(getTimeZoneAbbreviation(bogus, "en-US")).toBe(bogus);
  });

  it("honours the provided date for DST-observing zones", () => {
    // Berlin is CET (winter) and CEST (summer). ICU versions may render
    // either the literal "CET/CEST" or "GMT+1/GMT+2"; accept both.
    const winter = new Date("2026-01-15T12:00:00Z");
    const summer = new Date("2026-07-15T12:00:00Z");
    expect(getTimeZoneAbbreviation("Europe/Berlin", "en-US", winter)).toMatch(
      /CET|GMT\+1/
    );
    expect(getTimeZoneAbbreviation("Europe/Berlin", "en-US", summer)).toMatch(
      /CEST|GMT\+2/
    );
  });
});

describe("formatDateTimeAttr", () => {
  it("formats the datetime value in the clock time zone", () => {
    expect(
      formatDateTimeAttr(new Date("2026-04-20T13:44:00Z"), "Asia/Tokyo")
    ).toBe("2026-04-20T22:44");
  });

  it("falls back to an ISO string when the time zone cannot be formatted", () => {
    const date = new Date("2026-04-20T13:44:00Z");
    expect(formatDateTimeAttr(date, "Invalid/Zone")).toBe(date.toISOString());
  });
});

describe("formatTime", () => {
  it("honours an explicit hour12=false override", () => {
    const date = new Date("2026-04-20T13:44:00Z");
    // en-US defaults to 12h; forcing 24h should produce "15" (Berlin = +2).
    const result = formatTime(date, "Europe/Berlin", "en-US", false);
    expect(result).toMatch(/^15/);
    expect(result).not.toMatch(/AM|PM/i);
  });

  it("honours an explicit hour12=true override", () => {
    const date = new Date("2026-04-20T13:44:00Z");
    // en-GB defaults to 24h; forcing 12h should produce an AM/PM string.
    const result = formatTime(date, "Europe/Berlin", "en-GB", true);
    expect(result).toMatch(/AM|PM|am|pm/i);
  });

  it("returns an empty string on invalid time zone", () => {
    const date = new Date("2026-04-20T13:44:00Z");
    expect(formatTime(date, "Invalid/NotAZone", "en-US")).toBe("");
  });
});

describe("buildClocksRowAriaLabel", () => {
  it("joins city, TZ, and time when all three are present", () => {
    expect(buildClocksRowAriaLabel("Berlin", "CET", "14:44")).toBe(
      "Berlin, CET, 14:44"
    );
  });

  it("drops only the time field when empty (pre-tick render)", () => {
    expect(buildClocksRowAriaLabel("Berlin", "CET", "")).toBe("Berlin, CET");
  });

  it("includes the label when present", () => {
    expect(buildClocksRowAriaLabel("Berlin", "CET", "14:44", "Home")).toBe(
      "Home, Berlin, CET, 14:44"
    );
  });
});
