/**
 * The filter toolbar over the Activity history table.
 *
 * The applied state is the URL, never this form: what the reader sees is what
 * the link they can paste to a housemate will show. So this holds a draft, and
 * every control writes through to the URL — the segments and the time presets
 * at once, the two text fields after a pause so a five-letter domain is one
 * navigation rather than five.
 *
 * A time preset writes both bounds as absolute seconds, resolved once at the
 * click. Neither half may be left open: a bookmark has to describe the same
 * investigation tomorrow, and a window that slid overnight — or one that stayed
 * open at the top and swallowed everything logged since — would answer a
 * different question under the same link.
 *
 * A URL can move under this form at any time, and the draft only follows the
 * part of it that actually moved. A field is re-seeded when its own URL value
 * changed and the new value is not one this toolbar just wrote: the debounce
 * means the URL is always a little behind the keyboard, and a landing commit
 * must not roll the input back over the letters typed since.
 *
 * The custom range is the one control that does not live-apply. Two half-typed
 * timestamps are a normal intermediate state of typing one of them, and a lower
 * bound at or above an upper bound selects nothing at all, so the pair is
 * validated and applied together or not at all.
 *
 * Live mode does not render this at all — the page mounts it inside the History
 * panel. The stream is unfiltered, and a row of controls that looked usable
 * would promise filtering that is not happening.
 */

import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
import type { ActivitySearch } from "./search";

/** Long enough that a typed word is one navigation, short enough to feel live. */
const DEBOUNCE_MS = 350;

const RESULTS = [
	{ value: "any", label: "Any" },
	{ value: "blocked", label: "Blocked" },
	{ value: "allowed", label: "Allowed" },
] as const;

const PRESETS = [
	{ label: "Any time", seconds: null },
	{ label: "Past hour", seconds: 3600 },
	{ label: "Past 24 hours", seconds: 86_400 },
	{ label: "Past 7 days", seconds: 604_800 },
] as const;

const CUSTOM_ITEM = "Custom…";

/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
const HIT_TARGET = 44;

const styles = stylex.create({
	toolbar: {
		marginTop: "1rem",
		display: "flex",
		flexWrap: "wrap",
		alignItems: "center",
		gap: "0.5rem",
	},
	/** The domain field is the one that grows; everything else keeps its size. */
	searchWrap: {
		position: "relative",
		flexGrow: 1,
		flexShrink: 1,
		flexBasis: "14rem",
		display: "flex",
	},
	searchIcon: {
		position: "absolute",
		insetInlineStart: "0.5rem",
		top: "50%",
		transform: "translateY(-50%)",
		color: colors.textMuted,
		pointerEvents: "none",
	},
	/** Every control in the row is a pointer target before it is anything else. */
	field: {
		minHeight: HIT_TARGET,
	},
	searchInput: {
		width: "100%",
		paddingInlineStart: "1.875rem",
	},
	clientInput: {
		flexGrow: 0,
		flexShrink: 1,
		flexBasis: "10rem",
	},
	/** A button is text-sized by default; this is the hit area around the text. */
	hitTarget: {
		minHeight: HIT_TARGET,
		minWidth: HIT_TARGET,
		display: "inline-flex",
		alignItems: "center",
		justifyContent: "center",
	},
	resultGroup: {
		display: "flex",
		gap: "0.25rem",
	},
	/** The segment styling of the Overview period picker, item for item. */
	segment: {
		cursor: "pointer",
		borderStyle: "none",
		borderRadius: "0.25rem",
		paddingInline: "0.625rem",
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
		minHeight: HIT_TARGET,
		minWidth: HIT_TARGET,
		display: "inline-flex",
		alignItems: "center",
		justifyContent: "center",
	},
	/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
	segmentFocusVisible: {
		outlineWidth: 2,
		outlineStyle: "solid",
		outlineColor: colors.focus,
		outlineOffset: 2,
	},
	/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
	segmentSelected: {
		backgroundColor: {
			default: "oklch(92% 0.004 286.32)",
			"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
		},
		color: colors.text,
		fontWeight: 500,
	},
	segmentIdle: {
		backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
		color: colors.textSecondary,
	},
	popover: {
		borderRadius: "0.25rem",
		borderWidth: 1,
		borderStyle: "solid",
		borderColor: colors.border,
		backgroundColor: colors.surfaceRaised,
		color: colors.text,
		boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
	},
	menu: {
		outlineStyle: "none",
		paddingBlock: "0.25rem",
	},
	menuItem: {
		cursor: "pointer",
		paddingInline: "0.75rem",
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
		whiteSpace: "nowrap",
		minHeight: HIT_TARGET,
		display: "flex",
		alignItems: "center",
	},
	/** Inset because an item flush against the popover edge clips an outset ring. */
	menuItemFocused: {
		backgroundColor: colors.primary,
		color: colors.primaryText,
		outlineColor: { default: null, ":focus-visible": colors.primaryText },
	},
	/**
	 * Clear keeps its box when there is nothing to clear. It appears the moment a
	 * filter is set, and a control that appeared by widening the row would move
	 * every other control out from under the pointer that was reaching for it.
	 */
	clearHidden: {
		visibility: "hidden",
	},
	customRow: {
		marginTop: "0.5rem",
		display: "flex",
		flexWrap: "wrap",
		alignItems: "flex-end",
		gap: "0.5rem",
	},
	customField: {
		display: "flex",
		flexDirection: "column",
		gap: "0.25rem",
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
	},
	error: {
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
		color: colors.dangerText,
	},
});

