import React from 'react';
import { __, _n, sprintf } from '@wordpress/i18n';
import { copyText } from '../copy';

/**
 * A card with a defined header zone.
 *
 * The header used to be a margin-separated block inside the same padding box
 * as the content, which meant a card had no internal structure — title and
 * body shared one field and the eye had nothing to anchor on. Now the header
 * is its own zone with a rule under it, an optional tinted icon square, and
 * the action on the title's line. Every card on every screen changes shape
 * from this one edit.
 *
 * `flush` drops the body padding for cards whose content is a full-bleed
 * table; the table's own header band then sits directly under the rule.
 */
export function Card({ title, subtitle, action, icon, tag, children, className = '', flush = false }) {
	const hasHead = title || action || icon;
	return (
		<section className={`ars-card${flush ? ' is-flush' : ''} ${className}`}>
			{hasHead && (
				<header className="ars-card-head">
					<div className="flex items-start gap-3 min-w-0">
						{icon && <span className="ars-card-icon">{icon}</span>}
						<div className="min-w-0">
							{tag && <span className="ars-card-tag">{tag}</span>}
							{title && <h2 className="t-title m-0">{title}</h2>}
							{/* 70ch broke an 88-character subtitle two words from the end and
							    left "it" alone on a second line. */}
							{subtitle && <p className="t-body m-0 mt-0.5 max-w-[88ch]">{subtitle}</p>}
						</div>
					</div>
					{action && <div className="shrink-0 flex items-center gap-2">{action}</div>}
				</header>
			)}
			<div className={flush ? '' : 'ars-card-body'}>{children}</div>
		</section>
	);
}

/**
 * The screen's own conclusion, stated before its evidence.
 *
 * A screen made of ten equally-weighted cards is a report, and a report makes
 * the reader do the synthesis. Most people opening one of these screens have a
 * few minutes and a different job — they need to know whether anything is wrong
 * and what to press, and only then whether the chart on card six is trending.
 *
 * So the conclusion goes first, at a size nothing else on the page competes
 * with, with the reason underneath it and the action beside it. The `why` line
 * always names a number that appears further down, so the verdict can be
 * checked rather than believed.
 *
 * Tone is the loudest thing here, so it is the thing most worth getting right:
 * `bad` is reserved for something that is actually costing the customer
 * something right now, never for a threshold. A banner that is permanently red
 * is a banner people stop reading, and then the one time it matters they will
 * not see it either.
 */
export function Verdict({ tone = 'neutral', text, why, action, onAction, busy = false, children }) {
	return (
		<section className={`ars-verdict is-${tone}`}>
			<span className="ars-verdict-mark" aria-hidden />
			<div className="ars-verdict-body">
				<p className="ars-verdict-text">{text}</p>
				{why && <p className="ars-verdict-why">{why}</p>}
				{children}
			</div>
			{action && (
				<div className="ars-verdict-action">
					<Button variant={tone === 'bad' || tone === 'warning' ? 'primary' : 'ghost'} busy={busy} onClick={onAction}>
						{action}
					</Button>
				</div>
			)}
		</section>
	);
}

/**
 * The advisor strip — one line that knows what to do next.
 *
 * The dashboard used to open on a greeting and a four-line banner, and the
 * list of prioritised fixes lived a full screen further down. This is the
 * Copilot pattern the reference tools use instead: a single quiet row —
 * coloured dot, one sentence, a count, the one button — that unfolds into
 * the recommendations when asked. The sentence changes; the height never
 * does, so the numbers below always start in the same place.
 */
export function Advisor({ tone = 'neutral', text, sub, count = 0, action, onAction, busy = false, defaultOpen = false, children }) {
	const [open, setOpen] = React.useState(defaultOpen);
	const expandable = !!children;
	return (
		<section className={`ars-advisor is-${tone}${open ? ' is-open' : ''}`}>
			<div className="ars-advisor-row">
				<button
					type="button"
					className="ars-advisor-head"
					onClick={expandable ? () => setOpen((v) => !v) : undefined}
					aria-expanded={expandable ? open : undefined}
					disabled={!expandable}
				>
					<span className="ars-advisor-dot" aria-hidden />
					<span className="ars-advisor-text">{text}</span>
					{sub && <span className="ars-advisor-sub">{sub}</span>}
					{count > 0 && <span className="ars-advisor-count">{count}</span>}
					{expandable && (
						<svg className="ars-advisor-caret" viewBox="0 0 24 24" fill="none" aria-hidden>
							<path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
						</svg>
					)}
				</button>
				{action && (
					<span className="ars-advisor-action">
						<Button variant={tone === 'bad' || tone === 'warning' ? 'primary' : 'ghost'} busy={busy} onClick={onAction}>
							{action}
						</Button>
					</span>
				)}
			</div>
			{open && children && <div className="ars-advisor-body">{children}</div>}
		</section>
	);
}

/**
 * A half-circle gauge — the reference tools' shape for "how healthy".
 *
 * Always the working violet: the gauge is identity, and the bands beside it
 * carry the judgement. A gauge that turns red is a second verdict competing
 * with the real one.
 */
export function Gauge({ value, max = 100, label = 'Score', delta }) {
	const R = 58;
	const C = Math.PI * R;
	const pct = Math.max(0, Math.min(1, (Number(value) || 0) / max));
	const gid = React.useId();
	return (
		<div className="ars-gauge">
			<svg viewBox="0 0 140 86" role="img" aria-label={sprintf(__('%1$s: %2$s of %3$s', 'ai-rank-suite'), label, value, max)}>
				<defs>
					{/* The identity gradient — this is a score instrument, one of the
					    three places the brand ramp is allowed to appear. */}
					<linearGradient id={`${gid}-arc`} x1="0" y1="1" x2="1" y2="0">
						<stop offset="0%" stopColor="var(--ars-brand)" />
						<stop offset="100%" stopColor="var(--ars-brand-2)" />
					</linearGradient>
				</defs>
				<path d="M 12 78 A 58 58 0 0 1 128 78" fill="none" stroke="var(--ars-surface-3)" strokeWidth="12" strokeLinecap="round" />
				{pct > 0 && (
					<path
						d="M 12 78 A 58 58 0 0 1 128 78"
						fill="none"
						stroke={`url(#${gid}-arc)`}
						strokeWidth="12"
						strokeLinecap="round"
						strokeDasharray={`${pct * C} ${C}`}
						style={{ transition: 'stroke-dasharray 600ms cubic-bezier(0.2,0,0,1)' }}
					/>
				)}
				<text x="70" y="64" textAnchor="middle" className="ars-gauge-value">
					{value}
				</text>
				<text x="70" y="80" textAnchor="middle" className="ars-gauge-max">
					/ {max}
				</text>
			</svg>
			{delta !== undefined && delta !== null && (
				<div className="ars-gauge-delta">
					<Delta value={delta} />
				</div>
			)}
		</div>
	);
}

/**
 * The page-level loading and error state, shared by every screen.
 *
 * Forty screens used to open on a bare card that said "Loading…" — a word,
 * in a box, for as long as the request took. This is the shape of the page
 * that is coming (a title bar and three content bands, shimmering), so the
 * layout does not jump when data lands; and when it fails, the reason in a
 * card the reader can act on rather than a sentence that replaced the page.
 */
export function PageState({ error, licensed = true, proTitle, proNote, label = __('Loading', 'ai-rank-suite') }) {
	// A screen whose data needs a licence is not a broken screen. Callers pass
	// licensed={false} (or catch a 402 and pass it here) and get the Pro
	// stand-in instead of the red "could not load" card.
	if (! licensed) {
		return (
			<div className="ars-page">
				<Card title={proTitle || __('Part of AI Rank Suite Pro', 'ai-rank-suite')}>
					<div className="ars-pro is-compact">
						<span className="ars-pro-badge">{__('Pro', 'ai-rank-suite')}</span>
						<div className="min-w-0">
							<p className="ars-pro-text">{proNote || __('This screen reads data that Pro collects. Activating your licence turns it on; nothing here has been deleted.', 'ai-rank-suite')}</p>
						</div>
						<a className="ars-btn ars-btn-ghost ars-pro-cta" href="#/settings/license">{__('See Pro', 'ai-rank-suite')}</a>
					</div>
				</Card>
			</div>
		);
	}
	if (error) {
		return (
			<div className="ars-page">
				<Card title={__('This screen could not load', 'ai-rank-suite')} subtitle={__('The request to your site\'s REST API failed', 'ai-rank-suite')}>
					<div className="grid gap-3">
						<Notice tone="serious">{error}</Notice>
						<div className="flex gap-2 flex-wrap">
							<Button onClick={() => window.location.reload()}>{__('Try again', 'ai-rank-suite')}</Button>
							<Button variant="ghost" onClick={() => { window.location.hash = '/developer'; }}>{__('Open developer tools', 'ai-rank-suite')}</Button>
						</div>
						<p className="m-0 t-caption">
							{__('Usual causes: a security plugin or host firewall blocking /wp-json/, an expired login, or a module quarantined after an error — the developer tools show which.', 'ai-rank-suite')}
						</p>
					</div>
				</Card>
			</div>
		);
	}
	return (
		<div className="ars-page ars-pageskel" aria-busy="true" aria-label={label}>
			<div className="ars-pageskel-bar" />
			<div className="ars-pageskel-grid">
				<div className="ars-pageskel-block" />
				<div className="ars-pageskel-block" />
			</div>
			<div className="ars-pageskel-block is-tall" />
		</div>
	);
}

/**
 * What a card shows when it has nothing to show.
 *
 * The default was a card with a heading, a subtitle and 200px of white space,
 * which reads as broken rather than as empty. Three things fix that: say what
 * would be here, say why it is not, and offer the one action that would fill
 * it. The third is the part usually missing — an empty state with no way out of
 * it is just a dead end with better manners.
 */
export function Empty({ icon, title, children, action, onAction, compact = false }) {
	return (
		<div className={`ars-empty${compact ? ' is-compact' : ''}`}>
			{icon && <span className="ars-empty-icon" aria-hidden>{icon}</span>}
			<div className="ars-empty-text">
				<div className="ars-empty-title">{title}</div>
				{children && <p className="ars-empty-body">{children}</p>}
			</div>
			{action && (
				<Button variant="ghost" onClick={onAction}>
					{action}
				</Button>
			)}
		</div>
	);
}

