import React, { useCallback, useEffect, useRef, useState } from 'react';
import { __, _n, sprintf } from '@wordpress/i18n';
import { createPortal } from 'react-dom';
import { api, env, moduleRunning } from '../api';

/*
 * Everything that spends an AI call needs the AI module to be in this build.
 * The free edition ships without it, and every one of these buttons called an
 * /ai/ route regardless — producing "no route matched the URL and request
 * method" where a description was supposed to appear. A button that can only
 * fail should not be on the screen.
 */
const HAS_AI = !Array.isArray(env.modules) || env.modules.includes('ai');
const HAS_BULK = !Array.isArray(env.modules) || env.modules.includes('bulk');
import { Card, Button, Badge, Bar, ScorePill, StatusBadge, Switch, LengthMeter, FilterBar, ChipBar, ScoreRing } from '../components/ui';
import { IconExternal, IconRefresh, IconX, IconSparkle, IconCode } from '../components/icons';
import SchemaEditor from '../components/SchemaEditor';
import KeywordEditor from '../components/KeywordEditor';
import SnippetPreview from '../components/SnippetPreview';
import AdvancedSeo from '../components/AdvancedSeo';
import CheckList from '../components/CheckList';
import HeadTab from '../components/HeadTab';
import ReportTab from '../components/ReportTab';
import AiReview from '../components/AiReview';
import AiTools from '../components/AiTools';

/**
 * The fields the drawer's Save writes.
 *
 * Comparing against this is how "you have unsaved changes" stays honest: a
 * dirty flag set by every keystroke would fire on a keystroke that was undone,
 * and one set by nothing at all is what lets people close the panel and lose
 * the description they just wrote.
 */
const snapshot = (it) =>
	JSON.stringify({
		seo_title: it?.seo_title || '',
		seo_desc: it?.seo_desc || '',
		exclude: !!it?.exclude,
		robots: it?.robots || [],
		keywords: it?.keywords || {},
		canonical: it?.canonical || '',
		breadcrumb_title: it?.breadcrumb_title || '',
		previews: it?.previews || {},
		social: it?.social || {},
	});

