/**
 * The filter toolbar on its own: the controls that decide what reaches the URL,
 * driven through a harness that plays the part the page plays.
 *
 * The timezone is fixed because the only invalid bound reachable in jsdom is a
 * wall-clock time inside a spring-forward gap: jsdom applies the
 * `datetime-local` value sanitization algorithm, so text that is merely
 * incomplete never reaches the component at all — the input hands it back as
 * the empty string, which is a bound the operator cleared.
 */

import { useCallback, useState } from "react";
import { afterAll, expect, test, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
import { unixToDatetimeLocal } from "./datetime";

vi.stubEnv("TZ", "Europe/Paris");
afterAll(() => vi.unstubAllEnvs());

/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
const GAP_WALL_TIME = "2026-03-29T02:30:00";

/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
	const ids = input.getAttribute("aria-describedby");
	if (ids === null) throw new Error("input has no aria-describedby");
	return ids
		.split(/\s+/)
		.map((id) => {
			const node = document.getElementById(id);
			if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
			return node.textContent ?? "";
		})
		.join(" ");
}

/**
 * The page's half of the contract: the applied state is held outside the form.
 *
 * `deferred` holds the patches back instead of applying them, so a test can
 * land them in an order the network and the router can genuinely produce —
 * an early debounce arriving after a later one, over a draft that has moved on.
 */
function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) {
	const patches: Array<Partial<AppliedFilters>> = [];
	const state: { current: AppliedFilters } = { current: initial };
	const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };

	function Harness() {
		const [applied, setApplied] = useState(initial);
		state.current = applied;
		setter.current = setApplied;
		const onApply = useCallback((patch: Partial<AppliedFilters>) => {
			patches.push(patch);
			if (!deferred) setApplied((prev) => ({ ...prev, ...patch }));
		}, []);
		const onClear = useCallback(() => {
			patches.push({});
			if (!deferred) setApplied(NO_FILTERS);
		}, []);
		return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
	}

	render(<Harness />);
	/** The URL moving under the form: a commit landing, or the back button. */
	function land(next: AppliedFilters) {
		act(() => setter.current!(next));
	}
	return { patches, state, land };
}

function openTimeMenu() {
	fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
}

test("the segments commit to the applied state on the click, with no button in between", () => {
	const { patches, state } = renderFilters();
	expect(screen.getAllByRole("radio").map((radio) => radio.closest("label")?.textContent)).toEqual([
		"Any",
		"Blocked",
		"Allowed",
	]);

	fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
	expect(patches).toEqual([{ blocked: true }]);
	expect(state.current.blocked).toBe(true);

	fireEvent.click(screen.getByRole("radio", { name: "Allowed" }));
	expect(state.current.blocked).toBe(false);

	fireEvent.click(screen.getByRole("radio", { name: "Any" }));
	expect(state.current.blocked).toBeUndefined();
});

test("a time preset writes an absolute second, and the trigger names the preset", () => {
	const now = 1_800_000_000_000;
	vi.spyOn(Date, "now").mockReturnValue(now);
	const { patches } = renderFilters();

	openTimeMenu();
	expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
		"Any time",
		"Past hour",
		"Past 24 hours",
		"Past 7 days",
		"Custom…",
	]);

	fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));

	// Both bounds, concrete: an open upper bound would keep taking in queries
	// logged after the reader stopped looking, so the same link tomorrow would
	// name a different day.
	expect(patches).toEqual([{ since: now / 1000 - 86_400, until: now / 1000 }]);
	expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();

	// A clock that has moved on does not move the label: the URL still holds the
	// second the click resolved to.
	vi.spyOn(Date, "now").mockReturnValue(now + 60_000);
	fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
	expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();

	vi.restoreAllMocks();
});

test("bounds nobody picked here read as Custom, and Any time clears both", () => {
	const { patches } = renderFilters({ ...NO_FILTERS, since: 1_700_000_000, until: 1_700_000_600 });
	expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();

	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Any time" }));

	expect(patches).toEqual([{ since: undefined, until: undefined }]);
	expect(screen.getByRole("button", { name: "Time: Any time" })).toBeTruthy();
});