/**
 * A ranked list where the bar *is* the row.
 *
 * Used wherever the question is "which of these first" — the leverage board,
 * audit findings, prompts by visibility. Three things are always true of that
 * question and are built in here rather than re-decided per screen: the list is
 * sorted by consequence and not by count, the bar is scaled against the largest
 * item so the top row is always full width, and the number on the right is the
 * thing being counted rather than a percentage of something unstated.
 */
export function Board({ items, onItem, empty }) {
	const max = Math.max(1, ...items.map((i) => i.value || 0));
	if (!items.length) return empty || null;
	return (
		<ul className="ars-board">
			{items.map((item) => (
				<li key={item.id} className={`ars-board-row${onItem ? ' is-clickable' : ''}`}>
					<button
						type="button"
						className="ars-board-hit"
						onClick={onItem ? () => onItem(item) : undefined}
						disabled={!onItem}
					>
						<span className="ars-board-label" title={item.hint || item.label}>
							{item.label}
						</span>
						<span className="ars-board-track">
							<span
								className="ars-board-fill"
								style={{ width: `${Math.round(((item.value || 0) / max) * 100)}%`, background: item.color }}
							/>
						</span>
						<span className="ars-board-value tabular-nums">
							{item.display ?? item.value}
							{item.unit && <span className="ars-board-unit">{item.unit}</span>}
						</span>
					</button>
				</li>
			))}
		</ul>
	);
}

/**
 * A dense grid of numbers, in the shape every serious SEO tool uses.
 *
 * The tiles this replaces were generous: one metric each, a sentence of
 * explanation underneath, four across a row. Six metrics therefore took two
 * screenfuls and carried four sentences nobody re-reads after the first visit.
 *
 * Semrush, Ahrefs and Rank Math all land on the same answer, and it is not a
 * stylistic one — somebody checking a site wants every headline number in one
 * glance so they can spot the one that moved. So: a bordered grid, three
 * across, value and change and nothing else, with the explanation demoted to a
 * hover. Density is the feature.
 *
 * `accent` draws the coloured rule under a cell — used to tie a metric to its
 * series in the chart below it, which is the only reason to colour a number.
 */
export function MetricGrid({ metrics, columns = 3, onSelect, active }) {
	/*
	 * Never more tracks than tiles.
	 *
	 * Twelve screens pass `columns={4}` as a constant, which is right until a
	 * tile becomes conditional — and then the row ends in an empty cell that
	 * reads as a missing number rather than as one nobody needed. Clamping
	 * here fixes the whole class at once, including the callers that have not
	 * grown a conditional tile yet.
	 */
	const cols = Math.max(1, Math.min(columns, (metrics || []).length || 1));
	return (
		<div className="ars-metrics" data-columns={cols} style={{ '--ars-metrics-cols': cols }}>
			{metrics.map((m) => {
				const Tag = onSelect ? 'button' : 'div';
				return (
					<Tag
						key={m.id || m.label}
						type={onSelect ? 'button' : undefined}
						className={`ars-metrics-cell${active === (m.id || m.label) ? ' is-active' : ''}${
							onSelect ? ' is-clickable' : ''
						}`}
						onClick={onSelect ? () => onSelect(m.id || m.label) : undefined}
						style={m.accent ? { '--ars-metric-accent': m.accent } : undefined}
					>
						<span className="ars-metrics-label">
							{m.label}
							{m.hint && (
								<span className="ars-metrics-help" title={m.hint} aria-label={m.hint}>
									?
								</span>
							)}
						</span>
						<span className="ars-metrics-row">
							<span className="ars-metrics-value tabular-nums">
								{m.value}
								{m.unit && <span className="ars-metrics-unit">{m.unit}</span>}
							</span>
							{m.delta !== undefined && m.delta !== null && <Delta value={m.delta} invert={m.invert} />}
						</span>
						{m.spark && m.spark.length > 1 && (
							<span className="ars-metrics-spark">
								<Sparkline data={m.spark} color={m.accent || 'var(--ars-brand)'} height={26} />
							</span>
						)}
						{m.sub && <span className="ars-metrics-sub">{m.sub}</span>}
						{m.accent && <span className="ars-metrics-rule" aria-hidden />}
					</Tag>
				);
			})}
		</div>
	);
}

/**
 * A donut with its categories named beside it.
 *
 * The bare ring said "88 / 100" and nothing else, so the only way to learn what
 * made it 88 was to scroll. Rank Math's version of this puts the breakdown next
 * to the number — good, fair, poor, no data, each with a count — and it is
 * strictly better: the score and its composition are one glance instead of two.
 */
export function DonutBreakdown({ value, max = 100, label, rows, footnote }) {
	const total = rows.reduce((a, r) => a + (r.value || 0), 0) || 1;
	let offset = 0;
	const R = 54;
	const C = 2 * Math.PI * R;

	return (
		<div className="ars-donut">
			<svg viewBox="0 0 140 140" className="ars-donut-svg" role="img" aria-label={`${label}: ${value} of ${max}`}>
				<circle cx="70" cy="70" r={R} fill="none" stroke="var(--ars-surface-2)" strokeWidth="16" />
				{rows.map((r, i) => {
					const share = (r.value || 0) / total;
					const dash = `${share * C} ${C}`;
					const rot = `rotate(${-90 + (offset / total) * 360} 70 70)`;
					offset += r.value || 0;
					return (
						<circle
							key={i}
							cx="70"
							cy="70"
							r={R}
							fill="none"
							stroke={r.color}
							strokeWidth="16"
							strokeDasharray={dash}
							transform={rot}
						/>
					);
				})}
				<text x="70" y="70" className="ars-donut-value" textAnchor="middle" dominantBaseline="central">
					{value}
				</text>
				<text x="70" y="92" className="ars-donut-max" textAnchor="middle">
					/ {max}
				</text>
			</svg>
			<div className="ars-donut-legend">
				{rows.map((r, i) => (
					<div key={i} className="ars-donut-row">
						<span className="ars-donut-dot" style={{ background: r.color }} aria-hidden />
						<span className="ars-donut-name">{r.label}</span>
						<span className="ars-donut-count tabular-nums" style={{ color: r.color }}>
							{r.value}
						</span>
					</div>
				))}
				{footnote && <p className="ars-donut-note">{footnote}</p>}
			</div>
		</div>
	);
}

/**
 * The row that "Where this site is weak" is made of, as a component.
 *
 * A number, what it measures, a bar showing how the pages are distributed
 * across good and bad, and how much of the site it touches. It is the most
 * useful thing in this plugin because it answers four questions at once — how
 * bad, at what, how widespread, and is it worth my afternoon — and it does it
 * in one line that can be scanned down a column.
 *
 * Everything that ranks areas of a site now uses it, so the audit, the
 * dashboard and the score breakdown read as the same instrument rather than
 * three different ones.
 *
 * The score is optional on purpose. Where there is a real measured number it is
 * shown; where there is not, inventing one to fill the column would be worse
 * than leaving it out.
 */
export function ScoreRow({ score, label, sub, segments = [], reach, note, tone, onClick, wide = false }) {
	const total = segments.reduce((a, s) => a + (s.value || 0), 0) || 1;
	const band = (n) => (n >= 80 ? 'good' : n >= 50 ? 'mid' : 'bad');
	return (
		<button
			type="button"
			/* Area names are two words; a tracked question is a sentence. One
			   column width cannot serve both without truncating the sentences to
			   "Which SEO suite shoul…", which is the one part of the row a person
			   actually reads. */
			className={`ars-scorerow${onClick ? '' : ' is-static'}${wide ? ' is-wide' : ''}`}
			onClick={onClick}
			disabled={!onClick}
		>
			{score !== undefined && score !== null ? (
				<span className={`ars-scorerow-score is-${tone || band(score)} tabular-nums`}>{score}</span>
			) : (
				/* The column must exist even when the number does not — a missing
				   first cell shifted every other cell one column left, and the
				   label spent its life squeezed into 40px. An em-dash is also
				   the honest value: not measured, not zero. */
				<span className="ars-scorerow-score" style={{ color: 'var(--ars-ink-3)' }}>
					—
				</span>
			)}
			<span className="ars-scorerow-text">
				<span className="ars-scorerow-label">{label}</span>
				{sub && <span className="ars-scorerow-sub">{sub}</span>}
			</span>
			<span className="ars-scorerow-bar" role="img" aria-label={segments.map((x) => `${x.label}: ${x.value}`).join(', ')}>
				{segments
					.filter((x) => (x.value || 0) > 0)
					.map((x, i) => (
						<span
							key={i}
							title={`${x.label}: ${x.value}`}
							style={{ width: `${((x.value || 0) / total) * 100}%`, background: x.color }}
						/>
					))}
			</span>
			<span className="ars-scorerow-reach" title={note || undefined}>
				{reach}
				{note && <span className="ars-scorerow-note">{note}</span>}
			</span>
			{onClick && (
				<svg className="ars-scorerow-caret" viewBox="0 0 24 24" fill="none" aria-hidden>
					<path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
				</svg>
			)}
		</button>
	);
}

/**
 * Configuration, folded away from the thing it configures.
 *
 * An API key, a property ID, a connection: typed once, then read never. The
 * report underneath is read every week. Putting the first above the second
 * meant every visit to Search Console or Analytics opened on a form the person
 * had already filled in, with the numbers they came for pushed below the fold.
 *
 * Collapsed by default — except when the thing it configures is not connected
 * yet, because then the form *is* the task and hiding it would be a puzzle.
 * That is the `defaultOpen` prop, and it should always be driven by connection
 * state rather than by taste.
 */
export function Panel({ title, note, defaultOpen = false, children }) {
	const [open, setOpen] = React.useState(defaultOpen);
	return (
		<section className={`ars-panel${open ? ' is-open' : ''}`}>
			<button type="button" className="ars-panel-head" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
				<svg className="ars-panel-caret" viewBox="0 0 24 24" fill="none" aria-hidden>
					<path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
				</svg>
				<span className="ars-panel-title">{title}</span>
				{note && <span className="ars-panel-note">{note}</span>}
			</button>
			{open && <div className="ars-panel-body">{children}</div>}
		</section>
	);
}

/**
 * A label above a group of cards or tiles.
 *
 * A long screen is a stack of equally-weighted cards with nothing saying
 * where one subject ends and the next begins. This is that.
 */