function Drawer({ id, onClose, onChanged, onNavigate, position }) {
	const [item, setItem] = useState(null);
	const [busy, setBusy] = useState(false);
	const [aiBusy, setAiBusy] = useState(null);
	// Concrete rewrite suggestions from the coach, shown where they were asked for.
	const [coach, setCoach] = useState(null);
	const [saved, setSaved] = useState(false);
	const [error, setError] = useState(null);
	const [faqs, setFaqs] = useState([]);
	const [faqSaved, setFaqSaved] = useState(false);
	const [schema, setSchema] = useState(null);
	const [schemaSaved, setSchemaSaved] = useState(false);
	const [tab, setTab] = useState('optimize');
	const [maxed, setMaxed] = useState(false);
	// The head plan is fetched with the record rather than when the tab is
	// opened, so the tab can carry a count before anybody has looked at it.
	const [head, setHead] = useState(null);
	// A snapshot of the fields the footer's Save writes, taken every time the
	// server hands us a fresh record. Anything diverging from it is unsaved.
	const [clean, setClean] = useState(null);
	const bodyRef = useRef(null);

	useEffect(() => {
		setItem(null);
		setFaqs([]);
		setSchema(null);
		setHead(null);
		setTab('optimize');
		api
			.get(`/content/${id}`)
			.then((next) => {
				setItem(next);
				setClean(snapshot(next));
			})
			.catch((e) => setError(e.message));
		api.get(`/schema/post/${id}`).then(setSchema).catch(() => {});
		api.get(`/content/${id}/head`).then(setHead).catch(() => {});
		// Saved FAQs must come back when the page is reopened — otherwise the
		// panel looks empty and the next generation silently replaces them.
		api.get(`/ai/faq/${id}`).then((r) => setFaqs(r.items || [])).catch(() => {});
	}, [id]);

	const dirty = clean !== null && item !== null && snapshot(item) !== clean;

	// One confirm, in one place, rather than at each exit — leaving by Escape,
	// by the X, by the scrim and by Alt+arrow are the same decision.
	const confirmLeave = () =>
		!dirty ||
		// eslint-disable-next-line no-alert
		window.confirm(__('You have unsaved changes on this page. Leave without saving?', 'ai-rank-suite'));

	const close = () => {
		if (confirmLeave()) onClose();
	};

	const step = (delta) => {
		if (!onNavigate || !position) return;
		const target = position.index + delta;
		if (target < 0 || target >= position.total) return;
		if (confirmLeave()) onNavigate(target);
	};

	// Escape closes the sheet; every tab switch returns the body to the top so
	// the reader never lands mid-section.
	useEffect(() => {
		const onKey = (e) => {
			if (e.key === 'Escape') {
				close();
				return;
			}
			// Alt+↑/↓ moves to the previous or next page in the list without
			// closing — reviewing forty pages should not mean forty round trips
			// through the table.
			if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
				e.preventDefault();
				step(e.key === 'ArrowDown' ? 1 : -1);
			}
		};
		document.addEventListener('keydown', onKey);
		return () => document.removeEventListener('keydown', onKey);
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [dirty, position?.index, position?.total]);

	useEffect(() => {
		if (bodyRef.current) bodyRef.current.scrollTop = 0;
	}, [tab]);

	const saveSchema = async () => {
		setAiBusy('schema');
		setError(null);
		try {
			const next = await api.put(`/schema/post/${id}`, { items: schema.items });
			setSchema(next);
			setSchemaSaved(true);
			setTimeout(() => setSchemaSaved(false), 2200);
		} catch (e) {
			setError(e.message);
		} finally {
			setAiBusy(null);
		}
	};

	// Title suggestions come back as a list; the drawer applies the first and
	// keeps the rest available in the full editor rather than opening a picker
	// inside a picker.
	/*
	 * Is the AI module in this build at all?
	 *
	 * The free edition ships without it, and these two buttons called
	 * /ai/titles and /ai/describe regardless — so pressing "Write with AI" on
	 * the free edition produced "no route matched the URL and request method"
	 * next to the field it was supposed to fill. The fields themselves were
	 * always editable; the button beside them was not.
	 */
	const aiTitle = async () => {
		setAiBusy('title');
		setError(null);
		try {
			const r = await api.post(`/ai/titles/${id}`);
			const first = (r.titles || [])[0];
			if (first) setItem((it) => ({ ...it, seo_title: first }));
		} catch (e) {
			setError(e.message);
		} finally {
			setAiBusy(null);
		}
	};

	const aiDescribe = async () => {
		setAiBusy('desc');
		setError(null);
		try {
			const r = await api.post(`/ai/describe/${id}`);
			setItem((it) => ({ ...it, seo_desc: r.meta_description }));
		} catch (e) {
			setError(e.message);
		} finally {
			setAiBusy(null);
		}
	};

	/**
	 * "Fix with AI" on a single failing check.
	 *
	 * The three title checks and the keyword-in-title one all have the same
	 * remedy — a better title — so they route to the title generator; the ones
	 * about the opening and the body route to the coach, which is already
	 * grounded in the failing checks. A check with no route does nothing rather
	 * than pretending: an AI button that produces a shrug is worse than no
	 * button.
	 */
	const fixCheck = async (check) => {
		const titleFixes = ['title_number', 'title_sentiment', 'title_power', 'kw_title_start'];
		setAiBusy(check.id);
		setError(null);
		try {
			if (titleFixes.includes(check.id)) {
				const r = await api.post(`/ai/titles/${id}`);
				const first = (r.titles || [])[0];
				if (first) {
					setItem((it) => ({ ...it, seo_title: first }));
					setTab('optimize');
				}
				return;
			}
			const r = await api.post(`/ai/improve/${id}`);
			setCoach({ check: check.id, suggestions: r.suggestions || r.improvements || [] });
		} catch (e) {
			setError(e.message);
		} finally {
			setAiBusy(null);
		}
	};

	const aiFaq = async () => {
		setAiBusy('faq');
		setError(null);
		try {
			const r = await api.post(`/ai/faq/${id}`);
			setFaqs(r.items || []);
		} catch (e) {
			setError(e.message);
		} finally {
			setAiBusy(null);
		}
	};

	const saveFaq = async () => {
		setAiBusy('faq-save');
		try {
			await api.put(`/ai/faq/${id}`, { items: faqs });
			setFaqSaved(true);
			setTimeout(() => setFaqSaved(false), 2200);
		} catch (e) {
			setError(e.message);
		} finally {
			setAiBusy(null);
		}
	};

	/**
	 * An AI proposal was applied, or undone.
	 *
	 * Unlike the fields above, this path has already written to the database:
	 * /ai/suggest/{id}/apply calls update_post_meta itself and returns the
	 * values it stored. So there is nothing to save, and two things that were
	 * being missed.
	 *
	 * The list was never told. It merged the drawer's changes only when the
	 * footer's Save ran, so applying a proposal updated the drawer and left the
	 * row behind it showing the old T/D/K dots and the old title — a change
	 * that had genuinely been made looked like it had not, which is exactly
	 * how it was reported.
	 *
	 * And the drawer went on believing it was dirty, because `clean` was never
	 * moved to the new values. Closing it then warned about losing changes that
	 * were already stored, and pressing Save wrote the same strings again.
	 *
	 * Re-reading the row rather than merging the two fields is deliberate: the
	 * list renders has_title, has_desc and the scores, and none of those are in
	 * the apply response. Patching in seo_title alone would leave the dots
	 * wrong, which is the same bug one layer down.
	 */
	const applied = async (v) => {
		// Show the new text at once; the round trip below is for the rest.
		setItem((it) => ({ ...it, ...v }));
		try {
			/*
			 * Everything the change touched, not only the field it was written
			 * into.
			 *
			 * A new SEO title changes what the page emits in its head, and it
			 * changes what the audit should say about the title — so the Head
			 * tab and the checks both go stale the moment it is applied. The
			 * first version of this reloaded only the item, which left the Head
			 * tab showing the previous title and looked exactly like a change
			 * that had not been adopted. Schema is reloaded too, because a
			 * rewrite alters the body that schema is generated from.
			 *
			 * The item is awaited because the drawer's clean snapshot and the
			 * list row both depend on it; the other two are allowed to fail
			 * without taking the apply down with them.
			 */
			const next = await api.get(`/content/${id}`);
			setItem(next);
			setClean(snapshot(next));
			onChanged(next);
			api.get(`/content/${id}/head`).then(setHead).catch(() => {});
			api.get(`/schema/post/${id}`).then(setSchema).catch(() => {});
		} catch {
			// The write already succeeded, so a failed re-read must not look
			// like a failed apply. The drawer keeps the new text and the list
			// catches up when it next loads.
		}
	};

	const run = async (fn) => {
		setBusy(true);
		setError(null);
		try {
			const next = await fn();
			setItem(next);
			setClean(snapshot(next));
			onChanged(next);
		} catch (e) {
			setError(e.message);
		} finally {
			setBusy(false);
		}
	};

	// Everything the drawer can edit goes in one payload — a partial save was
	// silently dropping keywords and robots directives.
	const save = () =>
		run(async () => {
			const next = await api.put(`/content/${id}/meta`, {
				seo_title: item.seo_title,
				seo_desc: item.seo_desc,
				exclude: item.exclude,
				robots: item.robots || [],
				keywords: item.keywords || {},
				canonical: item.canonical || '',
				slug: item.slug || '',
				breadcrumb_title: item.breadcrumb_title || '',
				previews: item.previews || {},
				social: item.social || {},
			});
			setSaved(true);
			setTimeout(() => setSaved(false), 2200);
			return next;
		});

	const groups = [
		{ id: 'geo', label: __('GEO — AI readiness', 'ai-rank-suite') },
		{ id: 'keyword', label: __('Focus keyword', 'ai-rank-suite') },
		{ id: 'seo', label: __('SEO — classic search', 'ai-rank-suite') },
	];

	const failing = item ? item.checks.filter((c) => c.status !== 'pass').length : 0;
	const headIssues = head ? head.issues.filter((i) => i.level !== 'info').length : 0;
	const wide = tab === 'schema' || tab === 'keywords';

	const TABS = [
		{ id: 'optimize', label: __('Optimize', 'ai-rank-suite') },
		{ id: 'report', label: __('Full report', 'ai-rank-suite') },
		{ id: 'preview', label: __('Preview', 'ai-rank-suite') },
		{ id: 'head', label: __('Head', 'ai-rank-suite'), count: headIssues, tone: 'quiet' },
		{ id: 'keywords', label: __('Keywords', 'ai-rank-suite') },
		{ id: 'schema', label: __('Schema', 'ai-rank-suite') },
		{ id: 'faq', label: __('FAQ', 'ai-rank-suite'), count: faqs.length || 0, tone: 'quiet' },
		{ id: 'advanced', label: __('Advanced', 'ai-rank-suite') },
		{ id: 'checks', label: __('Checks', 'ai-rank-suite'), count: failing },
	];

	// Rendered into #airanksuite-root rather than in place. A fixed-position panel must
	// answer to the viewport, and any ancestor with a transform, filter or
	// containment silently takes that job away from it. Portalling to the app
	// root keeps the CSS custom properties (they are declared on #airanksuite-root)
	// while putting the sheet out of reach of whatever the page does next.
	return createPortal(
		<>
			<button type="button" className="ars-scrim" aria-label={__('Close panel', 'ai-rank-suite')} onClick={close} />
			<aside
					className={`ars-drawer${maxed ? ' is-max' : wide ? ' is-wide' : ''}`}
				role="dialog"
				aria-modal="true"
				aria-label={__('Page analysis', 'ai-rank-suite')}
			>
				{!item ? (
					<div className="p-6" style={{ gridRow: '1 / -1' }}>
						{error || 'Loading…'}
					</div>
				) : (
					<>
						<header className="ars-sheet-head">
							<div className="flex items-start justify-between gap-3">
								<div className="min-w-0">
									<h2 className="m-0 text-[15px] font-bold leading-snug truncate" title={item.title}>
										{item.title}
									</h2>
									<div
										className="flex items-center gap-2 mt-1 text-[11.5px] flex-wrap"
										style={{ color: 'var(--ars-ink-3)' }}
									>
										<span className="uppercase font-semibold">{item.type}</span>
										<span>·</span>
										<span>{sprintf(_n('%s word', '%s words', Number(item.words) || 0, 'ai-rank-suite'), item.words)}</span>
										<span>·</span>
										<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1">{__('View', 'ai-rank-suite')}<IconExternal size={11} />
										</a>
										<a href={item.edit_url} target="_blank" rel="noreferrer">{__('Edit', 'ai-rank-suite')}</a>
									</div>
								</div>
								<div className="flex items-center gap-2 shrink-0">
									{position && position.total > 1 && (
										<span className="flex items-center gap-1">
											<button
												type="button"
												onClick={() => step(-1)}
												disabled={position.index <= 0}
												aria-label={__('Previous page', 'ai-rank-suite')}
												title={__('Previous page (Alt+↑)', 'ai-rank-suite')}
												className="ars-move"
											>
												↑
											</button>
											<span className="text-[11px] tabular-nums" style={{ color: 'var(--ars-ink-3)' }}>
												{position.index + 1}/{position.total}
											</span>
											<button
												type="button"
												onClick={() => step(1)}
												disabled={position.index >= position.total - 1}
												aria-label={__('Next page', 'ai-rank-suite')}
												title={__('Next page (Alt+↓)', 'ai-rank-suite')}
												className="ars-move"
											>
												↓
											</button>
										</span>
									)}
									{dirty && (
										<span
											className="ars-tag"
											style={{ background: 'var(--ars-c-yellow)', color: 'var(--ars-c-yellow-ink)' }}
											title={__('You have changes that are not saved yet', 'ai-rank-suite')}
										>{__('Unsaved', 'ai-rank-suite')}</span>
									)}
									<ScoreChip label={__('GEO', 'ai-rank-suite')} value={item.geo_score} />
									<ScoreChip label={__('SEO', 'ai-rank-suite')} value={item.seo_score} />
									<button
										type="button"
										onClick={() => setMaxed(!maxed)}
										aria-label={maxed ? __('Restore panel width', 'ai-rank-suite') : __('Expand panel', 'ai-rank-suite')}
										title={maxed ? __('Restore width', 'ai-rank-suite') : __('Expand to full width', 'ai-rank-suite')}
										className="border-0 bg-transparent cursor-pointer p-1"
										style={{ color: 'var(--ars-ink-3)', fontSize: 13, lineHeight: 1 }}
									>
										{maxed ? '⤡' : '⤢'}
									</button>
									<button
										type="button"
										onClick={close}
										aria-label={__('Close', 'ai-rank-suite')}
										title={__('Close (Esc)', 'ai-rank-suite')}
										className="border-0 bg-transparent cursor-pointer p-1"
										style={{ color: 'var(--ars-ink-3)' }}
									>
										<IconX size={17} />
									</button>
								</div>
							</div>
						</header>

						<div className="ars-sheet-tabs" role="tablist">
							{TABS.map((t) => (
								<button
									key={t.id}
									type="button"
									role="tab"
									aria-selected={tab === t.id}
									className="ars-sheet-tab"
									onClick={() => setTab(t.id)}
								>
									{t.label}
									{t.count > 0 && (
										<span
											className="ars-pip"
											style={
												t.tone === 'quiet'
													? { background: 'var(--ars-surface-3, #eceff3)', color: 'var(--ars-ink-2)' }
													: undefined
											}
										>
											{t.count}
										</span>
									)}
								</button>
							))}
						</div>

						<div className="ars-sheet-body" ref={bodyRef}>
							<div className="ars-sheet-main">
							{tab === 'optimize' && (
								<div className="grid gap-4">
									{/* The reviewed path: propose, compare, edit, apply, undo. The
									    "Write with AI" buttons below stay for a quick single field. */}
									<AiReview
										postId={id}
										onApplied={applied}
									/>
									<AiTools
										postId={id}
										onSchema={(entry) => {
											// Hand the proposal to the schema editor as a new item;
											// saving is the editor's own button, so nothing is
											// written until the reviewer says so there.
											setSchema((sc) => ({ ...(sc || {}), items: [...((sc && sc.items) || []), entry] }));
											setTab('schema');
										}}
										onTranslate={(t) => setItem((it) => ({ ...it, seo_title: t.title, seo_desc: t.description }))}
										// Rewrite, alt text and links write straight to the page,
										// so the same re-read the proposal path uses applies here.
										onServerEdit={() => applied({})}
									/>
									<div>
										<div className="flex items-center justify-between gap-2 mb-1">
											<label className="block font-medium">{__('SEO title', 'ai-rank-suite')}</label>
											{HAS_AI && (
												<Button variant="ghost" busy={aiBusy === 'title'} onClick={aiTitle}>
													<IconSparkle size={13} />{__('Write with AI', 'ai-rank-suite')}</Button>
											)}
										</div>
										<input
											type="text"
											value={item.seo_title}
											placeholder={item.title}
											onChange={(e) => setItem({ ...item, seo_title: e.target.value })}
										/>
										<div className="mt-1.5">
											<LengthMeter value={item.seo_title || item.title} kind="title" />
										</div>
									</div>
									<div>
										<div className="flex items-center justify-between gap-2 mb-1">
											<label className="block font-medium">{__('Meta description', 'ai-rank-suite')}</label>
											{HAS_AI && (
												<Button variant="ghost" busy={aiBusy === 'desc'} onClick={aiDescribe}>
													<IconSparkle size={13} />{__('Write with AI', 'ai-rank-suite')}</Button>
											)}
										</div>
										<textarea
											rows={3}
											value={item.seo_desc}
											placeholder={item.excerpt || __('Describe this page in one or two sentences…', 'ai-rank-suite')}
											onChange={(e) => setItem({ ...item, seo_desc: e.target.value })}
										/>
										<div className="mt-1.5">
											<LengthMeter value={item.seo_desc || item.excerpt} kind="description" />
										</div>
									</div>
									<div>
										<label className="block font-medium mb-1.5">{__('Search engine visibility', 'ai-rank-suite')}</label>
										<div className="flex flex-wrap gap-3 text-[12.5px]">
											{['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex'].map((d) => (
												<label key={d} className="inline-flex items-center gap-1.5">
													<input
														type="checkbox"
														checked={(item.robots || []).includes(d)}
														onChange={(e) =>
															setItem({
																...item,
																robots: e.target.checked
																	? [...(item.robots || []), d]
																	: (item.robots || []).filter((x) => x !== d),
															})
														}
													/>
													{d}
												</label>
											))}
										</div>
									</div>
									<Switch
										checked={!item.exclude}
										onChange={(v) => setItem({ ...item, exclude: !v })}
										label={__('Include in llms.txt', 'ai-rank-suite')}
									/>
									{failing > 0 && (
										<button
											type="button"
											onClick={() => setTab('checks')}
											className="text-left px-3 py-2 cursor-pointer"
											style={{
												border: '1px solid var(--ars-border)',
												borderRadius: 'var(--ars-r-sm)',
												background: 'var(--ars-c-yellow)',
												color: 'var(--ars-c-yellow-ink)',
												fontSize: 12.5,
												fontWeight: 600,
											}}
										>
											{sprintf(_n('%d check needs attention → open Checks', '%d checks need attention → open Checks', failing, 'ai-rank-suite'), failing)}
										</button>
									)}
								</div>

							)}

							{tab === 'preview' && (
								<div className="grid gap-3">
									<p className="m-0 text-[12.5px]" style={{ color: 'var(--ars-ink-3)' }}>{__('How this page appears in search results and when it is shared.', 'ai-rank-suite')}</p>
									<SnippetPreview serp={item.serp} card={item.social_card} />
								</div>
							)}

							{tab === 'keywords' && (
								<div className="grid gap-3">
									<p className="m-0 text-[12.5px]" style={{ color: 'var(--ars-ink-3)' }}>
										Up to {item.max_keywords || 100} per group — short-tail, long-tail and the prompts people ask AI.
									</p>
									<KeywordEditor
										keywords={item.keywords}
										coverage={item.coverage}
										max={item.max_keywords || 100}
										postId={item.id}
										onChange={(keywords) => setItem({ ...item, keywords })}
									/>
								</div>
							)}

							{tab === 'schema' &&
								(schema ? (
									<div className="grid gap-3">
										<div className="flex items-center justify-between gap-2 flex-wrap">
											<p className="m-0 text-[12.5px]" style={{ color: 'var(--ars-ink-3)' }}>{__('Add as many types as the page needs — validated against Google’s requirements.', 'ai-rank-suite')}</p>
											<div className="flex items-center gap-2">
												{schemaSaved && <Badge tone="good">✓ {__('Saved', 'ai-rank-suite')}</Badge>}
												<Button variant="ghost" busy={aiBusy === 'schema'} onClick={saveSchema}>{__('Save schema', 'ai-rank-suite')}</Button>
											</div>
										</div>
										<SchemaEditor data={schema} onChange={(items) => setSchema({ ...schema, items })} />
									</div>
								) : (
									<p className="m-0 text-[12.5px]" style={{ color: 'var(--ars-ink-3)' }}>{__('Loading structured data…', 'ai-rank-suite')}</p>
								))}

							{tab === 'faq' && (
								<div className="grid gap-3">
									<div className="flex items-center justify-between gap-2 flex-wrap">
										<p className="m-0 text-[12.5px]" style={{ color: 'var(--ars-ink-3)' }}>{__('Grounded Q&A — feeds FAQPage schema and the', 'ai-rank-suite')}{' '}<code className="ars-mono">[airanksuite_faq]</code>{' '}
											{__('shortcode.', 'ai-rank-suite')}
										</p>
										<Button variant="ghost" busy={aiBusy === 'faq'} onClick={aiFaq}>
											<IconSparkle size={13} />{__('Generate', 'ai-rank-suite')}</Button>
									</div>
									{faqs.length === 0 ? (
										<div className="grid gap-2 place-items-start">
											<p className="m-0 text-[12.5px]" style={{ color: 'var(--ars-ink-2)' }}>
												{__('No FAQ items yet. Generate them from this page’s content (requires an AI key), or write one yourself.', 'ai-rank-suite')}
											</p>
											<Button variant="ghost" onClick={() => setFaqs([{ question: '', answer: '' }])}>{__('Add a question', 'ai-rank-suite')}</Button>
										</div>
									) : (
										<div className="grid gap-3">
											{faqs.map((f, i) => (
												<div
													key={i}
													className="grid gap-1.5 p-3"
													style={{ background: 'var(--ars-surface-2)', borderRadius: 'var(--ars-r-sm)' }}
												>
													<input
														type="text"
														value={f.question}
														onChange={(e) =>
															setFaqs(faqs.map((x, j) => (i === j ? { ...x, question: e.target.value } : x)))
														}
													/>
													<textarea
														rows={2}
														value={f.answer}
														onChange={(e) =>
															setFaqs(faqs.map((x, j) => (i === j ? { ...x, answer: e.target.value } : x)))
														}
													/>
													<div>
														<button
															type="button"
															className="border-0 bg-transparent cursor-pointer text-[12px] p-0"
															style={{ color: 'var(--ars-serious)' }}
															onClick={() => setFaqs(faqs.filter((_, j) => j !== i))}
														>{__('Remove', 'ai-rank-suite')}</button>
													</div>
												</div>
											))}
											<div className="flex items-center gap-2 flex-wrap">
												<Button onClick={saveFaq} busy={aiBusy === 'faq-save'}>{__('Save FAQ', 'ai-rank-suite')}</Button>
												<Button variant="ghost" onClick={() => setFaqs([...faqs, { question: '', answer: '' }])}>{__('Add a question', 'ai-rank-suite')}</Button>
												{faqSaved && <Badge tone="good">✓ {__('Saved', 'ai-rank-suite')}</Badge>}
											</div>
										</div>
									)}
								</div>
							)}

							{tab === 'report' && <ReportTab postId={id} onGoTo={setTab} />}

							{tab === 'head' && <HeadTab postId={id} initial={head} onLoad={setHead} onGoTo={setTab} />}

							{tab === 'advanced' && (
								<div className="grid gap-3">
									<p className="m-0 text-[12.5px]" style={{ color: 'var(--ars-ink-3)' }}>{__('Canonical, breadcrumb label, Google preview limits and social overrides.', 'ai-rank-suite')}</p>
									<AdvancedSeo item={item} onChange={(patch) => setItem({ ...item, ...patch })} />
								</div>
							)}

							{tab === 'optimize' && (
								<p className="m-0 mt-1 text-[12px]" style={{ color: 'var(--ars-ink-3)' }}>
									{__('Permalink, canonical URL, breadcrumb title and social overrides are under Advanced.', 'ai-rank-suite')}
								</p>
							)}

							{tab === 'checks' && coach && (
								<div
									className="ars-r-sm p-3 mb-2"
									style={{ background: 'var(--ars-brand-tonal)', color: 'var(--ars-brand-tonal-ink)' }}
								>
									<div className="font-semibold text-[12.5px] mb-1">{__('Suggestions', 'ai-rank-suite')}</div>
									{/*
									  * Read the shape the server actually sends.
									  *
									  * /ai/improve returns {title, detail} per suggestion. This
									  * read `sug.text || sug.suggestion`, and neither key exists,
									  * so every item rendered as undefined — a list of empty
									  * bullets under a heading, which looked like a panel that
									  * had failed to load rather than one rendering the wrong
									  * field. The older keys stay in the chain because a cached
									  * response from a previous version may still use them.
									  */}
									{(() => {
										const items = (coach.suggestions || [])
											.map((sug) => {
												if (typeof sug === 'string') return { title: sug, detail: '' };
												return {
													title: sug.title || sug.text || sug.suggestion || '',
													detail: sug.detail || sug.description || '',
												};
											})
											.filter((sug) => sug.title || sug.detail);

										if (items.length === 0) {
											return <p className="m-0 text-[12.5px]">{__('The model had nothing to add for this one.', 'ai-rank-suite')}</p>;
										}
										return (
											<ol className="m-0 pl-4 grid gap-2 text-[12.5px]">
												{items.map((sug, i) => (
													<li key={i}>
														{sug.title && <strong>{sug.title}</strong>}
														{sug.title && sug.detail ? <br /> : null}
														{sug.detail}
													</li>
												))}
											</ol>
										);
									})()}
								</div>
							)}

							{tab === 'checks' && (
								<CheckList
									checks={item.checks}
									sections={item.sections}
									fixing={aiBusy}
									onFix={fixCheck}
								/>
							)}
							</div>

							{/* The context rail.
							    Only the Optimize tab used to be two columns, so the whole
							    panel changed shape every time somebody switched tab — the
							    form jumped from half width to full and back. The rail is
							    now permanent and identical everywhere, which fixes the
							    jump and, more usefully, means the score and the snippet
							    are on screen while you edit the schema or read the checks
							    rather than only while you edit the title. */}
							<aside className="ars-sheet-rail">
								<div className="grid gap-1.5">
									<div className="t-overline">{__('This page', 'ai-rank-suite')}</div>
									<div className="flex items-center gap-2">
										<ScorePill value={item.geo_score} />
										{item.stale && (
											<span className="t-caption" title={__('This page changed after it was last scored — run the analyzer to refresh it.', 'ai-rank-suite')}>
												·&nbsp;stale
											</span>
										)}
										<span className="t-caption">{__('GEO', 'ai-rank-suite')}</span>
										<ScorePill value={item.seo_score} />
										<span className="t-caption">{__('SEO', 'ai-rank-suite')}</span>
									</div>
									<div className="t-caption">
										{sprintf(
											/* translators: 1: word count, 2: number of failing checks. */
											_n('%1$s words · %2$s open check', '%1$s words · %2$s open checks', failing, 'ai-rank-suite'),
											Number(item.words || 0).toLocaleString(),
											Number(failing).toLocaleString()
										)}
									</div>
									{item.readability?.sentences > 0 && (
										<div className="grid gap-1 mt-1">
											<div className="flex items-baseline justify-between">
												<span className="t-caption">{__('Readability', 'ai-rank-suite')}</span>
												<span className="t-caption">
													{sprintf(
														/* translators: 1: reading-ease score, 2: word for how hard it reads. */
														__('%1$d / 100 · %2$s', 'ai-rank-suite'),
														item.readability.flesch,
														item.readability.label
													)}
												</span>
											</div>
											<Bar
												value={Math.max(2, Math.min(100, item.readability.flesch))}
												color={
													item.readability.flesch >= 60
														? 'var(--ars-good)'
														: item.readability.flesch >= 30
															? 'var(--ars-warning)'
															: 'var(--ars-serious)'
												}
											/>
											<span className="t-caption">
												{sprintf(
													/* translators: 1: average words per sentence, 2: percentage of passive sentences. */
													__('%1$s words a sentence · %2$d%% passive', 'ai-rank-suite'),
													item.readability.words_per_sentence,
													item.readability.passive_share
												)}
											</span>
										</div>
									)}
								</div>

								<div className="grid gap-1.5">
									<div className="t-overline">{__('Live preview', 'ai-rank-suite')}</div>
									<SnippetPreview
										serp={{
											...item.serp,
											title: resolved(item.seo_title, item.serp?.title),
											desc: resolved(item.seo_desc, item.serp?.desc),
										}}
										card={item.social_card}
										brand={item.serp?.site}
									/>
								</div>

								{failing > 0 && (
									<div className="grid gap-1.5">
										<div className="t-overline">{__('Next to fix', 'ai-rank-suite')}</div>
										<ul className="m-0 p-0 list-none grid gap-1">
											{item.checks
												.filter((c) => c.status !== 'pass')
												.slice(0, 4)
												.map((c) => (
													<li
														key={c.id}
														className="text-[12px] flex gap-2 items-start px-2 py-1.5 ars-r-xs"
														style={{ background: 'var(--ars-surface-2)', color: 'var(--ars-ink-2)' }}
													>
														<span
															aria-hidden
															style={{
																color: c.status === 'fail' ? 'var(--ars-serious)' : 'var(--ars-warning)',
																fontWeight: 700,
															}}
														>
															{c.status === 'fail' ? '\u2715' : '!'}
														</span>
														<span>{c.label}</span>
													</li>
												))}
										</ul>
										<button
											type="button"
											onClick={() => setTab('checks')}
											className="border-0 bg-transparent cursor-pointer p-0 text-[12px] font-semibold text-left"
											style={{ color: 'var(--ars-brand)' }}
										>
											{sprintf(__('All %s checks →', 'ai-rank-suite'), failing)}
										</button>
									</div>
								)}
							</aside>
						</div>

						<footer className="ars-sheet-foot">
							<Button onClick={save} busy={busy} disabled={!dirty && !busy}>
								{dirty ? __('Save & re-analyze', 'ai-rank-suite') : __('Saved', 'ai-rank-suite')}
							</Button>
							<Button variant="ghost" busy={busy} onClick={() => run(() => api.post(`/content/${id}/analyze`))}>
								<IconRefresh size={13} />{__('Re-analyze', 'ai-rank-suite')}</Button>
							{saved && <Badge tone="good">✓ {__('Saved', 'ai-rank-suite')}</Badge>}
							{error && (
								<span className="text-[12px]" style={{ color: 'var(--ars-serious)' }}>
									{error}
								</span>
							)}
							<span className="ml-auto text-[11.5px]" style={{ color: 'var(--ars-ink-3)' }}>
								{position && position.total > 1 ? __('Alt+↑ / Alt+↓ to move · Esc to close', 'ai-rank-suite') : __('Esc to close', 'ai-rank-suite')}
							</span>
						</footer>
					</>
				)}
			</aside>
		</>,
		document.getElementById('airanksuite-root') || document.body
	);
}