/** The applied filters, with `mode` left to the page that owns the switch. */
export type AppliedFilters = Omit<ActivitySearch, "mode">;

/** Every filter off — what Clear applies, and the loader's empty-filter case. */
export const NO_FILTERS: AppliedFilters = {
	domain: undefined,
	client: undefined,
	blocked: undefined,
	since: undefined,
	until: undefined,
};

/** The half-open window the API reads: `ts >= since` and `ts < until`. */
interface Bounds {
	since: number | undefined;
	until: number | undefined;
}

/** The two text filters as the URL spells them, with absent written as empty. */
interface TextPair {
	domain: string;
	client: string;
}

function sameBounds(a: Bounds, b: Bounds): boolean {
	return a.since === b.since && a.until === b.until;
}

function blockedOption(blocked: boolean | undefined): string {
	if (blocked === undefined) return "any";
	return blocked ? "blocked" : "allowed";
}

function optionBlocked(value: string): boolean | undefined {
	if (value === "blocked") return true;
	return value === "allowed" ? false : undefined;
}

/** A filter value the URL can carry: trimmed, and empty means absent. */
function textFilter(value: string): string | undefined {
	const trimmed = value.trim();
	return trimmed === "" ? undefined : trimmed;
}

/** The message plus the bound it belongs to, so that input can point at it. */
interface BoundError {
	field: "since" | "until";
	message: string;
}

/** The toolbar is rendered once per page, so the message can hold a fixed id. */
const ERROR_ID = "activity-filter-error";

function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
	const label = field === "since" ? "Since" : "Until";
	return {
		field,
		message:
			reason === "unparseable"
				? `${label} is not a complete date and time.`
				: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
	};
}

/** The preset a label is claimed for, kept only while the URL still holds it. */
interface ChosenPreset extends Bounds {
	label: string;
}

/**
 * What this toolbar knows about the URL, and what it is still waiting to see
 * come back from it.
 *
 * `url` and `bounds` are the last values looked at, so a change can be told
 * apart field by field. `pendingText` and `pendingBounds` are every commit made
 * here that the URL has not echoed yet. Both are queues rather than single
 * slots: two keystrokes either side of the debounce put two text commits in
 * flight, and two menu picks in quick succession do the same to the range. In
 * both cases the older echo landing second must not be mistaken for someone
 * else's edit — that is what would clear the preset out from under the pick
 * that is actually current.
 */
interface Sync {
	url: TextPair;
	bounds: Bounds;
	pendingText: TextPair[];
	pendingBounds: Bounds[];
}

interface Props {
	applied: AppliedFilters;
	/** Merges a patch into the applied search. `replace` keeps typing out of history. */
	onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
	onClear: () => void;
}

