/**
 * Sync component — Reconciliation & Sync screen.
 *
 * Unified screen that reconciles WordPress ↔ Sideconvo state
 * and processes batch sync operations.
 *
 * @package Sideconvo
 */

import { useState, useEffect, useMemo, useRef } from '@wordpress/element';
import { useNavigate } from 'react-router-dom';
import {
	Button,
	Input,
	PageHeader,
	SelectFilter,
	StatusCard,
	SyncTableRow,
	IconSearch,
	IconSync,
	IconLoading,
	IconWordpress,
	IconSideconvo,
	IconArrowDown,
	IconCheck,
	AlertBar,
} from '../ui';
import type { SyncStatus, StepItem, StepStatus, AlertBarVariant } from '../ui';
import type { MenuSelectItem } from '../ui';
import { useSettings } from '../../contexts/SettingsContext';
import styles from './Sync.module.scss';

// ── Types ──────────────────────────────────────────

interface ContentItem {
	id: number;
	title: string;
	type: string;
	status: string;
	url: string;
	error?: string;
	sync_id?: number | null;
	last_synced_at?: string | null;
	expires_at?: string | null;
}

interface EnrichedItem extends ContentItem {
	syncStatus: SyncStatus;
}

type PageStatus = 'idle' | 'reconciling' | 'syncing' | 'complete' | 'error';

interface ProgressState {
	queued: number;
	indexed: number;
	failed: number;
	excluded: number;
	total: number;
	scope_count: number;
	percent: number;
}

interface ReconciliationReport {
	timestamp: string;
	summary: {
		total_wordpress: number;
		total_pinecone: number;
		orphaned: number;
		missing: number;
		stale: number;
		in_sync: number;
		invalid_ids: number;
	};
	orphaned_items: Array<{
		id: string;
		object_type: string;
		pinecone_ids: string[];
		title: string;
		lastUpdated: string;
		wordpress_exists: boolean;
	}>;
	invalid_id_items: Array<{
		id: string;
		title: string;
	}>;
	missing_items: Array<{
		id: number;
		title: string;
		type: string;
		last_synced_at: string;
		status: string;
	}>;
	stale_items: Array<{
		id: number;
		title: string;
		type: string;
		wp_modified: string;
		pinecone_updated: string;
	}>;
}

// ── Helpers ────────────────────────────────────────

/** Colored dot icon for status cards */
const StatusDot = ({ color }: { color: string }) => (
	<span
		style={{
			display: 'inline-block',
			width: 8,
			height: 8,
			borderRadius: '50%',
			backgroundColor: color,
		}}
	/>
);

/** Format a date string for display */
const formatDate = (dateStr: string | null | undefined): string => {
	if (!dateStr) return '—';
	try {
		// DB stores datetimes in UTC (current_time('mysql') on a UTC-configured WP).
		// Appending 'Z' tells the JS Date parser to treat the string as UTC so the
		// result is displayed in the browser's local timezone.
		const utcStr = dateStr.includes('T') ? dateStr : dateStr.replace(' ', 'T') + 'Z';
		const d = new Date(utcStr);
		if (isNaN(d.getTime())) return '—';
		const day = d.getDate();
		const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
		const month = months[d.getMonth()];
		const year = d.getFullYear();
		let hours = d.getHours();
		const minutes = d.getMinutes().toString().padStart(2, '0');
		const ampm = hours >= 12 ? 'PM' : 'AM';
		hours = hours % 12 || 12;
		return `${day} ${month} ${year} ${hours}:${minutes} ${ampm}`;
	} catch {
		return '—';
	}
};

/** Filter options for the status dropdown */
const FILTER_ITEMS: MenuSelectItem[] = [
	{ value: 'all', label: 'Show All' },
	{ value: 'indexed', label: 'Indexed' },
	{ value: 'missing', label: 'Missing' },
	{ value: 'stale', label: 'Stale' },
	{ value: 'orphaned', label: 'Orphaned' },
	{ value: 'expired', label: 'Expired' },
	{ value: 'failed', label: 'Failed' },
];

/** Search mode options for the advanced search dropdown */
const SEARCH_MODE_ITEMS: MenuSelectItem[] = [
	{ value: 'keyword', label: 'Keyword Search' },
	{ value: 'url', label: 'URL Pattern' },
	{ value: 'regex', label: 'Regular Expression' },
];

// ── Component ──────────────────────────────────────