export function SectionTitle({ children, hint, action }) {
	return (
		<div className="flex items-baseline justify-between gap-4 mt-1">
			<div className="flex items-baseline gap-3 min-w-0">
				<span className="t-overline shrink-0">{children}</span>
				{/* The hint wraps; it used to be `truncate`, which is
				    `white-space: nowrap`, and a nowrap child makes its whole
				    sentence the minimum width of every box above it — no
				    min-w-0 anywhere in the chain can undo that. In German,
				    about a third longer than English, that minimum grew past
				    the page and the entire screen scrolled sideways. A second
				    line costs 18 pixels and cannot do that. */}
				{hint && (
					<span className="t-caption min-w-0" style={{ letterSpacing: 0 }}>
						{hint}
					</span>
				)}
			</div>
			{action}
		</div>
	);
}

/**
 * Counts a number up on first paint.
 *
 * A dashboard whose figures are simply *there* on load reads as static; the
 * same figures arriving read as measured. Short enough that nobody waits for
 * it, and skipped entirely for anything that is not a number — "Live", "Off"
 * and "—" must not animate.
 */
function useCountUp(value, ms = 620) {
	const numeric = typeof value === 'number' ? value : null;
	const [shown, setShown] = React.useState(numeric === null ? value : 0);

	React.useEffect(() => {
		if (numeric === null) {
			setShown(value);
			return undefined;
		}
		if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
			setShown(numeric);
			return undefined;
		}
		let raf;
		const start = performance.now();
		const tick = (now) => {
			const p = Math.min(1, (now - start) / ms);
			// Ease-out: the number decelerates into place rather than stopping.
			setShown(Math.round(numeric * (1 - Math.pow(1 - p, 3))));
			if (p < 1) raf = requestAnimationFrame(tick);
		};
		raf = requestAnimationFrame(tick);
		return () => cancelAnimationFrame(raf);
	}, [numeric, value, ms]);

	return shown;
}

const BUTTON_VARIANTS = {
	primary: 'ars-btn ars-btn-primary',
	tonal: 'ars-btn ars-btn-tonal',
	ghost: 'ars-btn ars-btn-ghost',
	danger: 'ars-btn ars-btn-danger',
};

export function Button({ variant = 'primary', busy = false, children, ...props }) {
	return (
		<button
			type="button"
			className={BUTTON_VARIANTS[variant] || BUTTON_VARIANTS.primary}
			disabled={busy || props.disabled}
			{...props}
		>
			{busy && <Spinner />}
			{children}
		</button>
	);
}

export function Spinner() {
	return (
		<svg className="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
			<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="4" />
			<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
		</svg>
	);
}

export function Switch({ checked, onChange, label, disabled = false }) {
	return (
		<label className="inline-flex items-center gap-2.5 cursor-pointer select-none">
			<span
				role="switch"
				aria-checked={checked}
				aria-disabled={disabled || undefined}
				tabIndex={disabled ? -1 : 0}
				onClick={() => !disabled && onChange(!checked)}
				onKeyDown={(e) => {
					if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
						e.preventDefault();
						onChange(!checked);
					}
				}}
				// Geometry lives in CSS now. It was eleven inline style values
				// that changed between states, including the knob's own size —
				// so "on" rendered as a different control rather than as the
				// same one moved.
				className={`ars-switch${checked ? ' is-on' : ''}${disabled ? ' is-disabled' : ''}`}
			/>
			{label && <span className="text-[13px]">{label}</span>}
		</label>
	);
}

export function Badge({ tone = 'neutral', children }) {
	const tones = {
		neutral: { bg: 'var(--ars-surface-3)', fg: 'var(--ars-ink-2)' },
		brand: { bg: 'var(--ars-brand-tonal)', fg: 'var(--ars-brand-tonal-ink)' },
		good: { bg: 'var(--ars-good-tonal)', fg: 'var(--ars-good)' },
		warning: { bg: 'var(--ars-warning-tonal)', fg: 'var(--ars-warning)' },
		serious: { bg: 'var(--ars-serious-tonal)', fg: 'var(--ars-serious)' },
	};
	const t = tones[tone] || tones.neutral;
	return (
		<span
			className="inline-flex items-center gap-1 px-2 py-0.5 ars-r-sm text-[11.5px] font-semibold"
			style={{ background: t.bg, color: t.fg }}
		>
			{children}
		</span>
	);
}

const STATUS = {
	pass: { tone: 'good', icon: '✓', label: __('Pass', 'ai-rank-suite') },
	warn: { tone: 'warning', icon: '!', label: __('Improve', 'ai-rank-suite') },
	fail: { tone: 'serious', icon: '✕', label: __('Fix', 'ai-rank-suite') },
};

export function StatusBadge({ status }) {
	const s = STATUS[status] || STATUS.warn;
	return (
		<Badge tone={s.tone}>
			<span aria-hidden>{s.icon}</span> {s.label}
		</Badge>
	);
}

export function ScoreRing({ value, size = 152, label = 'AI Score', onLight = false }) {
	const stroke = 14;
	const r = (size - stroke) / 2;
	const c = 2 * Math.PI * r;
	const pct = Math.max(0, Math.min(100, value ?? 0));
	const gid = React.useId();
	return (
		<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
			{/* A screen reader was told "0 / 100" for a ring the eye saw as "–":
			    the visual said not scored yet, the label said scored zero. */}
			<svg width={size} height={size} role="img" aria-label={value === null || value === undefined ? `${label}: ${__('not scored yet', 'ai-rank-suite')}` : `${label}: ${pct} / 100`}>
				<defs>
					<linearGradient id={gid} x1="0%" y1="0%" x2="100%" y2="100%">
						<stop offset="0%" stopColor="var(--ars-brand)" />
						<stop offset="100%" stopColor="var(--ars-brand-2)" />
					</linearGradient>
				</defs>
				<circle
					cx={size / 2}
					cy={size / 2}
					r={r}
					fill="none"
					stroke={onLight ? 'var(--ars-surface-3)' : 'var(--ars-surface-2)'}
					strokeWidth={stroke}
				/>
				<circle
					cx={size / 2}
					cy={size / 2}
					r={r}
					fill="none"
					stroke={`url(#${gid})`}
					strokeWidth={stroke}
					strokeLinecap="round"
					strokeDasharray={`${(c * pct) / 100} ${c}`}
					transform={`rotate(-90 ${size / 2} ${size / 2})`}
					style={{
						// The arc sweeps in on mount. It is the one number in the
						// product that deserves an entrance.
						animation: 'ars-sweep 900ms cubic-bezier(0.2, 0, 0, 1) both',
						['--ars-dash']: `${(c * pct) / 100}`,
					}}
				/>
			</svg>
			<div className="absolute text-center" style={{ color: 'var(--ars-ink)' }}>
				<div className="t-num" style={{ fontSize: 40, fontWeight: 600 }}>
					{value ?? '–'}
				</div>
				<div className="t-caption mt-1.5 font-medium">/ 100</div>
			</div>
		</div>
	);
}

export function ScorePill({ value }) {
	if (value === null || value === undefined) {
		return <span style={{ color: 'var(--ars-ink-3)' }}>—</span>;
	}
	const v = Number(value);
	const tone = v >= 80 ? 'good' : v >= 50 ? 'mid' : 'bad';
	// A dot, not a glyph: "!" beside every score made the whole table shout.
	// The chip's colour carries the judgement; the dot anchors it.
	return (
		<span className={`ars-scorepill is-${tone} tabular-nums`}>
			<span className="ars-scorepill-dot" aria-hidden />
			{v}
		</span>
	);
}

/**
 * Page-level tabs, as an underlined bar.
 *
 * These were a boxed segmented control with a tick inside the active tab.
 * A segmented control says "pick one setting"; a tab bar says "this page has
 * several views", which is what these actually are — and the box drew a hard
 * grey outline across the top of every screen for no information at all.
 * The rule under the bar ties the tabs to the content below them.
 */
export function Tabs({ tabs, active, onChange, right }) {
	return (
		<div className="ars-tabbar">
			{/* Wrap, never scroll. A scrollbar under a four-item tab strip is
			    both ugly and useless — the tabs simply take a second line. */}
			<div className="ars-noscroll flex items-end gap-1 flex-wrap min-w-0" role="tablist">
				{tabs.map((t) => (
					<button
						key={t.id}
						type="button"
						role="tab"
						aria-selected={active === t.id}
						onClick={() => onChange(t.id)}
						className={`ars-tab${active === t.id ? ' is-active' : ''}`}
					>
						{t.icon}
						{t.label}
						{t.count !== undefined && <span className="ars-tab-count">{t.count}</span>}
					</button>
				))}
			</div>
			{right && <div className="shrink-0 flex items-center gap-2 pb-1.5">{right}</div>}
		</div>
	);
}

/**
 * StatTile is the old name for what KpiTile does better.
 *
 * The two shipped side by side with different palettes (hand-mixed hexes vs
 * the --ars-c-* tokens), different geometry (no accent hairline, no minimum
 * height) and a different number size, so the tile row on Links, Analytics and
 * Search Console did not match the tile row on the Dashboard. Same role, one
 * component: this is now a thin alias so existing call sites keep working.
 */
export function StatTile({ label, value, sub, tone, ...rest }) {
	return <KpiTile label={label} value={value} sub={sub} tone={tone || 'plain'} {...rest} />;
}

/**
 * Single-series line chart (SVG): smooth monotone curve, gradient area,
 * y-axis labels, endpoint marker and a floating tooltip pill.
 * Two measures of different scale get two charts — never a dual axis.
 */
/** The three horizontal rules every line chart draws, as fractions of its height. */
const GRID_LINES = [0, 0.5, 1];

