import { act, fireEvent, render, screen } from "@testing-library/react";
import Switch from "./Switch";

/**
 * The drawn track, which is the label's one span that does not hold the hidden
 * input. StyleX compiles to class names and jsdom loads no stylesheet, so the
 * class list is the only place the composed state is observable.
 */
function track(input: HTMLElement): HTMLElement {
	const label = input.closest("label") as HTMLElement;
	const spans = [...label.querySelectorAll("span")];
	const drawn = spans.find((span) => !span.contains(input));
	if (drawn === undefined) throw new Error("the switch drew no track");
	return drawn;
}

test("a switch reports the switch role, not a checkbox one", () => {
	const onChange = vi.fn();
	render(
		<Switch isSelected onChange={onChange}>
			Safe search
		</Switch>,
	);

	// The role is the whole point: it tells a screen reader the setting moves now
	// rather than on some later Save.
	const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
	expect(control.checked).toBe(true);
	expect(screen.queryByRole("checkbox")).toBeNull();

	fireEvent.click(control);
	// The caller owns the state, so the switch reports the value it would move to.
	expect(onChange).toHaveBeenCalledWith(false);
});

test("a bare switch takes its name from aria-label", () => {
	render(<Switch aria-label="udp://1.1.1.1:53 enabled" isSelected={false} onChange={vi.fn()} />);

	const control = screen.getByRole("switch", { name: "udp://1.1.1.1:53 enabled" }) as HTMLInputElement;
	expect(control.checked).toBe(false);
});

test("a disabled switch keeps its state on screen but takes no input", () => {
	render(
		<Switch isSelected isDisabled onChange={vi.fn()}>
			Safe search
		</Switch>,
	);

	const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
	expect(control.checked).toBe(true);
	// The disabled input is the whole guard: a browser fires no click on one, and
	// it is out of the tab order. Clicking it here would prove nothing either way,
	// because `fireEvent` dispatches straight at the node and skips that check.
	expect(control.disabled).toBe(true);
});

test("the track is drawn from the selected state, not left to the browser", () => {
	const { rerender } = render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);

	const control = screen.getByRole("switch");
	const off = track(control).className.split(" ");

	rerender(<Switch aria-label="Safe search" isSelected onChange={vi.fn()} />);
	const on = track(control).className.split(" ");

	// The two states compose to different class sets, so the thumb and the fill
	// actually move rather than the input alone changing.
	expect(on).not.toEqual(off);
});

test("keyboard focus composes the ring onto the drawn track", () => {
	render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);

	const control = screen.getByRole("switch");
	const drawn = track(control);
	const idle = drawn.className.split(" ");
	expect(control.closest("label")?.getAttribute("data-focus-visible")).toBeNull();

	// React Aria only calls focus visible after the modality is keyboard, which
	// is why a bare focus() is not enough to raise the ring.
	act(() => {
		fireEvent.keyDown(document.body, { key: "Tab" });
		control.focus();
	});

	expect(control.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
	const ringed = drawn.className.split(" ");
	expect(ringed.length).toBeGreaterThan(idle.length);
	expect(idle.every((name) => ringed.includes(name))).toBe(true);
});
