import type { Target, TargetCandidate } from "@circuy/contracts";

export const TEST_ATTRS = ["data-testid", "data-test", "data-cy", "data-qa"];

export function isVisible(element: Element): boolean {
	if (!(element instanceof HTMLElement)) return false;
	const style = getComputedStyle(element);
	if (style.display === "none" || style.visibility === "hidden") return false;
	const rect = element.getBoundingClientRect();
	return rect.width > 0 && rect.height > 0;
}

function uniqueVisible(elements: Iterable<Element>): Element | null {
	const visible = [...new Set(elements)].filter(isVisible);
	return visible.length === 1 ? (visible[0] ?? null) : null;
}

function normalizeText(value: string): string {
	return value.replace(/\s+/g, " ").trim();
}

export function accessibleName(element: Element): string {
	if (!(element instanceof HTMLElement)) return "";
	const labelled = element.getAttribute("aria-labelledby");
	if (labelled) {
		const parts = labelled
			.split(/\s+/)
			.map((id) => document.getElementById(id)?.textContent ?? "")
			.join(" ");
		const name = normalizeText(parts);
		if (name) return name;
	}
	const label = element.getAttribute("aria-label");
	if (label) return normalizeText(label);
	if (element instanceof HTMLImageElement && element.alt) {
		return normalizeText(element.alt);
	}
	if (element.title) return normalizeText(element.title);
	return normalizeText(element.textContent ?? "");
}

export function implicitRole(element: Element): string | null {
	const tag = element.tagName.toLowerCase();
	if (tag === "button") return "button";
	if (tag === "a" && element.hasAttribute("href")) return "link";
	if (tag === "input" || tag === "textarea") return "textbox";
	if (tag === "select") return "combobox";
	return null;
}

function matchesAria(element: Element, role: string, name: string): boolean {
	const actualRole = element.getAttribute("role") ?? implicitRole(element);
	return actualRole === role && accessibleName(element) === name;
}

export function resolveCandidate(
	candidate: TargetCandidate,
	root: ParentNode = document,
): Element | null {
	switch (candidate.kind) {
		case "testid": {
			const found: Element[] = [];
			for (const attr of TEST_ATTRS) {
				found.push(
					...root.querySelectorAll(
						`[${attr}="${CSS.escape(candidate.value)}"]`,
					),
				);
			}
			return uniqueVisible(found);
		}
		case "id": {
			const found =
				root instanceof Document
					? root.getElementById(candidate.value)
					: root.querySelector(`#${CSS.escape(candidate.value)}`);
			return found && isVisible(found) ? found : null;
		}
		case "aria": {
			const found = [...root.querySelectorAll("*")].filter((element) =>
				matchesAria(element, candidate.role, candidate.name),
			);
			return uniqueVisible(found);
		}
		case "text": {
			const found = [...root.querySelectorAll(candidate.tag)].filter(
				(element) =>
					normalizeText(element.textContent ?? "") === candidate.value,
			);
			return uniqueVisible(found);
		}
		case "css": {
			try {
				return uniqueVisible(root.querySelectorAll(candidate.value));
			} catch {
				return null;
			}
		}
	}
}

export function resolveTarget(
	target: Target,
	root: ParentNode = document,
): Element | null {
	for (const candidate of target.candidates) {
		const found = resolveCandidate(candidate, root);
		if (found) return found;
	}
	return null;
}

export function waitForTarget(
	target: Target,
	timeoutMs = 5000,
	root: ParentNode = document,
): Promise<Element | null> {
	const existing = resolveTarget(target, root);
	if (existing) return Promise.resolve(existing);

	return new Promise((resolve) => {
		const observeRoot =
			root instanceof Document ? (root.body ?? root.documentElement) : root;
		if (!(observeRoot instanceof Node)) {
			resolve(null);
			return;
		}

		let frame = 0;
		const finish = (element: Element | null) => {
			window.clearTimeout(timer);
			observer.disconnect();
			if (frame) cancelAnimationFrame(frame);
			resolve(element);
		};

		const evaluate = () => {
			frame = 0;
			const found = resolveTarget(target, root);
			if (found) finish(found);
		};

		const observer = new MutationObserver(() => {
			if (frame) return;
			frame = requestAnimationFrame(evaluate);
		});
		observer.observe(observeRoot, {
			subtree: true,
			childList: true,
			attributes: true,
		});

		const timer = window.setTimeout(() => finish(null), timeoutMs);
	});
}