export default function Sync() {
	const { showNotification } = useSettings();
	const navigate = useNavigate();

	// Data
	const [items, setItems] = useState<ContentItem[]>([]);
	const [isLoading, setIsLoading] = useState(true);
	const [total, setTotal] = useState(0);
	const [currentPage, setCurrentPage] = useState(1);
	const [totalPages, setTotalPages] = useState(1);

	// First-run welcome state: true when the local WordPress DB has never indexed
	// anything. Initialised synchronously from the PHP-localized indexedCount so
	// the banner renders on the first paint and the auto-sync is blocked before
	// any async fetch completes.  wp_localize_script sends integers as strings,
	// so coerce with Number() before comparing.
	const [isFirstRun, setIsFirstRun] = useState(() => {
		const phpCount = Number( (window as any).sideconvoData?.indexedCount ?? 0 );
		if (phpCount > 0) return false;
		// Also check localStorage cache — set by fetchStats whenever indexed > 0.
		// This survives component remounts within the same browser session, preventing
		// a stale PHP-localized zero from re-triggering the first-run sync after the
		// initial sync completes and the user navigates away and back.
		const cached = localStorage.getItem('sideconvo_indexed_count');
		if (cached && parseInt(cached, 10) > 0) return false;
		// If items are already queued (mid-sync from a previous session), resume the
		// existing queue rather than resetting everything via initialize_indexing().
		const phpQueuedCount = Number( (window as any).sideconvoData?.queuedCount ?? 0 );
		if (phpQueuedCount > 0) return false;
		// If scope is empty there is nothing to sync — don't treat this as first run.
		// Without this check, removing all content types triggers the first-run
		// auto-start timer (start-indexing with nothing to index).
		const phpScopeCount = Number( (window as any).sideconvoData?.scopeCount ?? 0 );
		if (phpScopeCount === 0) return false;
		return true;
	});

	// Seed "Pages Indexed" from localStorage so the card shows immediately on mount.
	const [cachedIndexedCount] = useState<number>(() => {
		const cached = localStorage.getItem('sideconvo_indexed_count');
		return cached !== null ? parseInt(cached, 10) : 0;
	});

	const phpScopeCount = Number((window as any).sideconvoData?.scopeCount ?? 0);
	// Seed "Pages in Scope" from localStorage so it renders immediately on mount.
	// Invalidated when content types change; otherwise shows the last known count.
	const [cachedScopeCount] = useState<number | undefined>(() => {
		const cached = localStorage.getItem('sideconvo_scope_count');
		if (cached !== null) return parseInt(cached, 10);
		// Fall back to PHP-localized value only on first ever load.
		return phpScopeCount > 0 ? phpScopeCount : undefined;
	});

	// True once the content-types check has resolved (prevents fetchItems from
	// firing before content-types sets the default selected post types in the DB).
	const [isContentTypesReady, setIsContentTypesReady] = useState(false);

	// Prevent multiple auto-sync triggers on mount
	const hasAutoSynced = useRef(false);
	// Prevent multiple background reconciliation refreshes when everything is already synced
	const hasAutoRefreshedReconciliation = useRef(false);
	// Prevent auto-deleting orphaned items more than once per page load
	const hasAutoDeletedOrphans = useRef(false);
	// Always holds the latest fetchItems so the polling interval avoids a stale closure.
	const fetchItemsRef = useRef<() => void>(() => {});
	// Always holds the latest fetchStats for the same reason.
	const fetchStatsRef = useRef<() => void>(() => {});

	// Prevent concurrent processBatch calls from the polling interval
	const isProcessingBatch = useRef(false);
	// Prevent concurrent reconciliation/start calls
	const isReconciling = useRef(false);

	// Timer reference for the rate-limit pause — cleared on unmount or new 429.
	const rateLimitTimerRef = useRef<number | null>(null);

	// Timestamp (ms) of the last completed sync. Used to suppress stale Pinecone
	// reconciliation reports for 5 minutes after a sync, since Pinecone catches
	// up asynchronously and would otherwise re-show items as "missing".
	const recentlySyncedAt = useRef<number | null>(
		(() => {
			try { const s = localStorage.getItem('sideconvo_recently_synced_at'); return s ? Number(s) : null; }
			catch { return null; }
		})()
	);

	// True once the first fetchStats call completes — lets us distinguish
	// "genuinely zero scope" from "not yet loaded" when rendering Pages in Scope.
	const [statsLoaded, setStatsLoaded] = useState(false);

	// Track which row is currently being acted on (by sync_id) for per-row loading state
	const [loadingRowId, setLoadingRowId] = useState<number | null>(null);

	// Track loading state for orphaned item deletions (by orphaned item string id)
	const [loadingOrphanedId, setLoadingOrphanedId] = useState<string | null>(null);
	const [isDeletingAllOrphaned, setIsDeletingAllOrphaned] = useState(false);

	// Progress / sync
	// Initialise from PHP-localized queuedCount so the button reflects reality on
	// the very first paint — no need to wait for the first fetchStats() round-trip.
	const [pageStatus, setPageStatus] = useState<PageStatus>(() => {
		const phpQueuedCount = Number( (window as any).sideconvoData?.queuedCount ?? 0 );
		return phpQueuedCount > 0 ? 'syncing' : 'idle';
	});
	const [errorMessage, setErrorMessage] = useState<string | null>(null);
	const [progress, setProgress] = useState<ProgressState>({
		queued: 0,
		indexed: 0,
		failed: 0,
		excluded: 0,
		total: 0,
		scope_count: 0,
		percent: 0,
	});

	// Batch-level progress counters from process-batch responses.
	// Tracks items attempted (not just successfully indexed), so the banner
	// shows real progress even when items are failing.
	const [batchProgress, setBatchProgress] = useState({ processed: 0, total: 0, failed: 0 });

	// Reconciliation — seeded from localStorage so cards render immediately on mount.
	const [reconciliationReport, setReconciliationReport] = useState<ReconciliationReport | null>(() => {
		try {
			const cached = localStorage.getItem('sideconvo_reconciliation_report');
			return cached ? (JSON.parse(cached) as ReconciliationReport) : null;
		} catch {
			return null;
		}
	});

	/** Persist report to state + localStorage in one call.
	 *
	 * Guard: if a sync completed recently (< 5 min ago), Pinecone may not have
	 * caught up yet via its async cloud trigger. In that case we merge the
	 * incoming report but keep missing/stale at 0 so the optimistic clear from
	 * processBatch() is not overwritten in localStorage, preventing the "33
	 * missing" flash on the next page load.
	 */
	const setAndCacheReport = (report: ReconciliationReport) => {
		let reportToCache = report;
		if (
			recentlySyncedAt.current !== null &&
			Date.now() - recentlySyncedAt.current < 5 * 60 * 1000 &&
			(report.summary.missing > 0 || report.summary.stale > 0)
		) {
			// Merge: keep Pinecone/orphaned counts but zero out missing/stale
			// so the stale Pinecone read doesn't undo the optimistic clear.
			reportToCache = {
				...report,
				summary: { ...report.summary, missing: 0, stale: 0 },
				missing_items: [],
				stale_items: [],
			};
		}
		setReconciliationReport(reportToCache);
		try {
			localStorage.setItem('sideconvo_reconciliation_report', JSON.stringify(reportToCache));
		} catch {
			// Storage quota exceeded — silently skip
		}
	};

	// UI: search & filter
	const [searchQuery, setSearchQuery] = useState('');
	const [debouncedSearch, setDebouncedSearch] = useState('');
	const [searchMode, setSearchMode] = useState<'keyword' | 'url' | 'regex'>('keyword');
	const [regexError, setRegexError] = useState<string | null>(null);
	const [searchModeOpen, setSearchModeOpen] = useState(false);
	const [statusFilter, setStatusFilter] = useState<string | null>(null);
	const [filterOpen, setFilterOpen] = useState(false);
	const [contentTypeFilter, setContentTypeFilter] = useState<string | null>(null);
	const [contentTypeFilterOpen, setContentTypeFilterOpen] = useState(false);
	const [contentTypeItems, setContentTypeItems] = useState<MenuSelectItem[]>([]);
	const perPage = 20;

	// ── Effects ────────────────────────────────

	// On mount: check content types, then load the cached reconciliation report.
	// Setting isContentTypesReady ensures fetchItems waits until content-types
	// has run (which saves the default selected post types to the DB).
	useEffect(() => {
		fetch(
			`${(window as any).sideconvoData.restUrl}sideconvo/v1/content-types`,
			{ headers: { 'X-WP-Nonce': (window as any).sideconvoData.nonce } }
		)
			.then((r) => r.json())
			.then((data) => {
				// Defaults (post + page) are applied server-side on first run —
				// no redirect needed even if the user has never saved content types.
				const postTypes: Array<{ name: string; label: string }> = data.postTypes ?? [];
				const taxonomies: Array<{ name: string; label: string }> = data.taxonomies ?? [];
				const selectedPostTypes: string[] = data.selectedPostTypes ?? [];
				const selectedTaxonomies: string[] = data.selectedTaxonomies ?? [];

				const extraPostTypes: Array<{ name: string; label: string }> = data.extraPostTypes ?? [];

				const items: MenuSelectItem[] = [{ value: 'all', label: 'All Types' }];
				postTypes
					.filter((pt) => selectedPostTypes.includes(pt.name))
					.forEach((pt) => items.push({ value: pt.name, label: pt.label }));
				taxonomies
					.filter((tax) => selectedTaxonomies.includes(tax.name))
					.forEach((tax) => items.push({ value: tax.name, label: tax.label }));
				// Extra types: manually-synced posts from post types not in the selection.
				extraPostTypes.forEach((pt) => {
					if (!items.find((i) => i.value === pt.name)) {
						items.push({ value: pt.name, label: pt.label });
					}
				});

				setContentTypeItems(items);
				setIsContentTypesReady(true);
				loadCachedReport();
			})
			.catch(() => {
				setIsContentTypesReady(true);
				loadCachedReport();
			});
	}, []); // eslint-disable-line react-hooks/exhaustive-deps

	// Debounce searchQuery for server-side fetching (300ms).
	// In regex mode, validate first and skip the request if the expression is invalid.
	useEffect(() => {
		const timer = setTimeout(() => {
			if (searchMode === 'regex' && searchQuery) {
				try {
					new RegExp(searchQuery);
					setRegexError(null);
				} catch (e) {
					setRegexError(e instanceof Error ? e.message : 'Invalid regular expression');
					return;
				}
			}
			setDebouncedSearch(searchQuery);
			setCurrentPage(1);
		}, 300);
		return () => clearTimeout(timer);
	}, [searchQuery, searchMode]); // eslint-disable-line react-hooks/exhaustive-deps

	// Fetch items + stats after content-types check has resolved.
	// fetchStats() is called here so progress counters (scope, queued, indexed)
	// are populated on mount — they are no longer bundled inside fetchItems().
	useEffect(() => {
		if (!isContentTypesReady) return;
		fetchItems();
		fetchStats();
	}, [currentPage, statusFilter, contentTypeFilter, isContentTypesReady, debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps

	// Refresh stats + items when a background content-type deletion finishes.
	// Also clear the stale reconciliation report so cards don't show pre-deletion
	// Pinecone counts (e.g. 703) when the actual post-deletion state is 0.
	useEffect(() => {
		const handler = () => {
			fetchStatsRef.current();
			fetchItemsRef.current();
			setReconciliationReport(null);
			try { localStorage.removeItem('sideconvo_reconciliation_report'); } catch { /* non-critical */ }
		};
		window.addEventListener('sideconvo:content-types-deleted', handler);
		return () => window.removeEventListener('sideconvo:content-types-deleted', handler);
	}, []);

	// When a background sync triggered from Content Types completes, refresh
	// stats and items so the table reflects the newly indexed content.
	useEffect(() => {
		const handler = () => {
			hasAutoRefreshedReconciliation.current = false;
			fetchStatsRef.current();
			fetchItemsRef.current();
		};
		window.addEventListener('sideconvo:content-types-synced', handler);
		return () => window.removeEventListener('sideconvo:content-types-synced', handler);
	}, []);

	// When new content types are added (and queued) from the Content Types page,
	// refresh stats so the auto-sync effect detects progress.queued > 0 and starts.
	useEffect(() => {
		const handler = () => {
			hasAutoSynced.current = false;
			try { } catch { /* non-critical */ }
			fetchStatsRef.current();
			fetchItemsRef.current();
		};
		window.addEventListener('sideconvo:content-types-added', handler);
		return () => window.removeEventListener('sideconvo:content-types-added', handler);
	}, []);

	// Polling — only active when syncing is running
	useEffect(() => {
		let interval: number | null = null;

		if (pageStatus === 'syncing') {
			// Kick off the first batch immediately so the user isn't waiting 5 seconds
			// for the first polling tick — especially important when pageStatus was
			// already 'syncing' on mount (phpQueuedCount > 0).
			processBatch();
			interval = window.setInterval(() => {
				// Lightweight stats-only poll — keeps progress bar current without
				// re-fetching the full items table on every tick.
				fetchStatsRef.current();
				processBatch();
			}, 5000);
		}

		return () => {
			if (interval) clearInterval(interval);
			// Clear any pending rate-limit resume timer when polling stops.
			if (rateLimitTimerRef.current) {
				clearTimeout(rateLimitTimerRef.current);
				rateLimitTimerRef.current = null;
			}
		};
	}, [pageStatus]); // eslint-disable-line react-hooks/exhaustive-deps

	// Auto-start first-run sync after a brief pause so the user sees the welcome banner.
	useEffect(() => {
		if (!isFirstRun) return;
		const timer = setTimeout(() => {
			handleFirstRunSync();
		}, 2500);
		return () => clearTimeout(timer);
	}, [isFirstRun]); // eslint-disable-line react-hooks/exhaustive-deps

	// Cancel isFirstRun if live stats confirm scope is genuinely empty.
	// The initializer uses PHP-localized data (stale after content-type changes
	// within the same page session); this effect corrects it once real data arrives.
	useEffect(() => {
		if (!isFirstRun || !statsLoaded || progress.scope_count > 0) return;
		setIsFirstRun(false);
	}, [isFirstRun, statsLoaded, progress.scope_count]); // eslint-disable-line react-hooks/exhaustive-deps

	// Auto-start sync once on mount when there are untracked or queued items.
	// not_synced_count = posts in scope that have no sync_state record yet.
	// When everything appears synced but we have no reconciliation report (e.g. the
	// 5-min transient expired), run a background reconciliation to populate
	// Missing/Stale/Orphaned counts. This is safe because it only runs when no sync
	// is needed, so it never races with runReconcileAndSync().
	useEffect(() => {
		if (hasAutoSynced.current || isLoading || pageStatus !== 'idle' || isFirstRun) return;

		const notSyncedCount = Math.max(
			0,
			progress.scope_count - progress.indexed - progress.queued - progress.failed - progress.excluded
		);

		if (progress.queued > 0) {
			// Items already queued in the local DB — process them directly without
			// reconciliation (reconciliation compares against Pinecone and may
			// return missing=0 if those items are already there, causing the queue
			// to be silently skipped).
			hasAutoSynced.current = true;
			setPageStatus('syncing');
			processBatch();
		} else if (progress.failed > 0 && progress.scope_count > 0) {
			// Re-queue failed items and process them — only when scope is non-empty.
			hasAutoSynced.current = true;
			retryFailedItems();
		} else if (progress.scope_count === 0 && progress.failed > 0) {
			// Scope is empty but failed records remain — delete them all.
			// retry-failed with scope=0 removes all failed records server-side
			// without re-queuing anything; retryFailedItems() then calls fetchStats()
			// to clear the Failed count on screen.
			hasAutoSynced.current = true;
			retryFailedItems();
		} else if (notSyncedCount > 0) {
			// Items exist in WP scope but have no sync record yet — run
			// reconciliation to identify and queue what needs syncing.
			hasAutoSynced.current = true;
			runReconcileAndSync();
		} else if (progress.scope_count === 0 && !hasAutoRefreshedReconciliation.current) {
			// Scope is empty — run reconciliation to detect any items still in Pinecone
			// (e.g. all content types were removed; previously-indexed items become orphaned).
			hasAutoRefreshedReconciliation.current = true;
			refreshReconciliationReport();
		} else if (progress.scope_count > 0 && !hasAutoRefreshedReconciliation.current) {
			// No actionable items — refresh reconciliation to detect drift
			// (orphaned items, new missing pages, etc.).
			// Only run when there's no report or the cached one is older than 5 minutes.
			const reportAge = reconciliationReport?.timestamp
				? (Date.now() - new Date(reconciliationReport.timestamp).getTime()) / 1000
				: Infinity;
			if (reconciliationReport === null || reportAge > 300) {
				hasAutoRefreshedReconciliation.current = true;
				refreshReconciliationReport();
			}
		}
	}, [progress, isLoading, isFirstRun, reconciliationReport]); // eslint-disable-line react-hooks/exhaustive-deps

	// When scope is empty and reconciliation reveals orphaned Pinecone items,
	// auto-delete them — no user action needed; the namespace should be clean.
	useEffect(() => {
		if (progress.scope_count !== 0) return;
		if (hasAutoDeletedOrphans.current) return;
		const orphanCount = reconciliationReport?.summary?.orphaned ?? 0;
		if (orphanCount === 0) return;
		hasAutoDeletedOrphans.current = true;
		handleDeleteAllOrphaned();
	}, [reconciliationReport, progress.scope_count]); // eslint-disable-line react-hooks/exhaustive-deps

	// ── API Functions (preserved) ──────────────────

	/** Load cached reconciliation report without triggering a new sync.
	 *  Shows localStorage-cached data immediately (via useState initializer),
	 *  then always fetches from server to ensure fresh data.
	 */
	const loadCachedReport = async () => {
		try {
			// Always fetch fresh data from server — the localStorage value was
			// already seeded into state by the useState initializer so the page
			// renders immediately without waiting for this fetch.
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/reconciliation/status`,
				{ headers: { 'X-WP-Nonce': (window as any).sideconvoData.nonce } }
			);
			if (response.ok) {
				const data = await response.json();
				if (data.success && data.report) {
					setAndCacheReport(data.report);
					// Only treat as first-run when BOTH the cached Pinecone count is zero
				// AND the PHP-localized indexedCount (live DB count) is also zero.
				// Using the cached report alone is unsafe: a stale report from a
				// brief Pinecone outage / namespace reset would trigger a full
				// re-index on every page load even when items are already indexed.
				const { total_pinecone } = data.report.summary;
				const phpIndexedCount = Number((window as any).sideconvoData?.indexedCount ?? 0);
				// Also guard against items mid-sync: if queuedCount > 0, items are
				// already in progress — resuming the queue is correct, not a full reset.
				const phpQueuedCount = Number((window as any).sideconvoData?.queuedCount ?? 0);
				if (total_pinecone === 0 && phpIndexedCount === 0 && phpQueuedCount === 0) {
					setIsFirstRun(true);
				}
				}
			}
		} catch {
			// Non-critical — page still works without cached report
		}
		// NOTE: do NOT call setIsLoading(false) here — fetchItems owns isLoading
		// and clears it when the items fetch completes. Calling it here races
		// with fetchItems and briefly shows “No content items found” mid-flight.
	};

	const fetchItems = async () => {
		setIsLoading(true);
		setItems([]);
		// Orphaned items live only in Sideconvo, not in WP sync_state — skip the
		// server fetch and let the render path show reconciliation report data.
		if (statusFilter === 'orphaned') {
			// Orphaned items are rendered from reconciliationReport — update pagination
			// to reflect the orphaned count rather than the previous all-items count.
			const orphanedCount = reconciliationReport?.orphaned_items.length ?? 0;
			setTotal(orphanedCount);
			setTotalPages(Math.max(1, Math.ceil(orphanedCount / perPage)));
			setIsLoading(false);
			return;
		}

		try {
			// For reconciliation-based filters (missing/stale), pass the specific
			// post IDs from the reconciliation report instead of a status string.
			// The `missing` DB status 'not_synced' is incorrect because missing items
			// were previously indexed and DO have a sync_state row.
			let serverStatus = '';
			let objectIds = '';
			if (statusFilter === 'indexed') {
				serverStatus = 'indexed';
			} else if (statusFilter === 'missing') {
				// Guard: don't fall through to an unfiltered fetch while report is loading.
				if (!reconciliationReport) { setIsLoading(false); return; }
				const missingFiltered = contentTypeFilter
					? reconciliationReport.missing_items.filter((i) => i.type === contentTypeFilter)
					: reconciliationReport.missing_items;
				objectIds = missingFiltered.map((i) => i.id).join(',');
				if (!objectIds) {
					setItems([]);
					setTotal(0);
					setTotalPages(1);
					setIsLoading(false);
					return;
				}
			} else if (statusFilter === 'stale') {
				// Guard: don't fall through to an unfiltered fetch while report is loading.
				if (!reconciliationReport) { setIsLoading(false); return; }
				const staleFiltered = contentTypeFilter
					? reconciliationReport.stale_items.filter((i) => i.type === contentTypeFilter)
					: reconciliationReport.stale_items;
				objectIds = staleFiltered.map((i) => i.id).join(',');
				if (!objectIds) {
					setItems([]);
					setTotal(0);
					setTotalPages(1);
					setIsLoading(false);
					return;
				}
			} else if (statusFilter === 'expired') {
				serverStatus = 'expired';
			} else if (statusFilter === 'failed') {
				serverStatus = 'failed';
			}

			let itemsParams = `page=${currentPage}&limit=${perPage}`;
			if (serverStatus) itemsParams += `&status=${serverStatus}`;
			if (objectIds) itemsParams += `&object_ids=${objectIds}`;
			// For server-side views, send the content type filter to the backend.
			// Missing/stale views already filtered object_ids above; orphaned is client-only.
			if (contentTypeFilter && !objectIds) itemsParams += `&type_key=${contentTypeFilter}`;
			if (debouncedSearch) {
				itemsParams += `&search=${encodeURIComponent(debouncedSearch)}`;
				if (searchMode !== 'keyword') itemsParams += `&search_mode=${searchMode}`;
			}

			const itemsResponse = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/sync-items?${itemsParams}`,
				{ headers: { 'X-WP-Nonce': (window as any).sideconvoData.nonce } }
			);

			if (!itemsResponse.ok) {
				throw new Error('Failed to fetch items');
			}

			const itemsData = await itemsResponse.json();

			const mappedItems: ContentItem[] = (itemsData.items || []).map((item: any) => ({
				id: item.object_id,
				title: item.title || 'Untitled',
				type: item.type_label || item.post_type || item.taxonomy || 'Unknown',
				status: item.status,
				url: item.view_url || '',
				error: item.error_message || undefined,
				sync_id: item.id,
				last_synced_at: item.last_synced_at || null,
				expires_at: item.expires_at || null,
			}));

			setItems(mappedItems);
			setTotal(itemsData.total || 0);
			setTotalPages(itemsData.totalPages || 1);

			// Stats (progress counters) are updated separately via fetchStats().
			setIsLoading(false);
		} catch (error) {
			console.error('Error fetching items:', error);
			setIsLoading(false);
		}
	};

	// Keep ref up-to-date so the polling interval always calls the latest version.
	fetchItemsRef.current = fetchItems;
	/** Fetch only sync-stats — lightweight update for progress counters during polling. */
	const fetchStats = async () => {
		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/sync-stats`,
				{ headers: { 'X-WP-Nonce': (window as any).sideconvoData.nonce } }
			);
			if (!response.ok) return;
			const statsData = await response.json();
			const scopeCount = statsData.scope_count || 0;
			const indexed = statsData.indexed || 0;
			const percent = scopeCount > 0 ? Math.round((indexed / scopeCount) * 100) : 0;
			setProgress((prev) => ({
				...prev,
				queued: statsData.queued || 0,
				indexed,
				failed: statsData.failed || 0,
				excluded: statsData.excluded || 0,
				total: statsData.total || 0,
				scope_count: scopeCount,
				percent,
			}));
			setStatsLoaded(true);
			localStorage.setItem('sideconvo_scope_count', String(scopeCount));
			// Keep localStorage in sync and notify the sidebar immediately so it
			// doesn't have to wait for its 30-second polling interval.
			if (indexed > 0 || (statsData.queued || 0) === 0) {
				localStorage.setItem('sideconvo_indexed_count', String(indexed));
				window.dispatchEvent(
					new CustomEvent('sideconvo:indexed-count-updated', { detail: { count: indexed } })
				);
			}
		} catch {
			// Non-critical — progress counters stay at last known value
		}
	};
	fetchStatsRef.current = fetchStats;

	const startIndexing = async (itemIds?: number[]) => {
		setBatchProgress({ processed: 0, total: 0, failed: 0 });
		try {
			const body: Record<string, unknown> = {};
			if (itemIds && itemIds.length > 0) {
				body.item_ids = itemIds;
			}
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/start-indexing`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
					body: JSON.stringify(body),
				}
			);

			if (!response.ok) {
				throw new Error('Failed to start indexing');
			}

			showNotification('info', 'Syncing with Sideconvo');
			processBatch();
		} catch (error) {
			console.error('Error starting indexing:', error);
			showNotification('error', 'Failed to start batch sync');
			setErrorMessage('Sync started but encountered an error. Some items may not have been indexed.');
			setPageStatus('error');
		}
	};

	const retryFailedItems = async () => {
		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/retry-failed`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
				}
			);
			if (!response.ok) return;
			const data = await response.json();
			if (data.queued > 0) {
				setPageStatus('syncing');
				processBatch();
			} else {
				// Nothing re-queued — out-of-scope failed records were deleted server-side.
				// Refresh counters and items so the Failed card and table clear.
				fetchStats();
				fetchItems();
			}
		} catch {
			// Non-critical — failed items stay failed
		}
	};

	const refreshReconciliationReport = async () => {
		if (isReconciling.current) return;
		const headers = {
			'Content-Type': 'application/json',
			'X-WP-Nonce': (window as any).sideconvoData.nonce,
		};
		const url = `${(window as any).sideconvoData.restUrl}sideconvo/v1/reconciliation/start`;
		try {
			let response = await fetch(url, { method: 'POST', headers });
			if (response.status === 409) {
				// Clear stale lock and retry once.
				await fetch(
					`${(window as any).sideconvoData.restUrl}sideconvo/v1/reconciliation/clear-lock`,
					{ method: 'POST', headers }
				).catch(() => null);
				response = await fetch(url, { method: 'POST', headers });
			}
			if (!response.ok) return;
			const data = await response.json();
			if (data.success && data.report) {
				setAndCacheReport(data.report);
			}
		} catch {
			// Non-critical — status cards may be stale but sync is done
		}
	};

	const processBatch = async () => {
		if (isProcessingBatch.current) return;
		isProcessingBatch.current = true;
		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/process-batch`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
				}
			);

			// Try to parse JSON even on error so we can surface the real message.
			const data = await response.json().catch(() => null);

			if (!response.ok) {
				const detail = data?.message ? ` (${data.message})` : '';
				throw new Error(`Failed to process batch${detail}`);
			}

			// 429 — server is rate-limited. Pause polling for retry_after seconds.
			// Keep pageStatus as 'syncing' so the progress banner stays visible.
			if (data?.rate_limited) {
				const retryAfter = Math.max(1, data.retry_after ?? 60);
				if (rateLimitTimerRef.current) clearTimeout(rateLimitTimerRef.current);
				rateLimitTimerRef.current = window.setTimeout(() => {
					rateLimitTimerRef.current = null;
					processBatch();
				}, retryAfter * 1000);
				return;
			}

			// Update batch-level progress counters so the banner shows real progress
			// (items attempted) rather than just items successfully indexed.
			if (data && data.total > 0) {
				setBatchProgress({
					processed: data.processed ?? 0,
					total: data.total ?? 0,
					failed: data.failed ?? 0,
				});
			}

				// Treat as complete when the server says so, OR the progress counters
			// confirm 100%. The latter handles the race where the batch lock causes
			// process_next_batch() to exit early without setting status='completed'.
			const batchComplete =
				data.complete ||
				data.status === 'completed' ||
				data.total === 0 ||
				(data.total > 0 && data.processed >= data.total);
			if (batchComplete) {
				setPageStatus('complete');
				showNotification('success', 'Sync complete');
				// Clear the status filter so fetchItems (triggered by the statusFilter
				// useEffect) uses a fresh closure — prevents stale missing_items from
				// being shown after the reconciliation report is cleared.
				setStatusFilter(null);
				// Refresh progress counters from sync_state.
				fetchStats();
				// Do NOT re-reconcile against Pinecone here — the sync sends data to
				// Sideconvo/Firebase which then async-writes to Pinecone via a cloud
				// trigger. Running reconciliation immediately after would always show
				// items as "missing" because Pinecone hasn't caught up yet.
				// Instead, optimistically clear missing/stale counts (we just synced them)
				// and persist so the stale report isn't shown on next page load.
				// Record the sync timestamp so setAndCacheReport() suppresses stale
				// Pinecone reports for the next 5 minutes.
				recentlySyncedAt.current = Date.now();
				try { localStorage.setItem('sideconvo_recently_synced_at', String(recentlySyncedAt.current)); } catch { /* non-critical */ }
				setReconciliationReport((prev) => {
					if (!prev) return prev;
					const updated = {
						...prev,
						summary: { ...prev.summary, missing: 0, stale: 0, total_wordpress: phpScopeCount },
						missing_items: [],
						stale_items: [],
					};
					try {
						localStorage.setItem('sideconvo_reconciliation_report', JSON.stringify(updated));
					} catch { /* non-critical */ }
					return updated;
				});
			}
		} catch (error) {
			console.error('Error processing batch:', error);
			const msg = error instanceof Error ? error.message : 'Unknown error';
			setErrorMessage(`Sync encountered an error: ${msg}`);
			setPageStatus('error');
		} finally {
			isProcessingBatch.current = false;
		}
	};

	const runReconcileAndSync = async () => {
		if (isReconciling.current) return;
		isReconciling.current = true;
		setErrorMessage(null);
		setPageStatus('reconciling');

		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/reconciliation/start`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
				}
			);

			// Parse body first so we can read the message on any status
			const data = await response.json().catch(() => null);

			if (!response.ok) {
				if (response.status === 409) {
					// Stale lock detected — force-clear it then retry immediately.
					await fetch(
						`${(window as any).sideconvoData.restUrl}sideconvo/v1/reconciliation/clear-lock`,
						{
							method: 'POST',
							headers: {
								'Content-Type': 'application/json',
								'X-WP-Nonce': (window as any).sideconvoData.nonce,
							},
						}
					).catch(() => null);
					const retryResp = await fetch(
						`${(window as any).sideconvoData.restUrl}sideconvo/v1/reconciliation/start`,
						{
							method: 'POST',
							headers: {
								'Content-Type': 'application/json',
								'X-WP-Nonce': (window as any).sideconvoData.nonce,
							},
						}
					).catch(() => null);
					if (!retryResp || !retryResp.ok) {
						// Still locked — reset to idle so the user can trigger manually.
						setPageStatus('idle');
						return;
					}
					const retryData = await retryResp.json().catch(() => null);
					// Fall through to the success handler with retryData
					if (retryData?.success && retryData?.report) {
						setAndCacheReport(retryData.report);
						const { missing, stale, total_pinecone } = retryData.report.summary;
						if (total_pinecone === 0) {
							setIsFirstRun(true);
							setPageStatus('idle');
						} else if (missing > 0 || stale > 0) {
							const missingIds: number[] = (retryData.report.missing_items || []).map((i: any) => i.id);
							const staleIds: number[] = (retryData.report.stale_items || []).map((i: any) => i.id);
							// Queue items first, then start polling — avoids a race where the
							// polling loop fires processBatch() before start-indexing has
							// enqueued anything, causing it to see total=0 and mark complete.
							await startIndexing([...missingIds, ...staleIds]);
							setPageStatus((prev) => prev === 'reconciling' ? 'syncing' : prev);
						} else {
							// Nothing to sync — everything is already in sync.
							setPageStatus('complete');
						}
					} else {
						setPageStatus('idle');
					}
					return;
				}
				throw new Error(data?.message || 'Reconciliation request failed');
			}

			if (data.success && data.report) {
				setAndCacheReport(data.report);
				const { missing, stale, total_pinecone } = data.report.summary;

				// First-run: nothing indexed yet — show welcome banner, don't auto-sync
				if (total_pinecone === 0) {
					setIsFirstRun(true);
					setPageStatus('idle');
				} else if (missing > 0 || stale > 0) {
					// Collect only the IDs that need syncing (missing + stale), not all content
					const missingIds: number[] = (data.report.missing_items || []).map((i: any) => i.id);
					const staleIds: number[] = (data.report.stale_items || []).map((i: any) => i.id);
					// Re-queue any failed items so they are included in the same batch run.
					// Failed items may already exist in Pinecone (not "missing") but still
					// need a re-attempt — retry-failed marks them queued without resetting
					// the progress counters for the current run.
					if (progress.failed > 0) {
						await fetch(
							`${(window as any).sideconvoData.restUrl}sideconvo/v1/retry-failed`,
							{ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': (window as any).sideconvoData.nonce } }
						).catch(() => {});
					}
					// Queue items first, then start polling — avoids a race where the
					// polling loop fires processBatch() before start-indexing has
					// enqueued anything, causing it to see total=0 and mark complete.
					await startIndexing([...missingIds, ...staleIds]);
					setPageStatus((prev) => prev === 'reconciling' ? 'syncing' : prev);
				} else if (progress.failed > 0) {
					// Pinecone is up-to-date but some items failed their last sync attempt.
					// Re-queue and process them now.
					setPageStatus('syncing');
					retryFailedItems();
				} else {
					// Nothing to sync — everything is already in sync.
					setPageStatus('complete');
				}
			} else {
				throw new Error(data.message || 'Reconciliation failed');
			}
		} catch (error) {
			console.error('Error running reconciliation:', error);
			const msg = error instanceof Error ? error.message : 'Unknown error';
			setErrorMessage(`Could not check sync status: ${msg}`);
			setPageStatus('error');
		} finally {
			isReconciling.current = false;
		}
	};

	const handleExcludeItem = async (sync_id: number | null) => {
		if (!sync_id) return;
		setLoadingRowId(sync_id);
		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/exclude-items`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
					body: JSON.stringify({ item_ids: [sync_id] }),
				}
			);

			if (!response.ok) throw new Error('Failed to exclude item');
			// Prevent the auto-sync useEffect from triggering a full reconcile cycle.
			hasAutoSynced.current = true;
			// Await fetchItems so loadingRowId is cleared only after fresh data
			// is in state — prevents a flash of stale status.
			await fetchItems();
		} catch (error) {
			console.error('Error excluding item:', error);
			showNotification('error', 'Failed to exclude item');
		} finally {
			setLoadingRowId(null);
		}
	};

	const handleIncludeItem = async (sync_id: number | null) => {
		if (!sync_id) return;
		setLoadingRowId(sync_id);
		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/include-items`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
					body: JSON.stringify({ item_ids: [sync_id] }),
				}
			);

			if (!response.ok) throw new Error('Failed to include item');
			// Prevent the auto-sync useEffect from triggering a full reconcile cycle.
			hasAutoSynced.current = true;
			// Await fetchItems so loadingRowId is cleared only after fresh data
			// is in state — prevents a flash of stale status.
			await fetchItems();
		} catch (error) {
			console.error('Error including item:', error);
			showNotification('error', 'Failed to include item');
		} finally {
			setLoadingRowId(null);
		}
	};

	const handleResyncSingleItem = async (sync_id: number | null, item_id: number) => {
		if (!sync_id) return;
		setLoadingRowId(sync_id);
		try {
			await startIndexing([item_id]);
			hasAutoSynced.current = true;
			await fetchItems();
			// Refresh progress counters so the Failed status card updates immediately.
			// The item was re-queued by startIndexing, so failed count drops to 0 in the DB
			// even before the batch processes it.
			fetchStats();
		} catch (error) {
			console.error('Error resyncing item:', error);
			showNotification('error', 'Failed to re-sync item');
		} finally {
			setLoadingRowId(null);
		}
	};

	const callBulkFix = async (items: Array<{ id: string; object_type: string; pinecone_ids?: string[] }>) => {
		const response = await fetch(
			`${(window as any).sideconvoData.restUrl}sideconvo/v1/reconciliation/bulk-fix`,
			{
				method: 'POST',
				headers: {
					'Content-Type': 'application/json',
					'X-WP-Nonce': (window as any).sideconvoData.nonce,
				},
				body: JSON.stringify({ action: 'delete_from_pinecone', items }),
			}
		);
		if (!response.ok) throw new Error('Failed to delete from Sideconvo');
		return response.json();
	};

	const handleDeleteOrphanedItem = async (id: string) => {
		setLoadingOrphanedId(id);
		try {
			const item = reconciliationReport?.orphaned_items.find((i) => i.id === id);
		await callBulkFix([{ id, object_type: item?.object_type ?? 'post', pinecone_ids: item?.pinecone_ids }]);
			// Remove item from local report so the row disappears immediately
			setReconciliationReport((prev) => {
				if (!prev) return prev;
				const updated = {
					...prev,
					orphaned_items: prev.orphaned_items.filter((i) => i.id !== id),
					summary: { ...prev.summary, orphaned: Math.max(0, prev.summary.orphaned - 1) },
				};
				try { localStorage.setItem('sideconvo_reconciliation_report', JSON.stringify(updated)); } catch {}
				return updated;
			});
			showNotification('success', 'Removed from Sideconvo');
		} catch (error) {
			console.error('Error deleting orphaned item:', error);
			showNotification('error', 'Failed to remove item from Sideconvo');
		} finally {
			setLoadingOrphanedId(null);
		}
	};

	const handleDeleteAllOrphaned = async () => {
		const orphanedItems = reconciliationReport?.orphaned_items ?? [];
		if (orphanedItems.length === 0) return;
		setIsDeletingAllOrphaned(true);
		try {
			await callBulkFix(orphanedItems.map((i) => ({ id: i.id, object_type: i.object_type ?? 'post', pinecone_ids: i.pinecone_ids })));
			// Clear all orphaned items from local report
			setReconciliationReport((prev) => {
				if (!prev) return prev;
				const updated = {
					...prev,
					orphaned_items: [],
					summary: { ...prev.summary, orphaned: 0 },
				};
				try { localStorage.setItem('sideconvo_reconciliation_report', JSON.stringify(updated)); } catch {}
				return updated;
			});
			showNotification('success', `Removed ${orphanedItems.length} orphaned item(s) from Sideconvo`);
		} catch (error) {
			console.error('Error deleting all orphaned items:', error);
			showNotification('error', 'Failed to remove orphaned items');
		} finally {
			setIsDeletingAllOrphaned(false);
		}
	};

	/**
	 * Queue all missing + stale items from the cached reconciliation report
	 * server-side, then drive them through the existing process-batch polling loop.
	 * This avoids re-running reconciliation and never sends a large ID array over the wire.
	 */
	const handleSyncAllMissing = async () => {
		if (!reconciliationReport) return;
		setErrorMessage(null);
		// Show a loading state immediately without starting the polling loop yet.
		// The polling loop starts only after queue-missing returns and items are
		// enqueued — prevents processBatch() from seeing total=0 prematurely.
		setPageStatus('reconciling');
		hasAutoSynced.current = true;

		// Collect IDs from the local report — used as fallback when the server
		// cache is expired so we never need an extra Pinecone round-trip.
		const localMissingIds = (reconciliationReport.missing_items || []).map((i) => i.id);
		const localStaleIds   = (reconciliationReport.stale_items   || []).map((i) => i.id);
		const localIds        = [ ...localMissingIds, ...localStaleIds ];

		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/queue-missing`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
				}
			);
			const data = await response.json().catch(() => null);
			if (!response.ok) {
				if ( localIds.length > 0 ) {
					// Server cache expired or unavailable — queue the known IDs
					// directly from the local report. This avoids a full reconciliation
					// (which requires a slow Pinecone list) and still fixes the drift.
					showNotification( 'info', `Syncing ${ localIds.length } item(s) with Sideconvo` );
					await startIndexing( localIds );
					setPageStatus((prev) => prev === 'reconciling' ? 'syncing' : prev);
					return;
				}
				throw new Error(data?.message || 'Failed to queue missing items');
			}
			if (data.total === 0) {
				setPageStatus('complete');
				showNotification('success', 'Nothing to sync — all items are up to date');
				return;
			}
			setPageStatus('syncing');
			showNotification('info', `Syncing ${data.total} missing item(s) with Sideconvo`);
			processBatch();
		} catch (error) {
			console.error('Error queuing missing items:', error);
			setErrorMessage(`Failed to queue missing items: ${error instanceof Error ? error.message : 'Unknown error'}`);
			setPageStatus('error');
		}
	};

	const handleFirstRunSync = async () => {
		setIsFirstRun(false);
		setPageStatus('syncing');
		await startIndexing();
	};

	const handleReindex = async (type: 'failed' | 'all') => {
		try {
			const response = await fetch(
				`${(window as any).sideconvoData.restUrl}sideconvo/v1/reindex-items`,
				{
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
						'X-WP-Nonce': (window as any).sideconvoData.nonce,
					},
					body: JSON.stringify({ type }),
				}
			);

			if (!response.ok) throw new Error('Failed to start reindex');

			setPageStatus('syncing');
			showNotification('success', 'Reindex started');
			fetchItems();
		} catch (error) {
			console.error('Error starting reindex:', error);
			showNotification('error', 'Failed to start reindex');
		}
	};

	// ── Derived Data ───────────────────────────────

	/** Map a raw sync_state status to a display SyncStatus */
	const toSyncStatus = (status: string): SyncStatus => {
		if (status === 'indexed') return 'indexed';
		if (status === 'excluded') return 'excluded';
		if (status === 'failed') return 'failed';
		// not_synced, queued all mean the item is missing from Sideconvo
		return 'missing';
	};

	/** Merge reconciliation status into sync items */
	const enrichedItems: EnrichedItem[] = useMemo(() => {
		if (!reconciliationReport) {
			return items.map((item) => ({ ...item, syncStatus: toSyncStatus(item.status) }));
		}

		const missingIds = new Set(reconciliationReport.missing_items.map((i) => i.id));
		const staleIds = new Set(reconciliationReport.stale_items.map((i) => i.id));

		return items.map((item) => {
			let syncStatus: SyncStatus = toSyncStatus(item.status);
			// Don't override explicitly excluded items — they should always display as
			// excluded even if reconciliation considers them 'missing' (because they're
			// intentionally absent from Sideconvo due to the metabox setting).
			if (item.status !== 'excluded') {
				if (missingIds.has(item.id)) syncStatus = 'missing';
				else if (staleIds.has(item.id)) syncStatus = 'stale';
			}
			return { ...item, syncStatus };
		});
	}, [items, reconciliationReport]);

	/** Server handles all search filtering — no client-side overlay needed.
	 *  Status filtering is handled server-side (status= or object_ids= params). */
	const filteredItems = enrichedItems;

	// True while the user has typed but the debounced request hasn't fired yet,
	// or the regex is invalid (server request suppressed).
	const isSearchPending = (searchQuery !== debouncedSearch) && !regexError;

	// Orphaned items come from the reconciliation report (not the server fetch),
	// so they need their own client-side search filter.
	const filteredOrphanedItems = useMemo(() => {
		const orphaned = reconciliationReport?.orphaned_items ?? [];
		if (!searchQuery) return orphaned;
		const q = searchQuery.toLowerCase();
		return orphaned.filter(
			(item) =>
				item.title.toLowerCase().includes(q) ||
				item.id.toLowerCase().includes(q)
		);
	}, [reconciliationReport?.orphaned_items, searchQuery]);

	/** Filter label based on current selection */
	const filterLabel = statusFilter
		? FILTER_ITEMS.find((i) => i.value === statusFilter)?.label || 'Show All'
		: 'Show All';

	const handleFilterChange = (value: string) => {
		setStatusFilter(value === 'all' ? null : value);
		setCurrentPage(1);
	};

	const contentTypeFilterLabel = contentTypeFilter
		? contentTypeItems.find((i) => i.value === contentTypeFilter)?.label || 'All Types'
		: 'All Types';

	const handleContentTypeFilterChange = (value: string) => {
		setContentTypeFilter(value === 'all' ? null : value);
		setCurrentPage(1);
	};

	const isBusy = pageStatus === 'reconciling' || pageStatus === 'syncing';

	// Derived banner values — use batch-level counters when a batch has run so
	// the progress bar advances even when items are failing (not just indexed).
	const bannerTotal = batchProgress.total > 0 ? batchProgress.total : progress.scope_count;
	const bannerProcessed = batchProgress.total > 0 ? batchProgress.processed : 0;
	const bannerPercent = bannerTotal > 0 ? Math.round((bannerProcessed / bannerTotal) * 100) : 0;
	const bannerFailed = batchProgress.failed;

	// True while the page hasn't loaded its first stats yet and no active sync is
	// running. During this window the button should be disabled and say "Checking…"
	// so the user knows something is happening rather than seeing a clickable
	// "Sync All" that appears to do nothing.
	const isInitializing = !statsLoaded && !isBusy && !isFirstRun;

	/** Steps for the sync progress indicator */
	const syncSteps: StepItem[] = useMemo(() => {
		const missing = reconciliationReport?.summary.missing ?? 0;
		const orphaned = reconciliationReport?.summary.orphaned ?? 0;
		const stale = reconciliationReport?.summary.stale ?? 0;

		// Simple sequential progression: first step with items is active, rest idle
		// When sync completes, all become completed
		const isSyncDone = pageStatus === 'complete';
		const steps: Array<{ label: string; count: number }> = [
			{ label: 'Add New Items', count: missing },
			{ label: 'Remove orphaned', count: orphaned },
			{ label: 'Update Stale', count: stale },
		];

		let foundActive = false;
		return steps.map(({ label, count }) => {
			let status: StepStatus = 'idle';
			if (isSyncDone) {
				status = 'completed';
			} else if (!foundActive && count > 0) {
				status = 'active';
				foundActive = true;
			}
			return { label, count, status };
		});
	}, [reconciliationReport, pageStatus]);

	// ── Render ─────────────────────────────────────

	return (
		<div className={styles.page}>
			{/* Absorbs WordPress admin's automatic focus-first-input behavior */}
			<input type="text" className={ styles.focusTrap } aria-hidden="true" tabIndex={ -1 } readOnly />
			{/* ── Header ── */}
			<PageHeader
				title="Reconciliation & Sync"
				toolbar={
					<>
						<div className={styles.searchWrapper}>
							<Input
								size="slim"
								leadingIcon={<IconSearch size={16} />}
								leadingChip={
									searchMode !== 'keyword'
										? {
											label: searchMode === 'url' ? 'URL' : 'Regex',
											onDismiss: () => {
												setSearchMode('keyword');
												setRegexError(null);
												setDebouncedSearch('');
												setSearchQuery('');
											},
										}
										: undefined
								}
								placeholder={
									searchMode === 'url' ? '/product/ or /category/…'
									: searchMode === 'regex' ? '^hello.*2024'
									: 'Search by title, ID, or URL'
								}
								value={searchQuery}
								onChange={(v) => { setSearchQuery(v); setRegexError(null); }}
								clearable
								className={styles.searchInput}
							/>
						</div>
						<SelectFilter
							label="Advanced"
							items={SEARCH_MODE_ITEMS}
							value={searchMode !== 'keyword' ? searchMode : null}
							onChange={(v) => {
								setSearchMode(v as 'keyword' | 'url' | 'regex');
								setRegexError(null);
								setDebouncedSearch('');
								setSearchQuery('');
							}}
							isOpen={searchModeOpen}
							onOpenChange={setSearchModeOpen}
							showBadge={false}
						/>
						{contentTypeItems.length > 2 && (
							<SelectFilter
								label={contentTypeFilterLabel}
								items={contentTypeItems}
								value={contentTypeFilter}
								onChange={handleContentTypeFilterChange}
								isOpen={contentTypeFilterOpen}
								onOpenChange={setContentTypeFilterOpen}
								showBadge={false}
							/>
						)}
						<SelectFilter
							label={filterLabel}
							items={FILTER_ITEMS}
							value={statusFilter}
							onChange={handleFilterChange}
							isOpen={filterOpen}
							onOpenChange={setFilterOpen}
							showBadge={false}
						/>
						<Button
							variant="primary"
							size="small"
							leadingIcon={
								isBusy || isInitializing
									? <IconLoading size={14} />
									: <IconSync size={14} />
							}
							onClick={runReconcileAndSync}
							disabled={isBusy || isInitializing}
						>
							{pageStatus === 'reconciling'
								? 'Reconciling...'
								: pageStatus === 'syncing'
								? 'Syncing...'
								: isInitializing
								? 'Checking...'
								: 'Sync All'}
						</Button>
					</>
				}
			/>

			{/* ── Status Cards ── */}
			<div className={styles.statusCards}>
				<StatusCard
					icon={<IconWordpress />}
					label="Pages in Scope"
					value={statsLoaded ? progress.scope_count : cachedScopeCount}
					isLoading={pageStatus === 'reconciling' && progress.total === 0}
					className={styles.statusCard}
				/>
				<StatusCard
					icon={<IconSideconvo />}
					label="Pages Indexed"
					value={statsLoaded ? progress.indexed : cachedIndexedCount}
					isLoading={pageStatus === 'reconciling' && !statsLoaded && cachedIndexedCount === 0}
					className={styles.statusCard}
				/>
				<StatusCard
					icon={<StatusDot color="var(--color-semantic-info)" />}
					label="Missing Pages"
					value={(statsLoaded && progress.scope_count === 0) ? 0 : (reconciliationReport?.summary.missing ?? '—')}
					isLoading={pageStatus === 'reconciling' && reconciliationReport === null}
					className={styles.statusCard}
				/>
				<StatusCard
					icon={<StatusDot color="var(--color-semantic-warning)" />}
					label="Stale"
					value={(statsLoaded && progress.scope_count === 0) ? 0 : (reconciliationReport?.summary.stale ?? '—')}
					isLoading={pageStatus === 'reconciling' && reconciliationReport === null}
					className={styles.statusCard}
				/>
				<StatusCard
					icon={<StatusDot color="var(--color-semantic-danger)" />}
					label="Orphaned"
					value={reconciliationReport?.summary.orphaned ?? '—'}
					isLoading={pageStatus === 'reconciling' && reconciliationReport === null}
					className={styles.statusCard}
				/>
				{progress.queued > 0 && pageStatus !== 'syncing' && (
					<StatusCard
						icon={<StatusDot color="var(--color-semantic-info)" />}
						label="Queued"
						value={progress.queued}
						className={styles.statusCard}
					/>
				)}
				{progress.failed > 0 && (
					<StatusCard
						icon={<StatusDot color="var(--color-semantic-danger)" />}
						label="Failed"
						value={progress.failed}
						className={styles.statusCard}
					/>
				)}
			</div>

			{/* ── First-Run Welcome Banner ── */}
			{isFirstRun && progress.scope_count > 0 && (
				<div className={styles.welcomeBanner}>
					<div className={styles.welcomeIcon}>
						<IconCheck size={11} color="var(--color-brand-primary)" />
					</div>
					<div className={styles.welcomeBody}>
						<p className={styles.welcomeHeading}>You're all set — syncing your content now</p>
						<p className={styles.welcomeText}>
							<strong>Posts</strong> and <strong>Pages</strong> are selected by default.
							Customize from the{' '}
							<button
								type="button"
								className={styles.welcomeLink}
								onClick={() => navigate('/content-types')}
							>
								Content Types
							</button>{' '}
							page.
						</p>
					</div>
					<span className={styles.welcomeAutoLabel}>
						<span className={styles.welcomeDot} />
						Starting automatically…
					</span>
				</div>
			)}

			{/* ── Sync Progress Banner (reconciling or syncing) ── */}
			{isBusy && (pageStatus !== 'syncing' || progress.scope_count > 0) && (
				<div className={styles.syncBanner}>
					<div className={styles.syncBannerHeader}>
						<IconLoading size={14} />
						<span className={styles.syncBannerTitle}>
							{pageStatus === 'reconciling'
								? 'Checking sync status…'
								: `Syncing content — ${bannerProcessed} of ${bannerTotal} items processed${bannerFailed > 0 ? ` · ${bannerFailed} failed` : ''}`}
						</span>
						{pageStatus === 'syncing' && (
							<span className={styles.syncBannerPct}>{bannerPercent}%</span>
						)}
					</div>
					{pageStatus === 'syncing' && bannerTotal > 0 && (
						<div className={styles.syncProgressTrack}>
							<div
								className={styles.syncProgressFill}
								style={{ width: `${bannerPercent}%` }}
							/>
						</div>
					)}
					{pageStatus === 'syncing' && (
						<div className={styles.syncBannerSteps}>
							{syncSteps.map((step, i) => (
								<div key={i} className={styles.syncBannerStepGroup}>
									{i > 0 && <span className={styles.syncBannerChevron}>›</span>}
									<span className={`${styles.syncBannerStep} ${styles[`step_${step.status}`]}`}>
										{step.label}{step.count !== undefined && ` (${step.count})`}
									</span>
								</div>
							))}
						</div>
					)}
				</div>
			)}

			{/* ── Error Banner ── */}
			{pageStatus === 'error' && errorMessage && (
				<div className={styles.errorBannerRow}>
					<AlertBar
						message={
							progress.queued > 0
								? `Sync paused — ${progress.queued} item(s) still queued. Click Retry to continue.`
								: errorMessage
						}
						variant="danger"
					/>
					<Button
						variant="secondary"
						size="small"
						onClick={
							progress.queued > 0
								? () => { setPageStatus('syncing'); setErrorMessage(null); processBatch(); }
								: runReconcileAndSync
						}
					>
						Retry
					</Button>
				</div>
			)}

			{/* ── Regex Error ── */}
			{regexError && (
				<AlertBar message={regexError} variant="danger" />
			)}

			{/* ── Alert Bar ── */}
			{(() => {
				const alertConfig: Record<string, { message: string; variant: AlertBarVariant }> = {
					all: { message: 'The pages below are indexed in Sideconvo', variant: 'info' },
					indexed: { message: 'The pages below are indexed in Sideconvo', variant: 'info' },
					missing: { message: 'These pages exist on WordPress but are not yet indexed in Sideconvo', variant: 'warning' },
					stale: { message: 'These pages are stale — the version on Sideconvo is older than what is in WordPress', variant: 'warning' },
					orphaned: { message: 'These pages exist on Sideconvo but are outside your current sync scope', variant: 'danger' },
					expired: { message: 'These pages have passed their expiry date and will be removed from Sideconvo on the next daily check', variant: 'warning' },
				};
				const config = alertConfig[statusFilter || 'all'];
				if (!config) return null;
				return <AlertBar message={config.message} variant={config.variant} />;
			})()}

			{/* ── Remove All Orphaned action bar ── */}
			{statusFilter === 'orphaned' && (reconciliationReport?.orphaned_items ?? []).length > 0 && (
				<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '8px 0' }}>
					<Button
						variant="danger"
						size="small"
						onClick={handleDeleteAllOrphaned}
						disabled={isDeletingAllOrphaned || loadingOrphanedId !== null}
					>
						{isDeletingAllOrphaned ? 'Removing...' : `Remove All Orphaned (${reconciliationReport!.orphaned_items.length})`}
					</Button>
				</div>
			)}

			{/* ── Sync All Missing action bar ── */}
			{statusFilter === 'missing' && (reconciliationReport?.missing_items ?? []).length > 0 && (
				<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '8px 0' }}>
					<Button
						variant="primary"
						size="small"
						onClick={handleSyncAllMissing}
						disabled={pageStatus === 'syncing' || pageStatus === 'reconciling'}
					>
						{pageStatus === 'syncing' ? 'Syncing...' : `Sync All Missing (${reconciliationReport!.missing_items.length})`}
					</Button>
				</div>
			)}

			{/* ── Invalid ID Cleanup Notice ── */}
			{(reconciliationReport?.invalid_id_items ?? []).length > 0 && (
				<AlertBar
					variant="info"
					message={`${reconciliationReport!.invalid_id_items.length} entr${reconciliationReport!.invalid_id_items.length === 1 ? 'y' : 'ies'} in Sideconvo ${reconciliationReport!.invalid_id_items.length === 1 ? 'was' : 'were'} created by an external source (not this plugin) and will not be touched by sync or cleanup operations.`}
				/>
			)}
			{/* ── Table ── */}
			<div className={styles.tableContainer}>
				{/* Header row — widths match SyncTableRow layout */}
				<div className={styles.tableHeader}>
					<div style={{ width: 32, flexShrink: 0 }} />
					<span className={styles.headerText} style={{ width: 80, flexShrink: 0 }}>ID</span>
					<span className={styles.headerText} style={{ flex: 1 }}>URL</span>
					<span className={styles.headerText} style={{ width: 100, flexShrink: 0 }}>Type</span>
					<span className={styles.headerText} style={{ width: 160, flexShrink: 0 }}>Last Synced</span>
					<div style={{ width: 100, flexShrink: 0 }} />
				</div>

				{/* Body */}
				<div className={styles.tableBody}>
					{statusFilter === 'orphaned' ? (
						// Orphaned items live only in Sideconvo — render from reconciliation report
						filteredOrphanedItems.length === 0 ? (
							<div className={styles.emptyRow}>No orphaned items found</div>
						) : (
							filteredOrphanedItems.map((item) => (
								<SyncTableRow
									key={`orphaned-${item.id}`}
									id={item.id}
									title={item.title}
									url=""
									type="Orphaned"
									lastUpdated={formatDate(item.lastUpdated)}
									status="orphaned"
									isLoading={loadingOrphanedId === item.id}
									onExclude={() => handleDeleteOrphanedItem(item.id)}
								/>
							))
						)
					) : isLoading || isSearchPending ? (
						<>
							{Array.from({ length: 8 }).map((_, i) => (
								<div key={i} className={styles.skeletonRow}>
									{/* dot */}
									<div className={styles.skeletonCell} style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0 }} />
									{/* id */}
									<div className={styles.skeletonCell} style={{ width: 60, flexShrink: 0 }} />
									{/* title + url */}
									<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
										<div className={styles.skeletonCell} style={{ width: `${55 + (i % 4) * 10}%` }} />
										<div className={styles.skeletonCell} style={{ width: `${35 + (i % 3) * 8}%`, height: 9, opacity: 0.6 }} />
									</div>
									{/* type */}
									<div className={styles.skeletonCell} style={{ width: 48, flexShrink: 0 }} />
									{/* date */}
									<div className={styles.skeletonCell} style={{ width: 100, flexShrink: 0 }} />
								</div>
							))}
						</>
					) : filteredItems.length === 0 ? (
						<div className={styles.emptyRow}>
							{searchQuery || statusFilter ? 'No items match your filters' : 'No content items found'}
						</div>
					) : (
						filteredItems.map((item) => (
							<SyncTableRow
								key={`${item.id}-${item.type}`}
								id={item.id}
								title={item.title}
								url={item.url}
								type={item.type}
								lastUpdated={formatDate(item.last_synced_at)}
								status={item.syncStatus}
								error={item.error}
								expires_at={item.expires_at || undefined}
								isLoading={loadingRowId === item.sync_id}
								onReindex={
									item.status !== 'excluded' && (item.syncStatus === 'missing' || item.syncStatus === 'stale' || item.syncStatus === 'failed') && item.sync_id
										? () => handleResyncSingleItem(item.sync_id!, item.id)
										: undefined
								}
								onExclude={
									item.status !== 'excluded' && item.syncStatus !== 'missing' && item.syncStatus !== 'stale' && item.sync_id
										? () => handleExcludeItem(item.sync_id!)
										: undefined
								}
								onInclude={
									item.status === 'excluded' && item.sync_id
										? () => handleIncludeItem(item.sync_id!)
										: undefined
								}
							/>
						))
					)}
				</div>

				{/* Scroll indicator */}
				{totalPages > 1 && currentPage < totalPages && filteredItems.length > 0 && (
					<div
						className={styles.scrollIndicator}
						onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
					>
						<IconArrowDown size={14} />
						<span>Scroll for more</span>
					</div>
				)}
			</div>

			{/* ── Pagination ── */}
			{totalPages > 1 && filteredItems.length > 0 && (
				<div className={styles.pagination}>
					<Button
						variant="secondary"
						size="small"
						onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
						disabled={currentPage === 1}
					>
						Previous
					</Button>
					<span className={styles.paginationText}>
						Page {currentPage} of {totalPages}
					</span>
					<Button
						variant="secondary"
						size="small"
						onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
						disabled={currentPage === totalPages}
					>
						Next
					</Button>
				</div>
			)}
		</div>
	);
}