test("the custom range applies only through Set range, and Enter is Set range", () => {
	const { patches } = renderFilters();
	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));

	const since = screen.getByLabelText("Since") as HTMLInputElement;
	fireEvent.change(since, { target: { value: "2026-03-29T04:30:00" } });
	// Typing a bound is not applying it: the other half may still be half-typed.
	expect(patches).toEqual([]);

	const applied = { since: Math.floor(new Date("2026-03-29T04:30:00").getTime() / 1000), until: undefined };
	fireEvent.click(screen.getByRole("button", { name: "Set range" }));
	expect(patches).toEqual([applied]);

	// Enter inside a bound is that bound's action, not the toolbar's submit: the
	// outer form flushes the text filters and would apply neither half of this.
	fireEvent.change(since, { target: { value: "2026-03-29T05:30:00" } });
	fireEvent.keyDown(since, { key: "Enter" });
	expect(patches).toHaveLength(2);
	expect(patches[1]).toEqual({
		since: Math.floor(new Date("2026-03-29T05:30:00").getTime() / 1000),
		until: undefined,
	});
});

test("a rejected bound marks its own input and describes it", () => {
	const { patches } = renderFilters();
	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));

	const since = screen.getByLabelText("Since") as HTMLInputElement;
	const until = screen.getByLabelText("Until") as HTMLInputElement;

	fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
	fireEvent.click(screen.getByRole("button", { name: "Set range" }));

	expect(since.getAttribute("aria-invalid")).toBe("true");
	expect(describedText(since)).toContain("daylight saving");
	// Refused means refused, and only the offending bound carries the mark.
	expect(patches).toEqual([]);
	expect(until.getAttribute("aria-invalid")).toBeNull();
	expect(until.getAttribute("aria-describedby")).toBeNull();
});

test("an upper bound below the lower one is refused, against the bound that is wrong", () => {
	const { patches } = renderFilters();
	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));

	const since = screen.getByLabelText("Since") as HTMLInputElement;
	const until = screen.getByLabelText("Until") as HTMLInputElement;
	fireEvent.change(since, { target: { value: "2026-05-02T10:00:00" } });
	fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
	fireEvent.click(screen.getByRole("button", { name: "Set range" }));

	expect(until.getAttribute("aria-invalid")).toBe("true");
	expect(describedText(until)).toContain("selects nothing");
	expect(since.getAttribute("aria-invalid")).toBeNull();
	expect(patches).toEqual([]);

	// The window is half-open, so two equal bounds are as empty as an inverted
	// pair and are refused the same way.
	fireEvent.change(until, { target: { value: "2026-05-02T10:00:00" } });
	fireEvent.click(screen.getByRole("button", { name: "Set range" }));
	expect(until.getAttribute("aria-invalid")).toBe("true");
	expect(patches).toEqual([]);

	fireEvent.change(until, { target: { value: "2026-05-02T11:00:00" } });
	fireEvent.click(screen.getByRole("button", { name: "Set range" }));
	expect(until.getAttribute("aria-invalid")).toBeNull();
	expect(patches).toHaveLength(1);
});

test("Clear keeps its place while there is nothing to clear, and stays out of the tab order", () => {
	const { state } = renderFilters();
	const clear = screen.getByText("Clear");
	expect(clear.getAttribute("tabindex")).toBe("-1");
	expect(clear.getAttribute("aria-hidden")).toBe("true");

	fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));

	expect(clear.getAttribute("tabindex")).toBeNull();
	expect(clear.getAttribute("aria-hidden")).toBeNull();

	fireEvent.click(clear);
	expect(state.current).toEqual(NO_FILTERS);
	expect(clear.getAttribute("tabindex")).toBe("-1");
});

test("Clear empties the text drafts along with the applied filters", () => {
	vi.useFakeTimers();
	try {
		const { state } = renderFilters();
		const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
		fireEvent.change(domain, { target: { value: "ads" } });
		act(() => vi.advanceTimersByTime(400));
		expect(state.current.domain).toBe("ads");

		fireEvent.click(screen.getByRole("button", { name: "Clear" }));
		act(() => vi.advanceTimersByTime(400));

		expect(domain.value).toBe("");
		expect(state.current).toEqual(NO_FILTERS);
	} finally {
		vi.useRealTimers();
	}
});

test("the domain field is search-shaped, unspellchecked, and labelled without a visible label", () => {
	renderFilters();
	const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
	expect(domain.type).toBe("search");
	expect(domain.getAttribute("spellcheck")).toBe("false");
	expect(domain.getAttribute("autocomplete")).toBe("off");
	expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
	// The magnifier is decoration over the field, never a second thing to read.
	expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy();
});

test("a bound change does not reset the domain draft that is still being typed", () => {
	const now = 1_800_000_000_000;
	vi.spyOn(Date, "now").mockReturnValue(now);
	const { state } = renderFilters();

	const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
	fireEvent.change(domain, { target: { value: "ads" } });

	// A preset commits at once, while the typed word is still waiting out its
	// debounce. The URL moves, but not the part of it this field is derived from.
	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));

	expect(state.current.since).toBe(now / 1000 - 3600);
	expect(domain.value).toBe("ads");

	vi.restoreAllMocks();
});