/**
 * Which value to preview.
 *
 * A per-post title may be a template — `%title% %sep% %sitename%` is a
 * perfectly ordinary thing to type into that field, and imports from Yoast and
 * Rank Math bring plenty of them in. The raw string is what the input shows,
 * but previewing it verbatim would tell the user their result reads
 * "%title% — %category%", which is not what any visitor will ever see. When the
 * field carries a variable, fall back to the resolved value the server sent.
 */
function resolved(value, fallback) {
	const v = (value || '').trim();
	if (!v) return fallback;
	return /%[a-z_]+%/i.test(v) ? fallback || v : v;
}

/** GEO/SEO score as a compact tonal chip — two big cards were pure scroll cost. */
function ScoreChip({ label, value }) {
	// A page that has never been analysed has no score — which is a different
	// statement from scoring zero, and must not be painted red as if it failed.
	if (value === null || value === undefined) {
		return (
			<span className="ars-scorechip" style={{ background: 'var(--ars-surface-3)', color: 'var(--ars-ink-3)' }} title={`${label} — not analysed yet`}>
				{label} <b>—</b>
			</span>
		);
	}
	const tone = value >= 80 ? 'green' : value >= 50 ? 'yellow' : 'red';
	return (
		<span
			className="ars-scorechip"
			style={{ background: `var(--ars-c-${tone})`, color: `var(--ars-c-${tone}-ink)` }}
			title={`${label} score`}
		>
			{label} <b>{value}</b>
		</span>
	);
}