export function LineChart({
	data,
	color = 'var(--ars-series-1)',
	label,
	height = 140,
	formatValue = (v) => v,
	/** Flip the y-axis — for rank positions, where a lower number is better. */
	invert = false,
	/**
	 * Fix the top of the axis.
	 *
	 * A series that is a percentage or a 0-100 score should be drawn against
	 * its real ceiling. Scaling to the highest observed value instead makes a
	 * four-point move fill the whole card, which reads as a dramatic
	 * improvement and is not one.
	 */
	axisMax = null,
	/**
	 * Let the drawing run to the card's edge.
	 *
	 * A chart is the one element on a card that gains nothing from the card's
	 * gutter: the axis labels already sit inside the drawing, so the padding
	 * is a second margin around a picture that had one. On a wide card it
	 * costs 40px of the only element that can use them.
	 */
	bleed = false,
}) {
	const [hover, setHover] = React.useState(null);
	const gid = React.useId();
	/*
	 * The viewBox was a fixed 640 wide while the box on screen is as wide as the
	 * card. preserveAspectRatio then scaled the whole drawing down to the fixed
	 * height and centred it, so on a wide screen the chart sat in a ~640px
	 * column with dead space either side. Measuring the box and drawing at that
	 * width keeps the viewBox 1:1 with the pixels: the line spans the card, and
	 * strokes and labels stay the size they were designed at instead of being
	 * scaled with it.
	 */
	const [width, setWidth] = React.useState(640);
	const node = React.useRef(null);
	const observer = React.useRef(null);
	const measure = React.useCallback((el) => {
		if (observer.current) {
			observer.current.disconnect();
			observer.current = null;
		}
		node.current = el;
		if (!el || typeof ResizeObserver === 'undefined') {
			return;
		}
		const ro = new ResizeObserver((entries) => {
			const w = Math.round(entries[0]?.contentRect.width || 0);
			if (w > 0) {
				setWidth(Math.max(320, w));
			}
		});
		ro.observe(el);
		observer.current = ro;
	}, []);
	/*
	 * Read the width on commit as well, and do not trust the observer for it.
	 *
	 * ResizeObserver's first callback is supposed to arrive right after
	 * observe(). It does not always: in a throttled or backgrounded rendering
	 * context the callback never runs at all, and the chart then keeps the 640
	 * default and letterboxes itself exactly as it did before any of this was
	 * fixed. Measured here instead, which is synchronous and cannot be
	 * throttled; the observer stays for later resizes, where it is reliable.
	 *
	 * No dependency array on purpose, with the guard below doing the work: a
	 * layout pass is cheap, a wrong width is a visible bug.
	 */
	React.useLayoutEffect(() => {
		const el = node.current;
		if (!el) {
			return;
		}
		const w = Math.round(el.getBoundingClientRect().width);
		if (w > 0) {
			setWidth((prev) => (Math.abs(prev - w) > 1 ? Math.max(320, w) : prev));
		}
	});
	/*
	 * The left gutter is as wide as the widest label, not a constant.
	 *
	 * It was 34, then 30 to give the drawing more room, and 30 is not enough
	 * for "100/100" at 9.5px: the leading digit fell off the left edge of the
	 * answer-score chart and the axis read "00/100". A number that is wrong by
	 * a factor of ten because of a padding value is worse than a chart that is
	 * four pixels narrower. Measured from the actual labels this chart will
	 * draw, so a percentage, a thousands-separated count and a score out of
	 * 100 each get exactly the room they need.
	 */
	const pad = { l: 30, r: 6, t: 12, b: 20 };
	if (!data || data.length < 2) {
		/*
		 * One reading is the first reading, not missing data.
		 *
		 * "Not enough data yet" on a freshly installed plugin appears on every
		 * chart at once and reads as a wall of broken panels — people conclude
		 * the charts were never built. When a single point exists, show it: the
		 * number, when it was taken, and what makes the line appear. Only a
		 * genuinely empty series says nothing is there.
		 */
		const one = data && data.length === 1 ? data[0] : null;
		return (
			<div className="ars-firstread" style={one ? undefined : { justifyContent: 'center' }}>
				{one && (
					<div className="ars-firstread-v" style={{ color }}>
						{formatValue(one.value)}
					</div>
				)}
				<div className="grid gap-0.5 min-w-0">
					<span className="font-semibold text-[13px]">
						{one
							? one.date
								? sprintf(__('First reading · %s', 'ai-rank-suite'), one.date)
								: __('First reading', 'ai-rank-suite')
							: sprintf(__('No %s recorded yet', 'ai-rank-suite'), String(label))}
					</span>
					<span className="t-caption">
						{one
							? __('A line needs two points. The next reading draws it.', 'ai-rank-suite')
							: sprintf(__('%s appears here once there are two readings.', 'ai-rank-suite'), label)}
					</span>
				</div>
			</div>
		);
	}
	const values = data.map((d) => d.value);
	const rawMax = Math.max(...values);
	const rawMin = Math.min(...values);
	// Counts read best against zero; a rank series clustered around 8.6 would
	// look flat on a 0-based axis, so scale it to its own range.
	const span = Math.max(rawMax - rawMin, 1e-6);
	const padding = invert ? span * 0.2 : 0;
	const min = invert ? rawMin - padding : 0;
	const max = invert ? rawMax + padding : Math.max(1, axisMax ?? rawMax);
	const range = Math.max(max - min, 1e-6);
	const yValue = (f) => (invert ? min + f * range : max - f * range);
	/*
	 * Widen the gutter before anything is placed in it.
	 *
	 * This has to happen above x() and the path, or the axis moves and the
	 * line does not: labels and curve would be drawn against two different
	 * left edges. 9.5px in this face runs about 5.4px per character, six
	 * pixels of air to the axis, never narrower than the old constant.
	 */
	pad.l = Math.max(
		30,
		Math.round(
			Math.max(...GRID_LINES.map((f) => String(formatValue(Math.round(yValue(f) * 10) / 10)).length)) * 5.4 + 8
		)
	);
	const x = (i) => pad.l + (i * (width - pad.l - pad.r)) / (data.length - 1);
	const y = (v) => {
		const t = (v - min) / range;
		return pad.t + (invert ? t : 1 - t) * (height - pad.t - pad.b);
	};

	// Smooth monotone-ish curve: Catmull-Rom converted to cubic Beziers.
	const pts = data.map((d, i) => [x(i), y(d.value)]);
	let path = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
	for (let i = 0; i < pts.length - 1; i++) {
		const p0 = pts[Math.max(0, i - 1)];
		const p1 = pts[i];
		const p2 = pts[i + 1];
		const p3 = pts[Math.min(pts.length - 1, i + 2)];
		const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
		const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
		path += ` C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
	}
	const area = `${path} L${pts[pts.length - 1][0].toFixed(1)},${height - pad.b} L${pts[0][0].toFixed(1)},${height - pad.b} Z`;

	const gridLines = GRID_LINES;
	const last = pts[pts.length - 1];
	const trendUp = invert ? values[values.length - 1] < values[0] : values[values.length - 1] > values[0];

	return (
		<div ref={measure} className={bleed ? 'ars-chart-bleed' : undefined}>
			<div className="flex items-center justify-between mb-1.5">
				<span className="text-[12px] font-semibold flex items-center gap-1.5">
					<span aria-hidden className="inline-block w-2.5 h-2.5 ars-r-sm" style={{ background: color }} />
					{label}
				</span>
				<span
					className="text-[11.5px] tabular-nums font-medium px-2 py-0.5 ars-r-sm"
					style={{
						background: trendUp ? 'var(--ars-good-tonal)' : 'var(--ars-surface-3)',
						color: trendUp ? 'var(--ars-good)' : 'var(--ars-ink-2)',
					}}
				>
					{invert ? sprintf(__('best %s', 'ai-rank-suite'), formatValue(rawMin)) : sprintf(__('peak %s', 'ai-rank-suite'), formatValue(rawMax))}
				</span>
			</div>
			<svg
				viewBox={`0 0 ${width} ${height}`}
				className="w-full block"
				style={{ height }}
				onMouseLeave={() => setHover(null)}
				onMouseMove={(e) => {
					const rect = e.currentTarget.getBoundingClientRect();
					const px = ((e.clientX - rect.left) / rect.width) * width;
					const i = Math.round(((px - pad.l) / (width - pad.l - pad.r)) * (data.length - 1));
					setHover({ i: Math.max(0, Math.min(data.length - 1, i)) });
				}}
				role="img"
				aria-label={label}
			>
				<defs>
					<linearGradient id={`${gid}-a`} x1="0" y1="0" x2="0" y2="1">
						<stop offset="0%" stopColor={color} stopOpacity="0.26" />
						<stop offset="60%" stopColor={color} stopOpacity="0.07" />
						<stop offset="100%" stopColor={color} stopOpacity="0" />
					</linearGradient>
				</defs>
				{gridLines.map((f) => (
					<g key={f}>
						<line
							x1={pad.l}
							x2={width - pad.r}
							y1={pad.t + f * (height - pad.t - pad.b)}
							y2={pad.t + f * (height - pad.t - pad.b)}
							stroke="var(--ars-border)"
							strokeDasharray={f === 1 ? '' : '3 5'}
						/>
						<text
							x={pad.l - 6}
							y={pad.t + f * (height - pad.t - pad.b) + 3}
							fontSize="9.5"
							fill="var(--ars-ink-3)"
							textAnchor="end"
							className="tabular-nums"
						>
							{formatValue(Math.round(yValue(f) * 10) / 10)}
						</text>
					</g>
				))}
				<path d={area} fill={`url(#${gid}-a)`} className="ars-chart-area" />
				<path
					d={path}
					fill="none"
					stroke={color}
					strokeWidth="2.5"
					strokeLinejoin="round"
					strokeLinecap="round"
					className="ars-chart-line"
					pathLength="1"
				/>
				{/* Endpoint marker — the ring is the surface colour, so it reads as a
				    punched hole in both themes instead of a hardcoded white halo. */}
				<circle cx={last[0]} cy={last[1]} r="7" fill={color} opacity="0.15" />
				<circle cx={last[0]} cy={last[1]} r="3.5" fill={color} stroke="var(--ars-surface)" strokeWidth="1.5" />
				{hover && (
					<g>
						<line
							x1={x(hover.i)}
							x2={x(hover.i)}
							y1={pad.t}
							y2={height - pad.b}
							stroke="var(--ars-ink-3)"
							strokeDasharray="2 3"
						/>
						<circle cx={x(hover.i)} cy={y(data[hover.i].value)} r="4.5" fill={color} stroke="var(--ars-surface)" strokeWidth="2" />
						{(() => {
							const tip = `${data[hover.i].date} · ${formatValue(data[hover.i].value)}`;
							const w = tip.length * 6.2 + 18;
							const tx = Math.max(pad.l, Math.min(width - pad.r - w, x(hover.i) - w / 2));
							const ty = Math.max(2, y(data[hover.i].value) - 30);
							// Ink-on-ink inverted chip: --ars-ink is near-black in light and
							// near-white in dark, so pairing it with --ars-surface text keeps
							// the tooltip readable in both themes (ink+white was white-on-white
							// the moment the dark palette landed).
							return (
								<g>
									<rect x={tx} y={ty} rx="4" ry="4" width={w} height="21" fill="var(--ars-ink)" opacity="0.94" />
									<text x={tx + w / 2} y={ty + 14} fontSize="10.5" fill="var(--ars-surface)" textAnchor="middle">
										{tip}
									</text>
								</g>
							);
						})()}
					</g>
				)}
				<text x={pad.l} y={height - 5} fontSize="9.5" fill="var(--ars-ink-3)">
					{data[0].date}
				</text>
				<text x={width - pad.r} y={height - 5} fontSize="9.5" fill="var(--ars-ink-3)" textAnchor="end">
					{data[data.length - 1].date}
				</text>
			</svg>
		</div>
	);
}

/**
 * Ranking position badge: podium tones for 1-3, blue for page one, neutral after.
 */
export function PosBadge({ value }) {
	if (value === null || value === undefined || value === 0) {
		return <span style={{ color: 'var(--ars-ink-3)' }}>—</span>;
	}
	const v = Math.round(value * 10) / 10;
	const tone = v <= 3 ? { bg: 'var(--ars-good-tonal)', fg: 'var(--ars-good)' }
		: v <= 10 ? { bg: 'var(--ars-brand-tonal)', fg: 'var(--ars-brand-tonal-ink)' }
		: { bg: 'var(--ars-surface-3)', fg: 'var(--ars-ink-2)' };
	return (
		<span
			className="inline-flex items-center justify-center ars-r-sm text-[12px] font-bold tabular-nums"
			style={{ minWidth: 34, height: 24, padding: '0 8px', background: tone.bg, color: tone.fg }}
		>
			{v}
		</span>
	);
}

/**
 * @param {boolean} [running]  A run is in flight: the fill carries the same
 *                             moving stripes the audit uses. A bar that only
 *                             grows every twenty seconds looks stopped, and to
 *                             somebody watching it that is the difference
 *                             between "working" and "crashed". Every screen
 *                             that reports a job through this component gets
 *                             it — one change instead of eight.
 */
/**
 * The progress of a run, drawn the same way everywhere.
 *
 * There were two designs. Long jobs on the site audit and the link structure
 * used a 10px striped bar that moves while the run does, with the count on the
 * left and the percentage on the right. The link checker, the bulk screen and
 * the site analyzer used `Bar`: six flat pixels, no percentage, labels written
 * by hand in each file with their own wording. Same product, same kind of
 * wait, three different answers to "is this still working?".
 *
 * This is the striped one, as a component, so a screen states what it is doing
 * and gets the rest. `Bar` stays for what it is good at: a proportion that is
 * not a run, like a budget or an average.
 *
 * @param {object}          props
 * @param {string}          props.label    What the run is doing right now.
 * @param {number}          [props.done]   Units finished, for "12 of 40".
 * @param {number}          [props.total]  Units in total; 0 or absent draws a full bar for work that cannot be counted.
 * @param {number}          [props.percent] Overrides the percentage derived from done/total.
 * @param {React.ReactNode} [props.note]   One line under the bar.
 */
export function Progress({ label, done, total, percent, note }) {
	const counted = Number(total) > 0;
	const pct = Math.max(
		0,
		Math.min(100, undefined !== percent && null !== percent ? Math.round(percent) : counted ? Math.round((Number(done) / Number(total)) * 100) : 100)
	);
	return (
		<div className="ars-progress">
			<div className="ars-progress-head">
				<span>{label}</span>
				{counted && <span className="ars-progress-pct tabular-nums">{pct}%</span>}
			</div>
			<div
				className="ars-progress-track"
				role="progressbar"
				aria-valuenow={counted ? pct : undefined}
				aria-valuemin={0}
				aria-valuemax={100}
				aria-label={label}
			>
				<div className="ars-progress-fill" style={{ width: `${pct}%` }} />
			</div>
			{note && (
				<p className="m-0 text-[11.5px]" style={{ color: 'var(--ars-ink-3)' }}>
					{note}
				</p>
			)}
		</div>
	);
}

export function Bar({ value, color, running = false }) {
	const pct = Math.max(0, Math.min(100, value ?? 0));
	return (
		<div
			className="overflow-hidden"
			style={{ height: 6, borderRadius: 'var(--ars-r-sm)', background: 'var(--ars-surface-3)' }}
			role="progressbar"
			aria-valuenow={pct}
			aria-valuemin={0}
			aria-valuemax={100}
		>
			<div
				className={`h-full${running ? ' ars-progress-fill' : ''}`}
				style={{
					width: `${pct}%`,
					borderRadius: 3,
					background: color || 'var(--ars-brand)',
					// Grows into place with the rest of the view rather than
					// snapping to its final width before the card has settled.
					transition: 'width 620ms cubic-bezier(0.2, 0, 0, 1)',
				}}
			/>
		</div>
	);
}

export function Notice({ tone = 'warning', children }) {
	// WordPress has one shape for "read this before you carry on": a white
	// panel with a four-pixel coloured edge on the left. Every core screen and
	// every plugin worth trusting uses it, so a user reads it without having to
	// learn it. The tone only decides which colour that edge is.
	const map = {
		warning: { klass: 'is-warning', edge: 'var(--ars-warning)' },
		serious: { klass: 'is-error', edge: 'var(--ars-serious)' },
		info: { klass: 'is-info', edge: 'var(--ars-info-ink)' },
		good: { klass: 'is-success', edge: 'var(--ars-good)' },
	};
	const t = map[tone] || map.warning;
	return (
		<div className={`ars-notice ${t.klass}`} role={t.klass === 'is-error' ? 'alert' : undefined} style={{ borderInlineStartColor: t.edge }}>
			<p>{children}</p>
		</div>
	);
}

/* ---------- Quality meters ---------- */

/**
 * Length ranges Google actually respects. Below `ok` the snippet wastes
 * space, above `max` it gets truncated in the SERP.
 */
const LENGTH_RULES = {
	title: { ok: 30, good: 50, best: 60, max: 60, label: __('SEO title', 'ai-rank-suite') },
	description: { ok: 70, good: 120, best: 158, max: 160, label: __('Meta description', 'ai-rank-suite') },
};

function lengthVerdict(len, kind = 'title') {
	const r = LENGTH_RULES[kind];
	if (len === 0) return { tone: 'empty', color: 'var(--ars-ink-3)', text: __('Empty — falls back to the page title', 'ai-rank-suite') };
	if (len > r.max) {
		return {
			tone: 'bad',
			color: 'var(--ars-serious)',
			text: sprintf(
				_n('%d character too long — Google will cut it off', '%d characters too long — Google will cut it off', len - r.max, 'ai-rank-suite'),
				len - r.max
			),
		};
	}
	if (len < r.ok) return { tone: 'bad', color: 'var(--ars-serious)', text: __('Too short to be persuasive', 'ai-rank-suite') };
	if (len < r.good) return { tone: 'warn', color: 'var(--ars-warning)', text: __('Usable — a little more detail would help', 'ai-rank-suite') };
	return { tone: 'good', color: 'var(--ars-good)', text: __('Perfect length', 'ai-rank-suite') };
}

/**
 * Length meter: a neutral track with one marked zone — the target — and a
 * live fill whose colour is the verdict.
 *
 * The four painted zones this replaces said the same thing four times and
 * louder than the field they sat under. The fill's colour already carries
 * short/usable/perfect/too-long; the single green band marks where to aim.
 */
export function LengthMeter({ value = '', kind = 'title', showText = true }) {
	const r = LENGTH_RULES[kind];
	const len = String(value || '').length;
	const v = lengthVerdict(len, kind);
	const scale = r.max * 1.25; // Leave room to visualise overrun.
	const pct = (n) => Math.min(100, (n / scale) * 100);

	return (
		<div className="grid gap-1">
			<div className="relative ars-r-sm overflow-hidden" style={{ height: 4, background: 'var(--ars-surface-3)' }}>
				<span
					className="absolute top-0 h-full"
					style={{
						left: `${pct(r.good)}%`,
						width: `${pct(r.max) - pct(r.good)}%`,
						background: 'var(--ars-good)',
						opacity: 0.16,
					}}
				/>
				<span
					className="absolute top-0 h-full ars-r-sm transition-all"
					style={{ left: 0, width: `${pct(len)}%`, background: v.color }}
				/>
				{len > 0 && (
					<span
						className="absolute ars-r-sm transition-all"
						style={{
							left: `calc(${pct(len)}% - 4px)`,
							top: -1,
							width: 8,
							height: 8,
							background: 'var(--ars-knob)',
							boxShadow: `0 0 0 2px ${v.color}`,
						}}
					/>
				)}
			</div>
			{showText && (
				<div className="flex items-center justify-between text-[11px]">
					<span style={{ color: v.color, fontWeight: 600 }}>{v.text}</span>
					<span className="tabular-nums" style={{ color: 'var(--ars-ink-3)' }}>
						{len} / {r.max}
					</span>
				</div>
			)}
		</div>
	);
}

/* ---------- Heatmap ---------- */

const HEAT_STOPS = [
	{ at: 0, bg: '#fce8e6', fg: '#c5221f' },
	{ at: 40, bg: '#fde7cd', fg: '#b06000' },
	{ at: 60, bg: '#fef7e0', fg: '#b06000' },
	{ at: 75, bg: '#e8f3ea', fg: '#188038' },
	{ at: 90, bg: '#ceead6', fg: '#0d652d' },
];

function heatColor(score) {
	if (score === null || score === undefined) return { bg: 'var(--ars-surface-3)', fg: 'var(--ars-ink-3)' };
	let stop = HEAT_STOPS[0];
	for (const s of HEAT_STOPS) if (score >= s.at) stop = s;
	return stop;
}

/**
 * Content health heatmap: one row per page, one cell per dimension.
 * Reading a hundred pages as a grid surfaces patterns a table never will —
 * a cold column means a site-wide problem, a cold row means one bad page.
 */
export function Heatmap({ rows, columns, onCell, onRow }) {
	const [hover, setHover] = React.useState(null);

	if (!rows || rows.length === 0) {
		return (
			<div className="grid place-items-center py-8 ars-r-lg text-center" style={{ background: 'var(--ars-surface-2)' }}>
				<span className="text-[13px]" style={{ color: 'var(--ars-ink-2)' }}>{__('Nothing analyzed yet.', 'ai-rank-suite')}</span>
			</div>
		);
	}

	return (
		<div className="grid gap-2">
			<div className="ars-table-wrap">
				<table className="w-full" style={{ borderCollapse: 'separate', borderSpacing: '3px' }}>
					<thead>
						<tr>
							<th className="text-left text-[11px] font-medium pl-1" style={{ color: 'var(--ars-ink-3)', minWidth: 190 }}>{__('Page', 'ai-rank-suite')}</th>
							{columns.map((c) => (
								<th
									key={c.key}
									className="text-[10.5px] font-medium"
									style={{ color: 'var(--ars-ink-3)', width: 62 }}
									title={c.hint}
								>
									{c.label}
								</th>
							))}
						</tr>
					</thead>
					<tbody>
						{rows.map((row) => (
							<tr key={row.id}>
								<td
									className="text-[12.5px] pl-1 pr-2 truncate"
									style={{ maxWidth: 220, cursor: onRow ? 'pointer' : 'default' }}
									title={row.label}
									onClick={() => onRow && onRow(row)}
								>
									{row.label}
								</td>
								{columns.map((c) => {
									const score = row.values[c.key];
									const heat = heatColor(score);
									const key = `${row.id}-${c.key}`;
									const isHover = hover === key;
									return (
										<td key={c.key}>
											<button
												type="button"
												onClick={() => onCell && onCell(row, c)}
												onMouseEnter={() => setHover(key)}
												onMouseLeave={() => setHover(null)}
												className="w-full ars-r-md border-0 cursor-pointer text-[11.5px] font-semibold tabular-nums transition-transform"
												style={{
													height: 30,
													background: heat.bg,
													color: heat.fg,
													transform: isHover ? 'scale(1.08)' : 'none',
													boxShadow: isHover ? '0 2px 8px rgb(60 64 67 / 0.18)' : 'none',
												}}
												title={`${row.label} · ${c.label}: ${score ?? '—'}`}
											>
												{score ?? '–'}
											</button>
										</td>
									);
								})}
							</tr>
						))}
					</tbody>
				</table>
			</div>
			<div className="flex items-center gap-2 text-[11px] pl-1" style={{ color: 'var(--ars-ink-3)' }}>
				<span>{__('Needs work', 'ai-rank-suite')}</span>
				<span className="flex gap-0.5">
					{HEAT_STOPS.map((s) => (
						<span key={s.at} className="ars-r-sm" style={{ width: 22, height: 8, background: s.bg }} />
					))}
				</span>
				<span>{__('Excellent', 'ai-rank-suite')}</span>
			</div>
		</div>
	);
}

/* ---------- Micro charts: the vocabulary for dense KPI display ---------- */

/**
 * Sparkline: a trend at a glance, no axes, no labels. Deliberately tiny —
 * its job is to say "rising" or "falling", not to be read precisely.
 */
export function Sparkline({ data, color = 'currentColor', height = 28, fill = true }) {
	const gid = React.useId();
	const values = (data || []).filter((v) => typeof v === 'number');
	if (values.length < 3) {
		return null;
	}
	const width = 100;
	const min = Math.min(...values);
	const max = Math.max(...values);
	const range = Math.max(max - min, 1e-6);
	const pad = 3;
	// The endpoint marker sat exactly on x=100 and the first point on x=0, so
	// half of each was outside the viewBox — every sparkline in the product
	// ended in a stroke that looked cut off. Inset the series by the marker's
	// own radius and it closes properly.
	const padX = 3;
	const x = (i) => padX + (i * (width - padX * 2)) / (values.length - 1);
	const y = (v) => pad + (1 - (v - min) / range) * (height - pad * 2);

	const pts = values.map((v, i) => [x(i), y(v)]);
	let path = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
	for (let i = 0; i < pts.length - 1; i++) {
		const p0 = pts[Math.max(0, i - 1)];
		const p1 = pts[i];
		const p2 = pts[i + 1];
		const p3 = pts[Math.min(pts.length - 1, i + 2)];
		const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
		const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
		path += ` C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
	}

	return (
		<svg viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" style={{ width: '100%', height, display: 'block', color }} aria-hidden>
			<defs>
				<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
					<stop offset="0%" stopColor="currentColor" stopOpacity="0.22" />
					<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
				</linearGradient>
			</defs>
			{fill && <path d={`${path} L${width - padX},${height} L${padX},${height} Z`} fill={`url(#${gid})`} />}
			{/* No endpoint marker: preserveAspectRatio="none" turns a circle into
			    an ellipse, and the one it drew sat half outside the viewBox. */}
			<path d={path} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
		</svg>
	);
}

/**
 * MiniBars: a distribution — how many items sit in each band. Reads faster
 * than a pie for five-or-fewer categories.
 */
export function MiniBars({ data, colors, height = 28 }) {
	const values = (data || []).map((v) => Number(v) || 0);
	const max = Math.max(...values, 1);
	// Status ramp from the tokens, not a second hand-mixed set of Google reds
	// and ambers that only ever appeared in this one chart.
	const palette = colors || [
		'var(--ars-serious)',
		'var(--ars-warning)',
		'var(--ars-c-yellow-ink)',
		'var(--ars-series-4)',
		'var(--ars-good)',
	];
	return (
		<div className="flex items-end gap-1" style={{ height }} aria-hidden>
			{values.map((v, i) => (
				<span
					key={i}
					className="flex-1 ars-r-sm-top transition-all"
					style={{
						// An empty band still needs to read as a band, not as a
						// hairline someone forgot to finish: it keeps a visible
						// stub in the track colour instead of a 3px sliver of red.
						height: v === 0 ? 3 : `${Math.max(6, (v / max) * height)}px`,
						background: v === 0 ? 'var(--ars-surface-3)' : palette[i % palette.length],
					}}
					title={String(v)}
				/>
			))}
		</div>
	);
}

/**
 * Delta badge. `invert` for metrics where down is good (rank position,
 * broken links) — the arrow follows the number, the colour follows meaning.
 */
export function Delta({ value, suffix = '', invert = false }) {
	if (value === null || value === undefined || value === 0) {
		return (
			<span className="text-[11px] font-medium" style={{ color: 'var(--ars-ink-3)' }}>
				±0{suffix}
			</span>
		);
	}
	const up = value > 0;
	const good = invert ? !up : up;
	return (
		<span
			className="inline-flex items-center gap-0.5 text-[11px] font-semibold px-1.5 py-0.5 ars-r-sm"
			style={{
				background: good ? 'var(--ars-good-tonal)' : 'var(--ars-serious-tonal)',
				color: good ? 'var(--ars-good)' : 'var(--ars-serious)',
			}}
		>
			<span aria-hidden>{up ? '▲' : '▼'}</span>
			{Math.abs(value)}
			{suffix}
		</span>
	);
}

/**
 * KPI tile: the launchpad unit. Every tile carries a live number and goes
 * somewhere — a tile that only labels a link is a wasted tile.
 */
export function KpiTile({
	label,
	value,
	unit,
	sub,
	tone = 'plain',
	chart,
	delta,
	onClick,
	status,
	disabled = false,
}) {
	// Container colour and ink come as a pair so text contrast is never left to
	// chance; `accent` drives the top hairline and any chart drawn inside.
	const tones = {
		plain: { bg: 'var(--ars-surface-2)', fg: 'var(--ars-ink)', mute: 'var(--ars-ink-3)', accent: 'var(--ars-ink-3)' },
		blue: { bg: 'var(--ars-c-blue)', fg: 'var(--ars-c-blue-ink)', mute: 'var(--ars-c-blue-ink)', accent: 'var(--ars-c-blue-ink)' },
		green: { bg: 'var(--ars-c-green)', fg: 'var(--ars-c-green-ink)', mute: 'var(--ars-c-green-ink)', accent: 'var(--ars-c-green-ink)' },
		yellow: { bg: 'var(--ars-c-yellow)', fg: 'var(--ars-c-yellow-ink)', mute: 'var(--ars-c-yellow-ink)', accent: 'var(--ars-c-yellow-ink)' },
		red: { bg: 'var(--ars-c-red)', fg: 'var(--ars-c-red-ink)', mute: 'var(--ars-c-red-ink)', accent: 'var(--ars-c-red-ink)' },
		violet: { bg: 'var(--ars-c-violet)', fg: 'var(--ars-c-violet-ink)', mute: 'var(--ars-c-violet-ink)', accent: 'var(--ars-c-violet-ink)' },
	};
	const t = tones[tone] || tones.plain;
	const Wrapper = onClick ? 'button' : 'div';
	const shown = useCountUp(value);
	// Digits get display size; words get read, not scanned. A ratio ("0 / 0",
	// "37 / 37") is still a figure — excluding the slash dropped those two
	// tiles to 21px in a row of 30px numbers, which read as a rendering bug.
	const isFigure = typeof value === 'number' || /^[\d.,%/\s-]+$/.test(String(value ?? '').trim() || 'x');

	return (
		<Wrapper
			type={onClick ? 'button' : undefined}
			onClick={disabled ? undefined : onClick}
			className={`ars-tile text-left w-full${tone === 'plain' ? ' is-plain' : ''}${
				onClick && !disabled ? ' is-clickable' : ''
			}`}
			// `color` drives the rail and the wash through currentColor; the text
			// below sets its own colour so the figure never inherits the tone.
			style={{ color: t.accent, opacity: disabled ? 0.55 : 1 }}
		>
			<div className="flex items-start justify-between gap-2">
				<span
					className="t-overline"
					style={{ color: 'var(--ars-ink-3)', letterSpacing: '0.055em', fontSize: 10.5 }}
				>
					{label}
				</span>
				{status}
			</div>

			<div className="flex items-baseline gap-2 mt-1">
				<span
					className="t-num"
					style={{
						fontSize: isFigure ? 26 : 17,
						fontWeight: isFigure ? 600 : 550,
						letterSpacing: isFigure ? '-0.032em' : '-0.01em',
						// The number is ink, not tone. A figure printed in its own
						// status colour is unreadable at a glance next to three
						// other figures in three other colours — the rail already
						// says what this tile is about.
						color: 'var(--ars-ink)',
					}}
				>
					{shown}
				</span>
				{unit && (
					<span className="text-[12px] font-medium" style={{ color: 'var(--ars-ink-3)' }}>
						{unit}
					</span>
				)}
				{delta}
			</div>

			{sub && (
				<div className="t-caption mt-1" style={{ color: 'var(--ars-ink-3)' }}>
					{sub}
				</div>
			)}

			{chart && <div className="mt-auto pt-2.5">{chart}</div>}
		</Wrapper>
	);
}

/* ---------- Analytical primitives: the Insights vocabulary ---------- */

/**
 * A sticky filter bar. One row, one job: change what the whole page means.
 * The filter belongs to the page, not to each individual card.
 */
export function FilterBar({ children, right }) {
	return (
		<div className="ars-filterbar flex flex-wrap items-center gap-3">
			<div className="flex flex-wrap items-center gap-3 min-w-0">{children}</div>
			{right && <div className="ml-auto flex items-center gap-2">{right}</div>}
		</div>
	);
}

/**
 * ChipBar — "narrow this list to one of these".
 *
 * Site Audit, Sitemap, AI Engine and Content each grew their own version of
 * this control: same job, four different looks (solid blue, bordered tab
 * strip, tonal segment, plain button). One chip now, with an optional count
 * so a filter can say how much it will leave on screen.
 */
export function ChipBar({ options, value, onChange, label, className = '', collapseAt = 24 }) {
	// A chip strip scales with the site: 70 sitemap files on a 16k-post site,
	// 200+ on a 50k one. Past `collapseAt` the tail folds behind one "+N more"
	// chip; the active option is always kept visible.
	const [expanded, setExpanded] = React.useState(false);
	let shown = options;
	if (!expanded && options.length > collapseAt) {
		shown = options.slice(0, collapseAt);
		const active = options.find((o) => o.id === value);
		if (active && !shown.some((o) => o.id === value)) shown = [...shown, active];
	}
	const hidden = options.length - shown.length;
	return (
		<div className={`ars-chipbar${className ? ` ${className}` : ''}`} role="group" aria-label={label}>
			{label && (
				<span className="text-[11.5px] font-medium mr-0.5" style={{ color: 'var(--ars-ink-3)' }}>
					{label}
				</span>
			)}
			{shown.map((o) => (
				<button
					key={o.id}
					type="button"
					className={`ars-chip${value === o.id ? ' is-active' : ''}`}
					aria-pressed={value === o.id}
					onClick={() => onChange(o.id)}
				>
					{o.label}
					{o.count !== undefined && o.count !== null && <span className="ars-chip-count">{o.count}</span>}
				</button>
			))}
			{hidden > 0 && (
				<button type="button" className="ars-chip" onClick={() => setExpanded(true)}>
					{sprintf(_n('+%d more', '+%d more', hidden, 'ai-rank-suite'), hidden)}
				</button>
			)}
			{expanded && options.length > collapseAt && (
				<button type="button" className="ars-chip" onClick={() => setExpanded(false)}>
					{__('Show fewer', 'ai-rank-suite')}
				</button>
			)}
		</div>
	);
}

/** Segmented control — the compact sibling of Tabs, for filter values. */
export function Segmented({ options, value, onChange, label }) {
	return (
		<div className="flex items-center gap-2">
			{label && (
				<span className="text-[11.5px] font-medium" style={{ color: 'var(--ars-ink-3)' }}>
					{label}
				</span>
			)}
			<div className="ars-seg" role="group" aria-label={label}>
				{options.map((o) => (
					<button
						key={o.id}
						type="button"
						className={`ars-seg-item${value === o.id ? ' is-active' : ''}`}
						aria-pressed={value === o.id}
						onClick={() => onChange(o.id)}
					>
						{o.label}
					</button>
				))}
			</div>
		</div>
	);
}

/**
 * Proportional stacked bar — "of everything checked, this much is healthy".
 * One bar beats five numbers when the parts sum to a whole.
 */
export function SegmentBar({ segments, height = 12, showLegend = true }) {
	const items = (segments || []).filter((s) => s.value > 0);
	const total = items.reduce((a, s) => a + s.value, 0);
	if (total === 0) {
		return (
			<div className="text-[12.5px]" style={{ color: 'var(--ars-ink-3)' }}>{__('Nothing measured yet.', 'ai-rank-suite')}</div>
		);
	}
	return (
		<div className="grid gap-2">
			<div className="flex overflow-hidden" style={{ height, borderRadius: 'var(--ars-r-xs)' }}>
				{items.map((s) => (
					<span
						key={s.label}
						title={`${s.label}: ${s.value}`}
						style={{
							width: `${(s.value / total) * 100}%`,
							background: s.color,
							transition: 'width 400ms cubic-bezier(0.2,0,0,1)',
						}}
					/>
				))}
			</div>
			{showLegend && (
				<div className="flex flex-wrap gap-x-4 gap-y-1">
					{items.map((s) => (
						<span
							key={s.label}
							className="inline-flex items-center gap-1.5 text-[11.5px]"
							style={{ color: 'var(--ars-ink-2)' }}
						>
							<span aria-hidden style={{ width: 8, height: 8, borderRadius: 'var(--ars-r-xs)', background: s.color, display: 'inline-block' }} />
							{s.label}
							<b className="tabular-nums" style={{ color: 'var(--ars-ink)' }}>
								{s.value}
							</b>
						</span>
					))}
				</div>
			)}
		</div>
	);
}

/**
 * Ranked horizontal bars. Sorted lists of "how many" read faster this way
 * than as a table, and the bar length does the comparing for you.
 */
export function RankBar({ items, max, color = 'var(--ars-brand)', onItem, format = (v) => v }) {
	const rows = items || [];
	const peak = max ?? Math.max(...rows.map((r) => r.value), 1);
	return (
		<div className="grid gap-1.5">
			{rows.map((r, i) => (
				<div
					key={r.key ?? r.label ?? i}
					className={`ars-rankrow${onItem ? ' is-clickable' : ''}`}
					onClick={onItem ? () => onItem(r) : undefined}
					role={onItem ? 'button' : undefined}
					tabIndex={onItem ? 0 : undefined}
					onKeyDown={onItem ? (e) => e.key === 'Enter' && onItem(r) : undefined}
				>
					<span className="ars-rankrow-label" title={r.label}>
						{r.label}
					</span>
					<span className="ars-rankrow-track">
						<span
							className="ars-rankrow-fill"
							style={{ width: `${Math.max(2, (r.value / peak) * 100)}%`, background: r.color || color }}
						/>
					</span>
					<span className="ars-rankrow-value tabular-nums">{format(r.value)}</span>
				</div>
			))}
		</div>
	);
}

/**
 * Column chart for daily activity. Deliberately axis-light: the question is
 * "did anything happen and when", not "exactly how much".
 *
 * `stacked` piles the series into one column per row instead of grouping
 * them side by side — for compositions, where the column's full height is a
 * total that means something (all tracked keywords on that day) and the
 * bands inside it are its parts. A grouped chart of the same data would make
 * the reader do the addition themselves.
 */
export function ColumnChart({ data, series, height = 110, stacked = false }) {
	const rows = data || [];
	const keys = series || [];
	const peak = stacked
		? Math.max(1, ...rows.map((r) => keys.reduce((a, k) => a + (Number(r[k.key]) || 0), 0)))
		: Math.max(1, ...rows.map((r) => keys.reduce((a, k) => Math.max(a, Number(r[k.key]) || 0), 0)));
	const step = Math.max(1, Math.ceil(rows.length / 8));
	return (
		<div className="grid gap-2">
			<div className="flex items-end gap-[3px]" style={{ height }}>
				{rows.map((r, i) =>
					stacked ? (
						<div
							key={i}
							className="flex-1 flex items-end justify-center"
							style={{ height }}
							title={`${r.label} · ${keys.map((k) => `${k.label}: ${Number(r[k.key]) || 0}`).join(' · ')}`}
						>
							<span className="flex flex-col-reverse" style={{ width: '100%', maxWidth: 12 }}>
								{keys.map((k, ki) => {
									const v = Number(r[k.key]) || 0;
									return (
										<span
											key={k.key}
											style={{
												height: `${(v / peak) * height}px`,
												background: k.color,
												// Only the topmost non-empty band gets the rounded cap.
												borderRadius:
													keys.slice(ki + 1).every((kk) => !(Number(r[kk.key]) || 0)) && v > 0
														? 'var(--ars-r-xs) var(--ars-r-xs) 0 0'
														: 0,
												transition: 'height 380ms cubic-bezier(0.2,0,0,1)',
											}}
										/>
									);
								})}
							</span>
						</div>
					) : (
						<div key={i} className="flex-1 flex items-end justify-center gap-[2px]" style={{ height }}>
							{keys.map((k) => {
								const v = Number(r[k.key]) || 0;
								return (
									<span
										key={k.key}
										title={`${r.label} · ${k.label}: ${v}`}
										style={{
											width: `${100 / keys.length}%`,
											maxWidth: 10,
											height: `${Math.max(2, (v / peak) * height)}px`,
											background: v === 0 ? 'var(--ars-surface-3)' : k.color,
											borderRadius: 'var(--ars-r-xs) var(--ars-r-xs) 0 0',
											transition: 'height 380ms cubic-bezier(0.2,0,0,1)',
										}}
									/>
								);
							})}
						</div>
					)
				)}
			</div>
			<div className="flex justify-between text-[10.5px]" style={{ color: 'var(--ars-ink-3)' }}>
				{rows
					.filter((_, i) => i % step === 0)
					.map((r, i) => (
						<span key={i}>{r.label}</span>
					))}
			</div>
			<div className="flex gap-4">
				{keys.map((k) => (
					<span key={k.key} className="inline-flex items-center gap-1.5 text-[11.5px]" style={{ color: 'var(--ars-ink-2)' }}>
						<span aria-hidden style={{ width: 8, height: 8, borderRadius: 'var(--ars-r-xs)', background: k.color, display: 'inline-block' }} />
						{k.label}
					</span>
				))}
			</div>
		</div>
	);
}

/**
 * Label + control + hint, in the one arrangement every settings screen uses.
 *
 * Written out by hand this is four lines and two class strings, which is how
 * the same field ended up with `mb-1` on one page and `mb-1.5` on the next.
 */
export function Field({ label, hint, htmlFor, children, className = '', span = false }) {
	return (
		<div className={className} style={span ? { gridColumn: '1 / -1' } : undefined}>
			{label && (
				<label className="ars-field-label" htmlFor={htmlFor}>
					{label}
				</label>
			)}
			{children}
			{/* WordPress calls this a description and gives it one look on every
			    settings screen in core. Same class, same look. */}
			{hint && <p className="ars-hint">{hint}</p>}
		</div>
	);
}

/**
 * Pick an image from the WordPress media library.
 *
 * Deliberately not a file input: the library is where the site's images
 * already are, it gives us the URL WordPress will actually serve, and the user
 * has driven this dialog before. Falls back to a plain URL field if the media
 * scripts are not on the page, so the field is never dead.
 */
export function MediaPicker({ value, onChange, label, hint, title = __('Select image', 'ai-rank-suite'), height = 96 }) {
	const open = () => {
		const media = window.wp?.media;
		if (!media) return;
		const frame = media({ title, button: { text: __('Use this image', 'ai-rank-suite') }, multiple: false, library: { type: 'image' } });
		frame.on('select', () => {
			const attachment = frame.state().get('selection').first().toJSON();
			// The full-size URL is what schema consumers want; sizes.full exists
			// only when WordPress generated one.
			onChange(attachment.sizes?.full?.url || attachment.url || '');
		});
		frame.open();
	};

	const hasMedia = !!window.wp?.media;

	return (
		<Field label={label} hint={hint}>
			<div className="flex items-start gap-3">
				<div
					className="ars-r-sm shrink-0 flex items-center justify-center overflow-hidden"
					style={{
						width: height,
						height,
						border: '1px solid var(--ars-border)',
						background: 'var(--ars-surface-2)',
					}}
				>
					{value ? (
						<img src={value} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
					) : (
						<span className="t-caption" style={{ fontSize: 11 }}>{__('None', 'ai-rank-suite')}</span>
					)}
				</div>
				<div className="grid gap-1.5 flex-1 min-w-0">
					<input
						type="url"
						value={value || ''}
						placeholder="https://…"
						onChange={(e) => onChange(e.target.value)}
					/>
					<div className="flex gap-2">
						{hasMedia && (
							<Button variant="ghost" onClick={open} type="button">{__('Choose from library', 'ai-rank-suite')}</Button>
						)}
						{value && (
							<Button variant="ghost" onClick={() => onChange('')} type="button">{__('Remove', 'ai-rank-suite')}</Button>
						)}
					</div>
				</div>
			</div>
		</Field>
	);
}

/**
 * A free-form list of short strings — service areas, languages, profile URLs.
 *
 * Enter and comma both commit, because people type both. Backspace on an empty
 * field removes the last chip, which is the behaviour every tag field has.
 */
export function TagInput({ value = [], onChange, placeholder = __('Add and press Enter', 'ai-rank-suite'), suggestions = [] }) {
	const [draft, setDraft] = React.useState('');

	const commit = (raw) => {
		const parts = String(raw)
			.split(',')
			.map((p) => p.trim())
			.filter(Boolean);
		if (!parts.length) return;
		const next = [...value];
		parts.forEach((p) => {
			if (!next.includes(p)) next.push(p);
		});
		onChange(next);
		setDraft('');
	};

	const remaining = suggestions.filter((sug) => !value.includes(sug)).slice(0, 8);

	return (
		<div className="grid gap-2">
			{value.length > 0 && (
				<div className="flex flex-wrap gap-1.5">
					{value.map((tag) => (
						<span key={tag} className="ars-chip-tag" title={tag}>
							<span className="truncate" style={{ maxWidth: 260 }}>
								{tag}
							</span>
							<button
								type="button"
								aria-label={sprintf(__('Remove %s', 'ai-rank-suite'), tag)}
								onClick={() => onChange(value.filter((t) => t !== tag))}
								className="border-0 bg-transparent cursor-pointer p-0 ml-1"
								style={{ color: 'inherit', lineHeight: 1, opacity: 0.65 }}
							>
								×
							</button>
						</span>
					))}
				</div>
			)}
			<input
				type="text"
				value={draft}
				placeholder={placeholder}
				onChange={(e) => setDraft(e.target.value)}
				onKeyDown={(e) => {
					if (e.key === 'Enter' || e.key === ',') {
						e.preventDefault();
						commit(draft);
					} else if (e.key === 'Backspace' && draft === '' && value.length) {
						onChange(value.slice(0, -1));
					}
				}}
				onBlur={() => commit(draft)}
			/>
			{remaining.length > 0 && (
				<div className="flex flex-wrap gap-1.5">
					{remaining.map((sug) => (
						<button key={sug} type="button" onClick={() => commit(sug)} className="ars-chip-tag is-suggestion">
							+ {sug}
						</button>
					))}
				</div>
			)}
		</div>
	);
}

/**
 * A set of checkboxes laid out as a responsive grid, for catalogues that are
 * too long for a row of switches and too short for a multi-select.
 */
export function CheckGrid({ options, value = [], onChange, min = '180px' }) {
	const toggle = (key) => onChange(value.includes(key) ? value.filter((v) => v !== key) : [...value, key]);
	return (
		<div className="ars-cardgrid" style={{ '--min': min, gap: 6 }}>
			{options.map((o) => (
				<label
					key={o.key}
					className="flex items-center gap-2 cursor-pointer text-[12.5px] px-2 py-1.5 ars-r-sm"
					style={{
						border: '1px solid var(--ars-border)',
						background: value.includes(o.key) ? 'var(--ars-brand-tonal)' : 'var(--ars-surface)',
						color: value.includes(o.key) ? 'var(--ars-brand-tonal-ink)' : 'var(--ars-ink-2)',
					}}
				>
					<input type="checkbox" checked={value.includes(o.key)} onChange={() => toggle(o.key)} />
					<span className="truncate">{o.label}</span>
				</label>
			))}
		</div>
	);
}

/**
 * Read-only code with a copy button. Six pages had grown their own.
 */
export function CodeBlock({ code, max = 420, language = '' }) {
	const [copied, setCopied] = React.useState(false);
	return (
		<div className="relative">
			<div className="absolute" style={{ top: 6, right: 6, zIndex: 1 }}>
				<Button
					variant="ghost"
					onClick={async () => {
						const ok = await copyText(code);
						setCopied(ok ? 'yes' : 'no');
						setTimeout(() => setCopied(false), 1600);
					}}
				>
					{copied === 'yes' ? `✓ ${__('Copied', 'ai-rank-suite')}` : copied === 'no' ? __('Select and copy', 'ai-rank-suite') : __('Copy', 'ai-rank-suite')}
				</Button>
			</div>
			<pre
				className="ars-mono ars-r-sm m-0 overflow-auto p-3 text-[11.5px]"
				style={{
					maxHeight: max,
					background: 'var(--ars-surface-2)',
					border: '1px solid var(--ars-border)',
					lineHeight: 1.55,
					whiteSpace: 'pre',
				}}
				data-language={language}
			>
				{code}
			</pre>
		</div>
	);
}

/**
 * The shape of a screen while its data is on the way.
 *
 * A module that renders nothing until its first request returns looks broken
 * for the second or two that request takes — and on a large site with a slow
 * host it is four seconds, which reads as "the plugin hung". A skeleton in the
 * layout the page is about to take says "loading", holds the scroll position,
 * and stops the content from jumping when it lands.
 */
export function PageSkeleton({ blocks = 3, hero = true, label = 'Loading' }) {
	return (
		<div className="ars-page" aria-busy="true" aria-label={label}>
			{hero && <div className="ars-pageskel-bar" style={{ height: 132 }} />}
			<div className="ars-pageskel-grid">
				{Array.from({ length: blocks }, (_, i) => (
					<div key={i} className={`ars-pageskel-block${i === 0 ? ' is-tall' : ''}`} />
				))}
			</div>
			<span className="sr-only">{label}…</span>
		</div>
	);
}

/**
 * Where a single number stands in the range it lives in.
 *
 * A score out of 100 has a shape even with one reading: the distance left to
 * the band you want to be in. Three bands, a marker, and the number — which
 * is more information than a flat line and reads in about a second.
 */
export function BandScale({ value, color, bands }) {
	// A shared component cannot assume its caller passed the one prop it
	// cannot work without. Two TrendCards did not, and every screen that
	// mounted them went to the error boundary the moment their chart had no
	// shape to draw — which is to say on a fresh install, on the first day,
	// which is exactly when somebody is deciding whether to keep this.
	if (!Array.isArray(bands) || 0 === bands.length) return null;
	const v = Math.max(0, Math.min(100, Number(value) || 0));
	const zone = bands.find((b) => v < b.to) || bands[bands.length - 1];
	/*
	 * Its own root class, not `ars-gauge`.
	 *
	 * Two unrelated components shared that name: the SVG arc above, which is
	 * 150px wide by design, and this band meter, which has to be full width or
	 * its percentage bands resolve against nothing. The rule that gave this one
	 * `width: 100%` is later in the stylesheet, so it won — and the arc gauge
	 * stretched to fill whatever card it was in. On the site-tests screen that
	 * was a 96 rendered about a foot tall.
	 */
	return (
		<div className="ars-bandscale">
			<div className="ars-gauge-head">
				<span className="ars-gauge-n" style={{ color }}>{v}</span>
				<span className="ars-gauge-of">/ 100</span>
				<span className="ars-gauge-zone">{zone.label}</span>
			</div>
			<div className="ars-gauge-track">
				{bands.map((b, i) => (
					<span
						key={b.label}
						className="ars-gauge-band"
						style={{ width: `${b.to - (i ? bands[i - 1].to : 0)}%`, background: b.tint }}
					/>
				))}
				<span className="ars-gauge-mark" style={{ left: `${v}%`, background: color }} />
			</div>
			<div className="ars-gauge-scale">
				{bands.map((b, i) => (
					<span key={b.label} style={{ width: `${b.to - (i ? bands[i - 1].to : 0)}%` }}>{b.label}</span>
				))}
			</div>
		</div>
	);
}

/**
 * What a total is made of, when its history is still one point.
 *
 * 321 open findings is a number. "0 critical, 14 warnings, 307 notices" is a
 * decision — and it is the same data, already on the card as tiles.
 */
export function Composition({ parts, total }) {
	const sum = parts.reduce((a, p) => a + (p.value || 0), 0) || 1;
	return (
		<div className="ars-comp">
			<div className="ars-comp-bar" role="img" aria-label={parts.map((p) => `${p.label}: ${p.value}`).join(', ')}>
				{parts.filter((p) => (p.value || 0) > 0).map((p) => (
					<span key={p.label} title={`${p.label}: ${p.value}`} style={{ width: `${(p.value / sum) * 100}%`, background: p.color }} />
				))}
			</div>
			<div className="ars-comp-keys">
				{parts.map((p) => (
					<span key={p.label} className="ars-comp-key">
						<i style={{ background: p.color }} />
						<b>{Number(p.value || 0).toLocaleString()}</b>
						<span>{p.label}</span>
					</span>
				))}
			</div>
			{total !== null && total !== undefined && (
				<p className="ars-comp-total">
					{sprintf(
						/* translators: %s: total. */
						__('%s in total right now', 'ai-rank-suite'),
						Number(total).toLocaleString()
					)}
				</p>
			)}
		</div>
	);
}