test("a debounced commit landing late does not roll the field back over newer typing", () => {
	vi.useFakeTimers();
	try {
		const { patches, land } = renderFilters(NO_FILTERS, true);
		const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;

		// Two commits in flight, and the reader is a keystroke ahead of both.
		fireEvent.change(domain, { target: { value: "ad" } });
		act(() => vi.advanceTimersByTime(400));
		fireEvent.change(domain, { target: { value: "ads" } });
		act(() => vi.advanceTimersByTime(400));
		expect(patches).toEqual([
			{ domain: "ad", client: undefined },
			{ domain: "ads", client: undefined },
		]);
		fireEvent.change(domain, { target: { value: "adsx" } });

		// The older one lands first. It is this toolbar's own echo, two keystrokes
		// stale, and seeding the field from it would delete what was typed since.
		land({ ...NO_FILTERS, domain: "ad" });
		expect(domain.value).toBe("adsx");

		land({ ...NO_FILTERS, domain: "ads" });
		expect(domain.value).toBe("adsx");

		// An address that was never sent from here is someone else's — a pasted
		// link, or the back button — and that one does move the field.
		land({ ...NO_FILTERS, domain: "elsewhere" });
		expect(domain.value).toBe("elsewhere");
	} finally {
		vi.useRealTimers();
	}
});

test("a window moved from outside re-opens the custom row over the bounds it arrived with", () => {
	const now = 1_800_000_000_000;
	vi.spyOn(Date, "now").mockReturnValue(now);
	const { land } = renderFilters();

	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
	// A preset supersedes the custom row, so it is shut and the label is the preset.
	expect(screen.queryByLabelText("Since")).toBeNull();
	expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();

	// The back button, or a pasted link: a range this form did not choose.
	const since = 1_700_000_000;
	land({ ...NO_FILTERS, since, until: since + 600 });

	expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
	// The label says Custom, so the fields it names have to be on screen holding
	// that range — a Custom window with nothing to read is the label lying.
	// jsdom's value sanitizer spells the milliseconds out, so the seeded text is
	// the prefix rather than the whole of what the input holds.
	expect((screen.getByLabelText("Since") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since));
	expect((screen.getByLabelText("Until") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since + 600));

	vi.restoreAllMocks();
});

test("a window moved from outside clears an error left over from the old one", () => {
	const { land } = renderFilters();
	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));

	const until = screen.getByLabelText("Until") as HTMLInputElement;
	fireEvent.change(screen.getByLabelText("Since"), { target: { value: "2026-05-02T10:00:00" } });
	fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
	fireEvent.click(screen.getByRole("button", { name: "Set range" }));
	expect(until.getAttribute("aria-invalid")).toBe("true");

	const since = 1_700_000_000;
	land({ ...NO_FILTERS, since, until: since + 600 });

	// The bounds the message was about are gone, so the message is too.
	expect((screen.getByLabelText("Until") as HTMLInputElement).getAttribute("aria-invalid")).toBeNull();
	expect(screen.queryByRole("alert")).toBeNull();
});

test("the client field names the exact match it performs", () => {
	renderFilters();
	const client = screen.getByLabelText("Client IP (exact match)") as HTMLInputElement;
	expect(client.getAttribute("placeholder")).toBe("Client IP…");
});

test("an earlier range commit landing late does not unseat the pick that is current", () => {
	const first = 1_800_000_000_000;
	vi.spyOn(Date, "now").mockReturnValue(first);
	const { patches, land } = renderFilters(NO_FILTERS, true);

	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));

	// A second pick before the first has come back through the URL.
	const second = first + 60_000;
	vi.spyOn(Date, "now").mockReturnValue(second);
	openTimeMenu();
	fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));

	expect(patches).toEqual([
		{ since: first / 1000 - 3600, until: first / 1000 },
		{ since: second / 1000 - 86_400, until: second / 1000 },
	]);

	// The older window lands first. It is this toolbar's own echo, so it must not
	// read as a range someone else chose: doing that would clear the preset and
	// throw the custom row open over a window the reader has already moved past.
	land({ ...NO_FILTERS, since: first / 1000 - 3600, until: first / 1000 });
	expect(screen.queryByLabelText("Since")).toBeNull();

	land({ ...NO_FILTERS, since: second / 1000 - 86_400, until: second / 1000 });
	expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
	expect(screen.queryByLabelText("Since")).toBeNull();

	vi.restoreAllMocks();
});