export default function ActivityFilters({ applied, onApply, onClear }: Props) {
	const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
	const urlBounds: Bounds = { since: applied.since, until: applied.until };

	const [domain, setDomain] = useState(urlText.domain);
	const [client, setClient] = useState(urlText.client);
	const [preset, setPreset] = useState<ChosenPreset | null>(null);
	const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
	const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
	const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
	const [error, setError] = useState<BoundError | null>(null);
	const [sync, setSync] = useState<Sync>({
		url: urlText,
		bounds: urlBounds,
		pendingText: [],
		pendingBounds: [],
	});

	const textMoved = sync.url.domain !== urlText.domain || sync.url.client !== urlText.client;
	const boundsMoved = !sameBounds(sync.bounds, urlBounds);
	if (textMoved || boundsMoved) {
		let pendingText = sync.pendingText;
		let pendingBounds = sync.pendingBounds;

		if (textMoved) {
			// The newest commit the URL matches, and everything before it, has now
			// been accounted for; an older one landing later is not new news.
			const echo = pendingText.findLastIndex(
				(sent) => sent.domain === urlText.domain && sent.client === urlText.client,
			);
			if (echo >= 0) {
				pendingText = pendingText.slice(echo + 1);
			} else {
				pendingText = [];
				if (sync.url.domain !== urlText.domain) setDomain(urlText.domain);
				if (sync.url.client !== urlText.client) setClient(urlText.client);
			}
		}

		if (boundsMoved) {
			const echo = pendingBounds.findLastIndex((sent) => sameBounds(sent, urlBounds));
			if (echo >= 0) {
				pendingBounds = pendingBounds.slice(echo + 1);
			} else {
				// Someone else moved the window — the back button, or a pasted link.
				// Whatever this form was showing about the old one is now wrong: the
				// preset it was named after, an error against bounds that are gone,
				// and a custom row that is open or shut for the wrong range.
				pendingBounds = [];
				setPreset(null);
				setError(null);
				setShowCustom(urlBounds.since !== undefined || urlBounds.until !== undefined);
				setSince(datetimeField(urlBounds.since));
				setUntil(datetimeField(urlBounds.until));
			}
		}

		setSync({ url: urlText, bounds: urlBounds, pendingText, pendingBounds });
	}

	const dirty = textFilter(domain) !== applied.domain || textFilter(client) !== applied.client;

	const commitText = useCallback(
		(replace: boolean) => {
			const next = { domain: textFilter(domain), client: textFilter(client) };
			setSync((prev) => ({
				...prev,
				pendingText: [...prev.pendingText, { domain: next.domain ?? "", client: next.client ?? "" }],
			}));
			onApply(next, replace);
		},
		[domain, client, onApply],
	);

	function commitBounds(bounds: Bounds) {
		setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] }));
		onApply(bounds);
	}

	useEffect(() => {
		if (!dirty) return;
		const id = setTimeout(() => commitText(true), DEBOUNCE_MS);
		return () => clearTimeout(id);
	}, [dirty, commitText]);

	function flush(event: FormEvent) {
		event.preventDefault();
		if (dirty) commitText(true);
	}

	function selectPreset(label: string) {
		if (label === CUSTOM_ITEM) {
			setShowCustom(true);
			return;
		}
		const option = PRESETS.find((candidate) => candidate.label === label);
		if (option === undefined) return;
		setShowCustom(false);
		setError(null);
		if (option.seconds === null) {
			setPreset(null);
			setSince(datetimeField(undefined));
			setUntil(datetimeField(undefined));
			commitBounds({ since: undefined, until: undefined });
			return;
		}
		// Both bounds, resolved once, here. An open upper bound would keep taking
		// in queries logged after the reader stopped looking, so "the past hour"
		// would name a different hour every time the link was opened.
		const now = Math.floor(Date.now() / 1000);
		const bounds: Bounds = { since: now - option.seconds, until: now };
		setPreset({ label, ...bounds });
		setSince(datetimeField(bounds.since));
		setUntil(datetimeField(bounds.until));
		commitBounds(bounds);
	}

	function setRange() {
		const sinceValue = resolveDatetimeField(since);
		if (!sinceValue.ok) {
			setError(boundError("since", sinceValue.reason));
			return;
		}
		const untilValue = resolveDatetimeField(until);
		if (!untilValue.ok) {
			setError(boundError("until", untilValue.reason));
			return;
		}
		// The window is half-open — `ts >= since` and `ts < until` — so two equal
		// bounds are as empty as an inverted pair, and neither is worth applying.
		if (sinceValue.value !== undefined && untilValue.value !== undefined && untilValue.value <= sinceValue.value) {
			setError({
				field: "until",
				message: "Until must be after Since, or the range selects nothing.",
			});
			return;
		}
		setError(null);
		setPreset(null);
		commitBounds({ since: sinceValue.value, until: untilValue.value });
	}

	function clear() {
		setDomain("");
		setClient("");
		setPreset(null);
		setShowCustom(false);
		setSince(datetimeField(undefined));
		setUntil(datetimeField(undefined));
		setError(null);
		setSync((prev) => ({
			...prev,
			pendingText: [],
			pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
		}));
		onClear();
	}

	const active =
		domain.trim() !== "" ||
		client.trim() !== "" ||
		applied.blocked !== undefined ||
		applied.since !== undefined ||
		applied.until !== undefined;

	// The label the URL earns on its own, overridden only while a preset click is
	// still the whole of what the URL says. Bounds nobody here chose read as
	// "Custom": that is what a pasted link or an edited range is.
	let timeLabel = "Any time";
	if (applied.since !== undefined || applied.until !== undefined) {
		timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
	}

	/** True for the one bound the current message is about; nothing else is marked. */
	const invalid = (field: BoundError["field"]): true | undefined =>
		error !== null && error.field === field ? true : undefined;

	function boundInput(field: BoundError["field"]) {
		const state = field === "since" ? since : until;
		const set = field === "since" ? setSince : setUntil;
		return (
			<label {...stylex.props(styles.customField)}>
				{field === "since" ? "Since" : "Until"}
				<input
					type="datetime-local"
					step={1}
					value={state.text}
					aria-invalid={invalid(field)}
					aria-describedby={invalid(field) && ERROR_ID}
					onChange={(event) => set(editDatetimeField(state, event.target.value))}
					onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
						// Enter here means this range, not the toolbar's text filters:
						// the two bounds only ever apply together, and the outer form's
						// submit would apply neither of them.
						if (event.key !== "Enter") return;
						event.preventDefault();
						setRange();
					}}
					onBlur={() => {
						const resolved = resolveDatetimeField(state);
						if (!resolved.ok) setError(boundError(field, resolved.reason));
						else if (invalid(field)) setError(null);
					}}
					{...stylex.props(shared.smallInput, styles.field, shared.focusRing)}
				/>
				{invalid(field) && (
					<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
						{error?.message}
					</span>
				)}
			</label>
		);
	}

	return (
		<form onSubmit={flush}>
			<div {...stylex.props(styles.toolbar)}>
				<div {...stylex.props(styles.searchWrap)}>
					<svg
						aria-hidden="true"
						viewBox="0 0 16 16"
						width="14"
						height="14"
						fill="none"
						stroke="currentColor"
						strokeWidth="1.5"
						{...stylex.props(styles.searchIcon)}
					>
						<circle cx="7" cy="7" r="4.5" />
						<path d="M10.5 10.5 14 14" strokeLinecap="round" />
					</svg>
					<input
						type="search"
						aria-label="Filter domains"
						placeholder="Filter domains…"
						spellCheck={false}
						autoComplete="off"
						value={domain}
						onChange={(event) => setDomain(event.target.value)}
						{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
					/>
				</div>
				{/*
				 * The client filter matches one address exactly — the server has no
				 * substring match for it — so the name says so rather than leaving the
				 * reader to discover it by typing half an address and getting nothing.
				 */}
				<input
					type="text"
					aria-label="Client IP (exact match)"
					placeholder="Client IP…"
					spellCheck={false}
					autoComplete="off"
					value={client}
					onChange={(event) => setClient(event.target.value)}
					{...stylex.props(shared.smallInput, styles.field, styles.clientInput, shared.focusRing)}
				/>
				<RadioGroup
					aria-label="Result"
					orientation="horizontal"
					value={blockedOption(applied.blocked)}
					onChange={(next) => onApply({ blocked: optionBlocked(next) })}
					className={() => stylex.props(styles.resultGroup).className ?? ""}
				>
					{RESULTS.map((option) => (
						<Radio
							key={option.value}
							value={option.value}
							className={({ isSelected, isFocusVisible }) =>
								stylex.props(
									styles.segment,
									isSelected ? styles.segmentSelected : styles.segmentIdle,
									isFocusVisible && styles.segmentFocusVisible,
								).className ?? ""
							}
						>
							{option.label}
						</Radio>
					))}
				</RadioGroup>
				<MenuTrigger>
					<Button
						className={() =>
							stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
						}
					>
						Time: {timeLabel}
					</Button>
					<Popover className={() => stylex.props(styles.popover).className ?? ""}>
						<Menu {...stylex.props(styles.menu)}>
							{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
								<MenuItem
									key={label}
									onAction={() => selectPreset(label)}
									className={({ isFocused }) =>
										stylex.props(
											styles.menuItem,
											shared.insetFocusRing,
											isFocused && styles.menuItemFocused,
										).className ?? ""
									}
								>
									{label}
								</MenuItem>
							))}
						</Menu>
					</Popover>
				</MenuTrigger>
				<button
					type="button"
					onClick={clear}
					tabIndex={active ? undefined : -1}
					aria-hidden={active ? undefined : true}
					{...stylex.props(shared.button, styles.hitTarget, shared.focusRing, !active && styles.clearHidden)}
				>
					Clear
				</button>
			</div>
			{showCustom && (
				<div {...stylex.props(styles.customRow)}>
					{boundInput("since")}
					{boundInput("until")}
					<button
						type="button"
						onClick={setRange}
						{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
					>
						Set range
					</button>
				</div>
			)}
		</form>
	);
}