/** The filters that answer "what is actually broken?" in one click. */
const FILTERS = [
	{ id: '', label: __('All pages', 'ai-rank-suite') },
	{ id: 'weak_geo', label: __('Weak GEO', 'ai-rank-suite') },
	{ id: 'weak_seo', label: __('Weak SEO', 'ai-rank-suite') },
	{ id: 'no_desc', label: __('No description', 'ai-rank-suite') },
	{ id: 'no_keyword', label: __('No keyword', 'ai-rank-suite') },
	{ id: 'thin', label: __('Thin content', 'ai-rank-suite') },
	{ id: 'noindex', label: __('Noindex', 'ai-rank-suite') },
	{ id: 'excluded', label: __('Not in llms', 'ai-rank-suite') },
];

/** A sortable column header. The arrow is the whole affordance. */
function SortHead({ id, label, sort, onSort, align = 'left', width = 'narrow' }) {
	const active = sort.by === id;
	return (
		<th className={align === 'right' ? `is-num is-${width}` : ''}>
			<button
				type="button"
				onClick={() => onSort(id)}
				className="border-0 bg-transparent cursor-pointer p-0 font-medium inline-flex items-center gap-1"
				style={{ color: active ? 'var(--ars-brand)' : 'inherit', font: 'inherit' }}
			>
				{label}
				<span aria-hidden style={{ opacity: active ? 1 : 0.25 }}>
					{active && sort.dir === 'asc' ? '▲' : '▼'}
				</span>
			</button>
		</th>
	);
}

export default function Content() {
	// #/content/123 opens that page's drawer directly. The editor panels link
	// here with the id they already know; without this they arrived at a list
	// and made the writer search for the page they had just been editing.
	const hashId = () => {
		const match = /#\/content\/(\d+)/.exec(window.location.hash || '');
		return match ? Number(match[1]) : null;
	};

	// Filters are deep-linkable: #/content?filter=no_desc&type=post&status=draft&q=…
	// so a view can be bookmarked or sent to a colleague, and the toolbar
	// jump list can land on "pages without a description" directly.
	const fromHash = () => {
		const q = new URLSearchParams((window.location.hash.split('?')[1] || ''));
		return {
			filter: q.get('filter') || '',
			type: q.get('type') || 'any',
			status: q.get('status') || '',
			search: q.get('q') || '',
			perPage: [20, 50, 100].includes(Number(q.get('per_page'))) ? Number(q.get('per_page')) : 20,
		};
	};
	const initial = fromHash();
	const [items, setItems] = useState(null);
	const [perPage, setPerPage] = useState(initial.perPage);
	const [selected, setSelected] = useState(() => new Set());
	const [bulkBusy, setBulkBusy] = useState('');
	const [meta, setMeta] = useState({ total: 0, pages: 1 });
	const [page, setPage] = useState(1);
	const [search, setSearch] = useState(initial.search);
	const [filter, setFilter] = useState(initial.filter);
	const [type, setType] = useState(initial.type);
	const [lang, setLang] = useState('');
	const [types, setTypes] = useState([]);
	// The library overview: one grouped query per five minutes, not one per
	// type per question. It drives the tab row, the counts and the health bar.
	const [library, setLibrary] = useState(null);
	const [tax, setTax] = useState({ tax: '', term: '' });
	const [status, setStatus] = useState(initial.status);
	const [sort, setSort] = useState({ by: 'modified', dir: 'desc' });
	const [open, setOpen] = useState(hashId);

	// Following a link to #/content/123 while this screen is already mounted
	// must open that page too, not just a fresh page load.
	useEffect(() => {
		const onHash = () => {
			const id = hashId();
			if (id) setOpen(id);
		};
		window.addEventListener('hashchange', onHash);
		return () => window.removeEventListener('hashchange', onHash);
	}, []);
	const [loading, setLoading] = useState(false);
	const [error, setError] = useState(null);
	const [scoring, setScoring] = useState(null);
	// How many rows the free site-wide scorer has finished, so the button can
	// count up instead of sitting on "Queuing…" for a minute.
	const [scoreDone, setScoreDone] = useState(0);

	// Bulk over the ticked rows: the same queue the Bulk tab drives, fed a
	// hand-picked set. Progress and results live on that tab.
	const bulkSelected = async (action) => {
		if (selected.size === 0) return;
		setBulkBusy(action);
		setError(null);
		try {
			await api.post('/bulk/start', { action, scope: 'ids', ids: [...selected] });
			window.location.hash = '/content/bulk';
		} catch (e) {
			setError(e.message);
		} finally {
			setBulkBusy('');
		}
	};
	const describeSiteWide = async () => {
		setBulkBusy('describe-all');
		setError(null);
		try {
			await api.post('/bulk/start', { action: 'describe', scope: 'missing' });
			window.location.hash = '/content/bulk';
		} catch (e) {
			setError(e.message);
		} finally {
			setBulkBusy('');
		}
	};
	/*
	 * Scoring every page, with and without the bulk queue.
	 *
	 * The queue lives in the Bulk module, which is paid — so in the free
	 * edition this button posted to /bulk/start and got "no route matched the
	 * URL and request method" back. Scoring itself is free: it is the local
	 * analyzer, the same one that runs when a post is saved, and the whole
	 * product is useless without it. So the free build walks the unscored rows
	 * through the per-post route instead of pretending the feature is paid.
	 */
	const scoreSiteWide = async () => {
		if (HAS_BULK) {
			setBulkBusy('site');
			setError(null);
			try {
				await api.post('/bulk/start', { action: 'analyze', scope: 'missing' });
				window.location.hash = '/content/bulk';
			} catch (e) {
				setError(e.message);
			} finally {
				setBulkBusy('');
			}
			return;
		}

		setBulkBusy('site');
		setError(null);
		const seen = new Set();
		let done = 0;
		try {
			// A scored row drops out of the filter, so page one is always the
			// next batch. `seen` is the stop condition: a pass that returns
			// only rows we have already tried is a row that cannot be scored,
			// and looping on it forever would be worse than stopping.
			for (;;) {
				const rows = await api.get('/content?type=any&filter=unscored&per_page=50&page=1');
				if (!Array.isArray(rows) || rows.length === 0) break;
				const fresh = rows.filter((r) => !seen.has(r.id));
				if (fresh.length === 0) break;
				for (const r of fresh) {
					seen.add(r.id);
					await api.post(`/content/${r.id}/analyze`);
					done += 1;
					setScoreDone(done);
				}
			}
			const lib = await api.get('/content/library');
			setLibrary(lib.types || []);
			load(page, search, filter, type, sort, lang, tax, status, perPage);
		} catch (e) {
			setError(e.message);
		} finally {
			setBulkBusy('');
			setScoreDone(0);
		}
	};
	const toggleRow = (id) =>
		setSelected((prev) => {
			const next = new Set(prev);
			if (next.has(id)) next.delete(id);
			else next.add(id);
			return next;
		});
	const toggleAll = (rows) =>
		setSelected((prev) => {
			const next = new Set(prev);
			const all = rows.every((r) => next.has(r.id));
			rows.forEach((r) => (all ? next.delete(r.id) : next.add(r.id)));
			return next;
		});

	// Score the unscored rows of one section, one after another, updating
	// each row as its result lands. Sequential on purpose: the analyzer is
	// local and quick, and twenty parallel requests would only queue in PHP.
	const scoreRows = async (tname, rows) => {
		setScoring(tname);
		try {
			for (const it of rows) {
				try {
					const next = await api.post(`/content/${it.id}/analyze`);
					setItems((list) => list.map((x) => (x.id === it.id ? { ...x, ...next } : x)));
				} catch (e) {
					setError(e.message);
				}
			}
			api.get('/content/library').then((r) => setLibrary(r.types || [])).catch(() => {});
		} finally {
			setScoring(null);
		}
	};

	const load = useCallback(async (p, q, f, t, so, lg = '', tx = { tax: '', term: '' }, st = '', pp = 20) => {
		setLoading(true);
		try {
			const params = new URLSearchParams({
				page: String(p),
				per_page: String(pp),
				search: q,
				filter: f,
				type: t,
				orderby: so.by,
				order: so.dir,
				lang: lg,
				tax: tx.tax || '',
				term: tx.term || '',
				status: st || '',
			});
			const res = await fetch(`${window.airanksuiteData.restUrl}/content?${params}`, {
				headers: { 'X-WP-Nonce': window.airanksuiteData.nonce },
				credentials: 'same-origin',
			});
			if (!res.ok) throw new Error(`Request failed (${res.status})`);
			setMeta({ total: Number(res.headers.get('X-WP-Total')), pages: Number(res.headers.get('X-WP-TotalPages')) });
			setItems(await res.json());
			setError(null);
		} catch (e) {
			setError(e.message);
		} finally {
			setLoading(false);
		}
	}, []);

	useEffect(() => {
		load(page, search, filter, type, sort, lang, tax, status, perPage);
		setSelected(new Set());
		// Mirror the view into the hash without adding history entries.
		const q = new URLSearchParams();
		if (filter) q.set('filter', filter);
		if (type !== 'any') q.set('type', type);
		if (status) q.set('status', status);
		if (search) q.set('q', search);
		if (perPage !== 20) q.set('per_page', String(perPage));
		const base = window.location.hash.split('?')[0] || '#/content';
		if (/^#\/content(\/|$|\?)/.test(base) && !/#\/content\/\d+/.test(base)) {
			const next = q.toString() ? `${base}?${q}` : base;
			if (window.location.hash !== next) window.history.replaceState(null, '', next);
		}
	}, [page, filter, type, sort, lang, tax, status, perPage, load]); // eslint-disable-line react-hooks/exhaustive-deps

	// Post types come from the insights payload, which already knows them —
	// one fewer endpoint to maintain for the same answer.
	useEffect(() => {
		if (moduleRunning('insights')) api.get('/insights?days=7').then((r) => setTypes(r.post_types || [])).catch(() => {});
		api.get('/content/library').then((r) => setLibrary(r.types || [])).catch(() => {});
	}, []);

	// Switching type resets everything scoped to the old one: a category
	// filter from Posts means nothing on Products, and silently carrying it
	// over produces an empty list with no visible reason.
	const pickType = (name) => {
		setType(name);
		setTax({ tax: '', term: '' });
		setPage(1);
	};

	// "Everything" gets a health strip too: the sum of the types, with the
	// averages weighted by size. Without it the most common view of this
	// screen opened straight onto a filter bar and a table of dashes.
	const active = (() => {
		const found = (library || []).find((t) => t.name === type);
		if (found) return found;
		if (type !== 'any' || !library || library.length === 0) return null;
		const rows = library.filter((t) => t.total > 0);
		if (rows.length === 0) return null;
		const sum = (k) => rows.reduce((a, t) => a + (t[k] || 0), 0);
		const wavg = (k) => {
			const w = rows.filter((t) => t[k] !== null && t[k] !== undefined);
			const n = w.reduce((a, t) => a + t.total, 0);
			return n ? Math.round(w.reduce((a, t) => a + t[k] * t.total, 0) / n) : null;
		};
		return {
			name: 'any',
			label: __('Everything', 'ai-rank-suite'),
			total: sum('total'),
			published: sum('published'),
			drafts: sum('drafts'),
			no_desc: sum('no_desc'),
			no_keyword: sum('no_keyword'),
			weak_geo: sum('weak_geo'),
			noindex: sum('noindex'),
			unanalyzed: sum('unanalyzed'),
			avg_geo: wavg('avg_geo'),
			avg_seo: wavg('avg_seo'),
		};
	})();

	const onSort = (by) => {
		setPage(1);
		setSort((s) => (s.by === by ? { by, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { by, dir: by === 'title' ? 'asc' : 'desc' }));
	};

	const i18n = env.i18n || { multilingual: false, perPost: false, languages: [] };
	const filtered =
		filter !== '' || type !== 'any' || search !== '' || lang !== '' || tax.term !== '' || status !== '';

	const reset = () => {
		setFilter('');
		setType('any');
		setLang('');
		setSearch('');
		setTax({ tax: '', term: '' });
		setStatus('');
		setSort({ by: 'modified', dir: 'desc' });
		setPage(1);
	};

	return (
		<div className="ars-page">
			{/* The library bar.

			    A flat list sorted by date is right for a five-page site and
			    close to useless on a magazine with 40,000 posts, 300 pages and
			    2,000 products: the products drown the pages, and "are my
			    product pages in worse shape than my posts" cannot be answered
			    by scrolling. One tab per post type, each carrying its own
			    count and its own health, answers it before anything is
			    clicked. */}
			{library && library.filter((t) => t.total > 0).length > 1 && (
				<TypeBar library={library} value={type} onChange={pickType} />
			)}

			{active && (
				<ContentHero
					type={active}
					filter={filter}
					onFilter={(f) => {
						setFilter(filter === f ? '' : f);
						setPage(1);
					}}
					onScoreAll={scoreSiteWide}
					onDescribeAll={describeSiteWide}
					busy={bulkBusy}
					scoreDone={scoreDone}
				/>
			)}

			{selected.size > 0 && (
				<div className="ars-selbar">
					<span className="font-semibold text-[13px]">
						{selected.size} selected
					</span>
					<span className="flex items-center gap-2 flex-wrap">
						{/* Same button, two engines: the paid queue where it
						    exists, the free per-post analyzer where it does
						    not. Scoring is free either way. */}
						<Button
							variant="tonal"
							busy={bulkBusy === 'analyze' || scoring === 'selection'}
							onClick={() => (HAS_BULK
								? bulkSelected('analyze')
								: scoreRows('selection', items.filter((x) => selected.has(x.id))))}
						>{__('Score', 'ai-rank-suite')}</Button>
						{HAS_AI && HAS_BULK && (
							<>
								<Button variant="tonal" busy={bulkBusy === 'describe'} onClick={() => bulkSelected('describe')} title={__('AI writes a meta description for each selected page', 'ai-rank-suite')}>{__('Write descriptions (AI)', 'ai-rank-suite')}</Button>
								<Button variant="tonal" busy={bulkBusy === 'faq'} onClick={() => bulkSelected('faq')} title={__('AI generates FAQs for each selected page', 'ai-rank-suite')}>{__('Generate FAQs (AI)', 'ai-rank-suite')}</Button>
							</>
						)}
						<Button variant="ghost" onClick={() => setSelected(new Set())}>{__('Clear', 'ai-rank-suite')}</Button>
					</span>
					<span className="t-caption">{__('Runs on the Bulk tab, where you can watch it and undo it.', 'ai-rank-suite')}</span>
				</div>
			)}

			{/* Two deliberate rows instead of a ragged wrap: eight facet chips
			    fill the first, and the second is anchored at both ends — type on
			    the left, search and Clear on the right. Before, the search box sat
			    at the left of the second row with ~590px of empty bar beside it. */}
			<FilterBar>
				<div className="w-full flex flex-wrap items-center gap-3">
					{/* One control, everything visible. A chip row plus a dropdown that
					    repeated four of the same chips read as two controls fighting.
					    Eight facets is chip territory, not segmented-control territory —
					    the same chip Site Audit, Sitemap and Links use. */}
					<ChipBar
						options={FILTERS}
						value={filter}
						onChange={(v) => {
							setFilter(v);
							setPage(1);
						}}
						label={__('Show', 'ai-rank-suite')}
					/>
				</div>
				<div className="w-full flex flex-wrap items-center gap-3">
					{i18n.multilingual && !i18n.perPost && (
						<a
							href="#/content/urls"
							className="ars-btn ars-btn-ghost"
							title={__('This site translates by URL, not by post. The filter here selects posts that have a page in that language; the per-language scores live under URLs & languages.', 'ai-rank-suite')}
						>
							{sprintf(__('%d languages → URLs', 'ai-rank-suite'), i18n.languages.length)}
						</a>
					)}
					{i18n.multilingual && (i18n.perPost || i18n.urlLangs) && (
						<select
							value={lang}
							onChange={(e) => {
								setLang(e.target.value);
								setPage(1);
							}}
							style={{ minWidth: '10rem' }}
							aria-label={__('Language', 'ai-rank-suite')}
						>
							<option value="">{__('All languages', 'ai-rank-suite')}</option>
							{i18n.languages.map((l) => (
								<option key={l.code} value={l.code}>
									{l.label}
								</option>
							))}
						</select>
					)}
					{/* Only when the tab bar above is absent — two controls for the
					    same choice is how a screen starts arguing with itself. */}
					{types.length > 1 && !library && (
						<select
							value={type}
							onChange={(e) => pickType(e.target.value)}
							style={{ minWidth: '10.5rem' }}
							aria-label={__('Content type', 'ai-rank-suite')}
						>
							<option value="any">{__('All types', 'ai-rank-suite')}</option>
							{types.map((t) => (
								<option key={t.name} value={t.name}>
									{t.label}
								</option>
							))}
						</select>
					)}
					{(active?.taxonomies || []).map((t) => (
						<select
							key={t.name}
							value={tax.tax === t.name ? tax.term : ''}
							onChange={(e) => {
								setTax(e.target.value ? { tax: t.name, term: e.target.value } : { tax: '', term: '' });
								setPage(1);
							}}
							/* Term names can be arbitrarily long, so this one
							   keeps a ceiling — the browser truncates a select
							   on its own — but a generous one. */
							style={{ minWidth: '10.5rem', maxWidth: 260 }}
							aria-label={t.label}
						>
							<option value="">All {t.label}</option>
							{t.terms.map((term) => (
								<option key={term.slug} value={term.slug}>
									{term.name} ({term.count})
								</option>
							))}
						</select>
					))}
					{active && active.drafts > 0 && (
						<select
							value={status}
							onChange={(e) => {
								setStatus(e.target.value);
								setPage(1);
							}}
							/* No pixel cap. 150px fits "Any status" and clips
							   "Beliebiger Status" to "Beliebiger Statu" — a
							   fixed width around translated text is a bug in
							   every language but the one it was measured in.
							   It sizes to its own content instead. */
							style={{ minWidth: '11rem' }}
							aria-label={__('Status', 'ai-rank-suite')}
						>
							<option value="">{__('Any status', 'ai-rank-suite')}</option>
							<option value="publish">{__('Published', 'ai-rank-suite')}</option>
							<option value="draft">{__('Drafts', 'ai-rank-suite')}</option>
							<option value="pending">{__('Pending', 'ai-rank-suite')}</option>
							<option value="future">{__('Scheduled', 'ai-rank-suite')}</option>
							<option value="private">{__('Private', 'ai-rank-suite')}</option>
						</select>
					)}
					<form
						className="ml-auto"
						onSubmit={(e) => {
							e.preventDefault();
							setPage(1);
							load(1, search, filter, type, sort, lang, tax, status);
						}}
					>
						<input
							type="text"
							placeholder={__('Search pages…', 'ai-rank-suite')}
							value={search}
							onChange={(e) => setSearch(e.target.value)}
							style={{ width: 220 }}
							aria-label={__('Search pages', 'ai-rank-suite')}
						/>
					</form>
					{filtered && (
						<Button variant="ghost" onClick={reset}>{__('Clear', 'ai-rank-suite')}</Button>
					)}
				</div>
			</FilterBar>

			{/*
			 * One box per post type — pages, posts, products, jobs, whatever the
			 * site registers. The mixed table made a product row and a legal page
			 * row look like the same kind of work; separate boxes with their own
			 * counts say where the work is before anything is read. The flat
			 * `items` order is preserved inside each box, so sorting still works
			 * and the drawer's prev/next walks the same sequence as before.
			 */}
			{error && (
				<Card title={__('Content analysis', 'ai-rank-suite')}>
					<p className="m-0" style={{ color: 'var(--ars-serious)' }}>{error}</p>
				</Card>
			)}
			{!items && !error && (
				<Card title={__('Content analysis', 'ai-rank-suite')}>
					<p className="m-0" style={{ color: 'var(--ars-ink-2)' }}>{__('Analyzing content…', 'ai-rank-suite')}</p>
				</Card>
			)}
			{items && items.length === 0 && (
				<Card title={__('Content analysis', 'ai-rank-suite')}>
					<div className="ars-r-lg px-4 py-5 grid gap-2 place-items-start" style={{ background: 'var(--ars-surface-2)' }}>
						<div className="font-medium text-[13px]">{__('Nothing matches this filter', 'ai-rank-suite')}</div>
						<div className="text-[12px]" style={{ color: 'var(--ars-ink-3)' }}>
							{filter === '' ? __('No published content found.', 'ai-rank-suite') : __('That is good news — no page has this problem.', 'ai-rank-suite')}
						</div>
						{filtered && (
							<Button variant="tonal" onClick={reset}>{__('Show all pages', 'ai-rank-suite')}</Button>
						)}
					</div>
				</Card>
			)}
			{items &&
				items.length > 0 &&
				(() => {
					const label = (name) => {
						const t = (library || []).find((x) => x.name === name);
						return t ? t.label : name;
					};
					const byType = new Map();
					items.forEach((it) => {
						if (!byType.has(it.type)) {
							byType.set(it.type, []);
						}
						byType.get(it.type).push(it);
					});
					// Every registered type gets its box, filled or not — a site
					// owner who just installed WooCommerce should see the empty
					// Products box and know where those rows will appear, not
					// wonder whether the plugin noticed the shop at all. Types
					// that only exist in the rows (edge cases) follow.
					const groups = (library || []).map((t) => t.name);
					byType.forEach((_rows, tname) => {
						if (!groups.includes(tname)) {
							groups.push(tname);
						}
					});
					return groups.map((tname) => {
						const rows = byType.get(tname) || [];
						const inLibrary = (library || []).find((x) => x.name === tname);
						// A type with nothing in view on this page is one quiet line,
						// not a table with only a header: the box still says the type
						// exists and where its rows will appear, without the visual
						// weight of an empty grid.
						if (rows.length === 0) {
							return (
								<div key={tname} className="ars-typeempty">
									<b>{label(tname)}</b>
									<span>
										{inLibrary && inLibrary.total > 0
											? sprintf(__('none of its %s items on this page of results', 'ai-rank-suite'), inLibrary.total.toLocaleString())
											: __('nothing published in this type yet', 'ai-rank-suite')}
									</span>
								</div>
							);
						}
						const unscored = rows.filter((it) => it.geo_score === null || it.geo_score === undefined);
						return (
							<Card
								key={tname}
								title={label(tname)}
								subtitle={__('Scored for AI readiness (GEO) and classic search (SEO) — click a row to fix it. The three letters before each title show whether it has an SEO title, a meta description and a focus keyword: green means set, grey means missing.', 'ai-rank-suite')}
								action={
									<span className="flex items-center gap-2">
										{unscored.length > 0 && !loading && (
											<Button
												variant="tonal"
												busy={scoring === tname}
												onClick={() => scoreRows(tname, unscored)}
												title={__('Runs the local analyzer on the unscored rows in view — free, no AI call', 'ai-rank-suite')}
											>
												{sprintf(__('Score %s unscored', 'ai-rank-suite'), unscored.length)}
											</Button>
										)}
										{/* "8 in view" out of 135, on a page grouped by type,
										    with the pager far below every group — so the same
										    eight rows were the whole product for anybody who
										    did not scroll to the bottom. This switches the list
										    to one type, which is the paged view of everything
										    in it. */}
										{!loading && inLibrary && inLibrary.total > rows.length && type === 'any' && (
											<button
												type="button"
												className="ars-linkbtn"
												onClick={() => pickType(tname)}
											>
												{sprintf(__('Show all %s', 'ai-rank-suite'), inLibrary.total.toLocaleString())}
											</button>
										)}
										<span className="text-[12px]" style={{ color: 'var(--ars-ink-3)' }}>
											{loading
												? __('Loading…', 'ai-rank-suite')
												: sprintf(
													/* translators: 1: rows on this page, 2: rows in the type. */
													__('%1$s of %2$s', 'ai-rank-suite'),
													rows.length.toLocaleString(),
													(inLibrary ? inLibrary.total : rows.length).toLocaleString()
												)}
										</span>
									</span>
								}
								flush
							>
								<div className="ars-table-wrap">
									<table className="ars-table">
										<thead>
											<tr>
												<th style={{ width: 34 }}>
													<input
														type="checkbox"
														aria-label={__('Select all in view', 'ai-rank-suite')}
														checked={rows.length > 0 && rows.every((r) => selected.has(r.id))}
														onChange={() => toggleAll(rows)}
													/>
												</th>
												<SortHead id="title" label={label(tname)} sort={sort} onSort={onSort} />
												{i18n.multilingual && i18n.perPost && <th className="is-narrow">{__('Lang', 'ai-rank-suite')}</th>}
												<th className="is-num is-narrow">{__('Words', 'ai-rank-suite')}</th>
												<SortHead id="geo" label={__('GEO', 'ai-rank-suite')} sort={sort} onSort={onSort} align="right" />
												<SortHead id="seo" label={__('SEO', 'ai-rank-suite')} sort={sort} onSort={onSort} align="right" />
												<th className="is-num is-narrow">{__('Issues', 'ai-rank-suite')}</th>
												<SortHead id="modified" label={__('Updated', 'ai-rank-suite')} sort={sort} onSort={onSort} align="right" width="mid" />
											</tr>
										</thead>
										<tbody>
											{rows.map((it) => (
												<tr key={it.id} onClick={() => setOpen(it.id)} style={{ cursor: 'pointer' }} className={selected.has(it.id) ? 'is-selected' : ''}>
													<td onClick={(e) => e.stopPropagation()} style={{ width: 34 }}>
														<input type="checkbox" aria-label={`Select ${it.title}`} checked={selected.has(it.id)} onChange={() => toggleRow(it.id)} />
													</td>
													<td className="font-medium" style={{ maxWidth: 420 }}>
														<span className="inline-flex items-center gap-2 max-w-full">
															{/*
															  * T, D and K meant title, description and keyword —
															  * to us. The tooltip that explained them was English
															  * only, and a tooltip is not an explanation on a
															  * touch screen anyway. The legend above the table
															  * explains them in words for everyone, each letter
															  * carries its own translated title, and the letters
															  * themselves follow the language: T/B/K in German.
															  * Plain __() rather than _x(): the catalogue build
															  * has no context support, so a context string would
															  * ship the English letter untranslated.
															  */}
															<span
																className="ars-signals"
																aria-label={sprintf(
																	/* translators: 1: title state, 2: description state, 3: keyword state. */
																	__('SEO title %1$s, meta description %2$s, focus keyword %3$s', 'ai-rank-suite'),
																	it.has_title ? __('set', 'ai-rank-suite') : __('missing', 'ai-rank-suite'),
																	it.has_desc ? __('set', 'ai-rank-suite') : __('missing', 'ai-rank-suite'),
																	it.has_keyword ? __('set', 'ai-rank-suite') : __('missing', 'ai-rank-suite')
																)}
															>
																<i
																	className={it.has_title ? 'is-on' : ''}
																	title={it.has_title ? __('SEO title: set', 'ai-rank-suite') : __('SEO title: missing', 'ai-rank-suite')}
																>{__('T', 'ai-rank-suite')}</i>
																<i
																	className={it.has_desc ? 'is-on' : ''}
																	title={it.has_desc ? __('Meta description: set', 'ai-rank-suite') : __('Meta description: missing', 'ai-rank-suite')}
																>{__('D', 'ai-rank-suite')}</i>
																<i
																	className={it.has_keyword ? 'is-on' : ''}
																	title={it.has_keyword ? __('Focus keyword: set', 'ai-rank-suite') : __('Focus keyword: missing', 'ai-rank-suite')}
																>{__('K', 'ai-rank-suite')}</i>
															</span>
															{/*
															  * The page's own title identifies the row; the
															  * SEO title is what a search engine prints. They
															  * are usually the same, and when they are not, the
															  * second one is the thing that was just edited — so
															  * showing only the first made a saved change look
															  * like a change that had not saved.
															  */}
															<span className="truncate">
																{it.title}
																{it.seo_title && it.seo_title !== it.title && (
																	<em
																		className="is-muted"
																		title={__('The SEO title, which is what search engines show', 'ai-rank-suite')}
																		style={{ fontStyle: 'normal', marginLeft: 8, opacity: 0.75 }}
																	>
																		&rarr; {it.seo_title}
																	</em>
																)}
															</span>
															{it.status && it.status !== 'publish' && (
																<span
																	className="ars-tag shrink-0"
																	style={{
																		color: 'var(--ars-c-yellow-ink)',
																		background: 'var(--ars-c-yellow)',
																	}}
																>
																	{it.status}
																</span>
															)}
														</span>
													</td>
													{i18n.multilingual && i18n.perPost && (
														<td className="is-muted uppercase">{it.lang || '—'}</td>
													)}
													<td className="is-num">{it.words ? it.words.toLocaleString() : <span className="is-muted">—</span>}</td>
													<td className="is-num">
														<ScorePill value={it.geo_score} />
													</td>
													<td className="is-num">
														<ScorePill value={it.seo_score} />
													</td>
													<td className="is-num">
														{it.geo_score === null || it.geo_score === undefined ? (
															<span className="ars-tag ars-tag-quiet" title={__('Not analysed yet — open the row or press “Score unscored”', 'ai-rank-suite')}>
																{__('unscored', 'ai-rank-suite')}
															</span>
														) : (
															it.issues
														)}
													</td>
													<td className="is-num is-muted">
														<span className="ars-rowmeta">
															<span className="ars-rowacts" onClick={(e) => e.stopPropagation()}>
																{it.edit_url && (
																	<a href={it.edit_url} target="_blank" rel="noreferrer" title={__('Edit in WordPress', 'ai-rank-suite')}>{__('Edit', 'ai-rank-suite')}</a>
																)}
																{it.url && (
																	<a href={it.url} target="_blank" rel="noreferrer" title={__('View the page', 'ai-rank-suite')}>{__('View', 'ai-rank-suite')}</a>
																)}
															</span>
															<span className="ars-rowmeta-time">{it.modified_human || '—'}</span>
														</span>
													</td>
												</tr>
											))}
										</tbody>
									</table>
								</div>
							</Card>
						);
					});
				})()}
			{/* Shown whenever there is more than one page — not only when the
			    total happens to exceed the default row count. */}
			{items && items.length > 0 && (meta.pages > 1) && (
				<div className="flex items-center justify-between flex-wrap gap-2">
					<span className="flex items-center gap-3 text-[12px]" style={{ color: 'var(--ars-ink-3)' }}>
						<span>
							{sprintf(
								/* translators: 1: current page, 2: last page, 3: total rows. */
								__('Page %1$s of %2$s · %3$s entries', 'ai-rank-suite'),
								page.toLocaleString(),
								Math.max(1, meta.pages || 1).toLocaleString(),
								(meta.total || 0).toLocaleString()
							)}
						</span>
						<label className="flex items-center gap-1.5">{__('Rows', 'ai-rank-suite')}<select
								className="ars-select"
								value={perPage}
								onChange={(e) => {
									setPerPage(Number(e.target.value));
									setPage(1);
								}}
							>
								{[20, 50, 100].map((n) => (
									<option key={n} value={n}>
										{n}
									</option>
								))}
							</select>
						</label>
					</span>
					<div className="flex gap-2">
						<Button variant="ghost" disabled={page <= 1} onClick={() => setPage(page - 1)}>
							{__('← Previous', 'ai-rank-suite')}
						</Button>
						<Button variant="ghost" disabled={page >= meta.pages} onClick={() => setPage(page + 1)}>{__('Next →', 'ai-rank-suite')}</Button>
					</div>
				</div>
			)}
			{open && (
				<Drawer
					id={open}
					onClose={() => setOpen(null)}
					position={(() => {
						const index = (items || []).findIndex((it) => it.id === open);
						return index < 0 ? null : { index, total: (items || []).length };
					})()}
					onNavigate={(index) => {
						const next = (items || [])[index];
						if (next) setOpen(next.id);
					}}
					onChanged={(next) => setItems((list) => list.map((it) => (it.id === next.id ? { ...it, ...next } : it)))}
				/>
			)}
		</div>
	);
}

/**
 * One tab per post type, with its own count.
 *
 * The counts are the point. A tab row without them is navigation; with them it
 * is a report — "3,412 products, 300 pages" tells you where the work is before
 * you have clicked anything.
 */
function TypeBar({ library, value, onChange }) {
	const shown = library.filter((t) => t.total > 0);
	const all = shown.reduce((a, t) => a + t.total, 0);

	return (
		<div className="ars-typebar" role="tablist" aria-label={__('Content type', 'ai-rank-suite')}>
			<button
				type="button"
				role="tab"
				aria-selected={value === 'any'}
				className={`ars-typetab${value === 'any' ? ' is-active' : ''}`}
				onClick={() => onChange('any')}
			>
				<span className="ars-typetab-label">{__('Everything', 'ai-rank-suite')}</span>
				<span className="ars-typetab-count tabular-nums">{all.toLocaleString()}</span>
			</button>
			{shown.map((t) => (
				<button
					key={t.name}
					type="button"
					role="tab"
					aria-selected={value === t.name}
					className={`ars-typetab${value === t.name ? ' is-active' : ''}`}
					onClick={() => onChange(t.name)}
					title={sprintf(__('%1$s: %2$s items', 'ai-rank-suite'), t.label, t.total.toLocaleString())}
				>
					<span className="ars-typetab-label">{t.label}</span>
					<span className="ars-typetab-count tabular-nums">{t.total.toLocaleString()}</span>
					{t.no_desc > 0 && <span className="ars-typetab-dot" aria-hidden />}
				</button>
			))}
		</div>
	);
}

/**
 * The state of the selected type, as four numbers you can click.
 *
 * Each figure is also the filter that shows you those exact rows — a count
 * that cannot be turned into a list is trivia.
 */
/**
 * The state of the selected type as the same hero every other screen opens
 * with: the average AI score as the ring, one sentence, the two bulk
 * actions that move it, and the four gaps as tiles that filter the list.
 * On a magazine with six thousand posts this is the sentence the editor
 * reads before deciding what the afternoon is for.
 */
function ContentHero({ type, filter, onFilter, onScoreAll, onDescribeAll, busy, scoreDone }) {
	if (type.total === 0) return null;
	const pct = (n) => Math.round((n / Math.max(1, type.total)) * 100);
	const scored = Math.max(0, type.total - (type.unanalyzed || 0));
	const descPct = pct(type.total - type.no_desc);
	const avg = type.avg_geo;
	const tone = avg === null ? 'neutral' : avg >= 80 ? 'good' : avg >= 50 ? 'warning' : 'bad';
	const badge = avg === null ? __('Not scored yet', 'ai-rank-suite') : avg >= 80 ? __('Healthy', 'ai-rank-suite') : avg >= 50 ? __('Room to improve', 'ai-rank-suite') : __('Needs attention', 'ai-rank-suite');
	// The label as the post type declares it. Lowercasing reads naturally in
	// English — "161 pages" — and is a spelling mistake in German, where every
	// noun is capitalised. It was the last of six.
	const label = type.name === 'any' ? __('items', 'ai-rank-suite') : type.label;

	// Say which population the number is: this list holds every status, the
	// dashboard's "Pages scored" holds published only — both must be readable
	// as the same site. "161 items (158 published, 3 drafts)".
	const drafts = Math.max(0, type.total - (type.published ?? type.total));
	const pop = drafts > 0
		? sprintf(
				/* translators: 1: published count, 2: draft count. */
				__(' (%1$s published, %2$s drafts)', 'ai-rank-suite'),
				(type.published ?? 0).toLocaleString(),
				drafts.toLocaleString()
			)
		: '';
	const title =
		avg === null
			? sprintf(
					/* translators: 1: count, 2: item label, 3: population note. */
					__('%1$s %2$s%3$s, none scored yet.', 'ai-rank-suite'),
					type.total.toLocaleString(),
					label,
					pop
				)
			: sprintf(
					/* translators: 1: scored, 2: total, 3: item label, 4: population note, 5: GEO, 6: ", SEO n" or empty. */
					__('%1$s of %2$s %3$s%4$s scored — average GEO %5$d%6$s.', 'ai-rank-suite'),
					scored.toLocaleString(),
					type.total.toLocaleString(),
					label,
					pop,
					avg,
					type.avg_seo !== null ? sprintf(__(', SEO %d', 'ai-rank-suite'), type.avg_seo) : ''
				);
	const why =
		type.no_desc > 0
			? sprintf(
					/*
					 * Two counts, not a percentage beside a count.
					 *
					 * "1 % have a meta description; 160 do not" asked the
					 * reader to hold a share and an absolute in their head at
					 * once and work out the total themselves — and on a small
					 * type it rounded to a number that looked wrong. Both
					 * halves are counts now, and the total is stated.
					 */
					/* translators: 1: pages with a description, 2: pages without one, 3: optional weak-GEO sentence. */
					__('%1$s of these pages have a meta description, %2$s do not — the cheapest gap to close, and the one an assistant quotes first. %3$s', 'ai-rank-suite'),
					Math.max(0, (type.total || 0) - (type.no_desc || 0)).toLocaleString(),
					type.no_desc.toLocaleString(),
					type.weak_geo
						? sprintf(__('%s score weak on AI readiness.', 'ai-rank-suite'), type.weak_geo.toLocaleString())
						: ''
				)
			: type.weak_geo > 0
				? sprintf(
						__('Every page has a description. %s still score weak on AI readiness — the list below, worst first, is the work.', 'ai-rank-suite'),
						type.weak_geo.toLocaleString()
					)
				: __('Descriptions filled, nothing weak: keep scoring after each publishing sprint so the numbers stay true.', 'ai-rank-suite');

	return (
		<div className="ars-hero">
			<div className="ars-hero-ring">
				<ScoreRing value={avg} size={132} label={__('Avg GEO', 'ai-rank-suite')} onLight />
			</div>
			<div className="ars-hero-body">
				<span className={`ars-hero-badge is-${tone}`}>{badge}</span>
				<h2 className="ars-hero-title">{title}</h2>
				<p className="ars-hero-why">{why}</p>
				<div className="ars-hero-actions">
					{(type.unanalyzed || 0) > 0 && (
						<button type="button" className="ars-btn ars-btn-primary" onClick={onScoreAll} disabled={busy === 'site'} title={__('The local analyzer. Free, and it runs on this site — nothing is sent anywhere.', 'ai-rank-suite')}>
							{busy === 'site'
								? sprintf(__('Scoring… %s done', 'ai-rank-suite'), (scoreDone || 0).toLocaleString())
								: sprintf(__('Score %s unscored', 'ai-rank-suite'), type.unanalyzed.toLocaleString())}
						</button>
					)}
					{/*
					  * The count and the run finally agree.
					  *
					  * The button offered every gap and the run behind it only
					  * touched published posts, so a site whose three gaps were
					  * three drafts saw "Write 3 descriptions (AI)" and then
					  * "nothing to do" on the screen it opened. The first fix
					  * shrank the offer to match; this one widened the run
					  * instead, which is what the reader actually wanted — the
					  * three gaps written, not explained.
					  */}
					{type.no_desc > 0 && HAS_AI && HAS_BULK && (
						<button type="button" className={`ars-btn ${(type.unanalyzed || 0) > 0 ? 'ars-btn-ghost' : 'ars-btn-primary'}`} onClick={onDescribeAll} disabled={busy === 'describe-all'} title={__('AI writes a description for every page without one — reviewable and undoable on the Bulk tab', 'ai-rank-suite')}>
							{busy === 'describe-all' ? __('Queuing…', 'ai-rank-suite') : sprintf(__('Write %s descriptions (AI)', 'ai-rank-suite'), type.no_desc.toLocaleString())}
						</button>
					)}
					<span className="t-caption">
						{sprintf(__('%s published', 'ai-rank-suite'), type.published.toLocaleString())}
						{type.drafts > 0 ? ' · ' + sprintf(_n('%s draft', '%s drafts', type.drafts, 'ai-rank-suite'), type.drafts.toLocaleString()) : ''}
					</span>
				</div>
			</div>
			<div className="ars-hero-stats">
				{[
					{ key: 'no_desc', label: __('No description', 'ai-rank-suite'), value: type.no_desc, tone: type.no_desc ? 'bad' : 'good' },
					{ key: 'no_keyword', label: __('No keyword', 'ai-rank-suite'), value: type.no_keyword, tone: type.no_keyword ? 'warn' : 'good' },
					{ key: 'weak_geo', label: __('Weak GEO', 'ai-rank-suite'), value: type.weak_geo, tone: type.weak_geo ? 'warn' : 'good' },
					{ key: 'noindex', label: __('Noindex', 'ai-rank-suite'), value: type.noindex },
				].map((st) => (
					<button
						key={st.key}
						type="button"
						className={`ars-hero-stat${filter === st.key ? ' is-active' : ''}`}
						onClick={() => onFilter(st.key)}
						title={filter === st.key ? __('Show all', 'ai-rank-suite') : sprintf(__('Show only: %s', 'ai-rank-suite'), st.label)}
					>
						<span className={`ars-hero-stat-v${st.tone ? ' is-' + st.tone : ''}`}>{st.value.toLocaleString()}</span>
						<span className="ars-hero-stat-l">{st.label}</span>
					</button>
				))}
			</div>
		</div>
	);
}

function HealthFig({ value, label, tone, onClick }) {
	return (
		<button
			type="button"
			className={`ars-healthfig${value > 0 && tone ? ` is-${tone}` : ''}`}
			onClick={onClick}
			disabled={value === 0}
		>
			<b className="tabular-nums">{value.toLocaleString()}</b>
			<span>{label}</span>
		</button>
	);
}
