'use client'

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
  Check,
  ChevronDown,
  ChevronLeft,
  ChevronRight,
  ChevronUp,
  Columns3,
  Copy,
  List,
  ListTree,
  PenLine,
  Plus,
  Search,
  Tag as TagIcon,
  Trash2,
  X,
} from 'lucide-react'
import { toast } from 'sonner'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { adminPortalContainer } from '../../lib/portal-container.js'
import {
  applyPagesQuery,
  bulkChangePageTags,
  bulkDeletePages,
  bulkUpdatePages,
  deletePage,
  duplicatePage,
  fetchPagesDataset,
  groupPages,
  movePage,
  type BulkResult,
  type Page,
  type PageGroupBy,
  type PagesDataset,
  type PageStatus,
  type PageTag,
} from '../../lib/pages-service.js'
import {
  buildPageTree,
  checkReparent,
  defaultExpandedIds,
  flattenPageTree,
} from '../../lib/pages-tree.js'
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
import { AuthorAvatar } from '../../components/ui/AuthorAvatar.js'
import { Button } from '../../components/ui/Button.js'
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
import { PagesTreeTable } from './PagesTreeTable.js'

/**
 * Pages management surface — table with a tag-chip filter bar, group-by,
 * status filter, sortable headers, bulk actions, and pagination. Data
 * flows through pages-service so a future server-side aggregate only
 * touches the service file.
 *
 * Routing:
 *   - Row click → /pages/:id/edit (the page builder).
 *   - "+ New page" lives in the top bar (computePageActions).
 *   - "Manage tags" (in the tag bar) → /pages/tags.
 */

interface PagesListViewProps {
  onNavigate: (path: string) => void
}

type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
type Dir = 'asc' | 'desc'
type ViewMode = 'list' | 'tree'

/** Optional flat-list columns the user can hide (design's "Tweaks" panel). */
type ToggleableColumn = 'tags' | 'author' | 'seo'
const TOGGLEABLE_COLUMNS: Array<{ key: ToggleableColumn; label: string }> = [
  { key: 'tags', label: 'Tags' },
  { key: 'author', label: 'Author' },
  { key: 'seo', label: 'SEO score' },
]

const PAGE_SIZE = 25
const VIEW_MODE_KEY = 'actuate.pages.viewMode'
const HIDDEN_COLUMNS_KEY = 'actuate.pages.hiddenColumns'

function readStoredViewMode(): ViewMode {
  if (typeof window === 'undefined') return 'list'
  try {
    return localStorage.getItem(VIEW_MODE_KEY) === 'tree' ? 'tree' : 'list'
  } catch {
    return 'list'
  }
}

function readStoredHiddenColumns(): Set<ToggleableColumn> {
  if (typeof window === 'undefined') return new Set()
  try {
    const raw = localStorage.getItem(HIDDEN_COLUMNS_KEY)
    if (!raw) return new Set()
    const known = new Set(TOGGLEABLE_COLUMNS.map((c) => c.key))
    const parsed: unknown = JSON.parse(raw)
    if (!Array.isArray(parsed)) return new Set()
    return new Set(parsed.filter((v): v is ToggleableColumn => known.has(v as ToggleableColumn)))
  } catch {
    return new Set()
  }
}

export function PagesListView({ onNavigate }: PagesListViewProps) {
  const [search, setSearch] = useState('')
  const [debouncedSearch, setDebouncedSearch] = useState('')
  const [status, setStatus] = useState<PageStatus | 'all'>('all')
  const [groupBy, setGroupBy] = useState<PageGroupBy>('none')
  const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set())
  const [sortBy, setSortBy] = useState<SortBy>('title')
  const [dir, setDir] = useState<Dir>('asc')
  const [page, setPage] = useState(1)
  const [selected, setSelected] = useState<Set<string>>(new Set())
  const [viewMode, setViewMode] = useState<ViewMode>(readStoredViewMode)
  const [hiddenCols, setHiddenCols] = useState<Set<ToggleableColumn>>(readStoredHiddenColumns)
  // null = "not yet touched" → falls back to the default expand state
  // (all root nodes with children open) computed from the current tree.
  const [expandedOverride, setExpandedOverride] = useState<Set<string> | null>(null)

  const [dataset, setDataset] = useState<PagesDataset | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [refreshToken, setRefreshToken] = useState(0)
  const [pendingDelete, setPendingDelete] = useState(false)
  const [rowDelete, setRowDelete] = useState<Page | null>(null)

  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
  useEffect(() => {
    if (debounceTimer.current) clearTimeout(debounceTimer.current)
    debounceTimer.current = setTimeout(() => {
      setDebouncedSearch(search.trim())
      setPage(1)
    }, 250)
    return () => {
      if (debounceTimer.current) clearTimeout(debounceTimer.current)
    }
  }, [search])

  const tagIds = useMemo(() => [...selectedTags], [selectedTags])

  // Single network read. Search / sort / group / paging are applied in
  // memory below, so changing any of those never re-fetches. The dataset
  // refreshes only on mount and after a mutation (refreshToken bump).
  useEffect(() => {
    let cancelled = false
    setLoading(true)
    setError(null)
    fetchPagesDataset()
      .then((res) => {
        if (cancelled) return
        setDataset(res)
        setLoading(false)
      })
      .catch((err: unknown) => {
        if (cancelled) return
        setError(err instanceof Error ? err.message : 'Failed to load pages')
        setLoading(false)
      })
    return () => {
      cancelled = true
    }
  }, [refreshToken])

  // Reset paging/selection when the active filters change shape.
  useEffect(() => {
    setPage(1)
    setSelected(new Set())
  }, [debouncedSearch, status, tagIds, groupBy])

  const allPages = useMemo(() => dataset?.pages ?? [], [dataset])
  const tags = useMemo(() => dataset?.tags ?? [], [dataset])
  const grandTotal = allPages.length
  const grouped = groupBy !== 'none'

  const filtered = useMemo(
    () =>
      applyPagesQuery(allPages, {
        status,
        tagIds,
        search: debouncedSearch,
        sortBy,
        sortDirection: dir,
      }),
    [allPages, status, tagIds, debouncedSearch, sortBy, dir],
  )
  const total = filtered.length
  const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))

  // Pagination only applies to the flat view; grouped views render every
  // match so the buckets stay complete.
  const pages = useMemo(
    () => (grouped ? filtered : filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)),
    [filtered, grouped, page],
  )

  const tagBySlug = useMemo(() => {
    const m = new Map<string, PageTag>()
    for (const t of tags) m.set(t.slug, t)
    return m
  }, [tags])

  const displayGroups = useMemo(() => groupPages(pages, groupBy, tags), [pages, groupBy, tags])

  // ─── Tree mode ─────────────────────────────────────────────────────

  const treeMode = viewMode === 'tree'
  const treeRoots = useMemo(() => buildPageTree(allPages), [allPages])
  const defaultExpanded = useMemo(() => defaultExpandedIds(treeRoots), [treeRoots])
  const expanded = expandedOverride ?? defaultExpanded

  // Per the design, an active search or status filter flattens the tree:
  // every match renders at depth 0 and expand state is ignored. (Tag
  // filters and group-by don't apply in tree mode — the hierarchy *is*
  // the grouping.)
  const treeFiltersActive = debouncedSearch !== '' || status !== 'all'
  const treeRows = useMemo(() => {
    if (treeFiltersActive) {
      return applyPagesQuery(allPages, {
        status,
        search: debouncedSearch,
        sortBy: 'title',
        sortDirection: 'asc',
      }).map((p) => ({ page: p, depth: 0, hasChildren: false }))
    }
    return flattenPageTree(treeRoots, expanded)
  }, [treeFiltersActive, allPages, status, debouncedSearch, treeRoots, expanded])

  const toggleExpand = (id: string) =>
    setExpandedOverride((prev) => {
      const next = new Set(prev ?? defaultExpanded)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })

  const changeViewMode = (mode: ViewMode) => {
    if (mode === viewMode) return
    setViewMode(mode)
    setSelected(new Set())
    try {
      localStorage.setItem(VIEW_MODE_KEY, mode)
    } catch {
      /* storage unavailable (private mode) — the toggle still works for the session */
    }
  }

  const toggleColumn = (key: ToggleableColumn) => {
    setHiddenCols((prev) => {
      const next = new Set(prev)
      if (next.has(key)) next.delete(key)
      else next.add(key)
      try {
        localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...next]))
      } catch {
        /* storage unavailable — the choice still applies for the session */
      }
      return next
    })
    // Hiding the column you're sorted by would leave an invisible sort —
    // fall back to the default title sort instead.
    if ((key === 'author' && sortBy === 'author') || (key === 'seo' && sortBy === 'seo')) {
      setSortBy('title')
      setDir('asc')
    }
  }

  const showTags = !hiddenCols.has('tags')
  const showAuthor = !hiddenCols.has('author')
  const showSeo = !hiddenCols.has('seo')
  // checkbox + title + status + modified + actions are always visible.
  const columnCount = 5 + (showTags ? 1 : 0) + (showAuthor ? 1 : 0) + (showSeo ? 1 : 0)
  // The table is `table-fixed`, so the flexible Title column only gets
  // whatever the min-width leaves after the fixed columns (400px always
  // visible + optional ones). Keep ≥240px for the title or it collapses
  // and headers overlap on narrow viewports.
  const tableMinWidth =
    400 + 240 + (showTags ? 200 : 0) + (showAuthor ? 170 : 0) + (showSeo ? 80 : 0)

  const handleSort = useCallback(
    (field: SortBy) => {
      if (sortBy === field) {
        setDir((d) => (d === 'asc' ? 'desc' : 'asc'))
      } else {
        setSortBy(field)
        setDir(field === 'modified' || field === 'seo' ? 'desc' : 'asc')
      }
    },
    [sortBy],
  )

  // `aria-sort` belongs on the column header cell (`<th>`), not the button
  // inside it — assistive tech reads sort state from the header.
  const ariaSortFor = (field: SortBy): 'ascending' | 'descending' | 'none' =>
    sortBy === field ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'

  const filtersAreActive = debouncedSearch !== '' || status !== 'all' || selectedTags.size > 0

  const clearFilters = () => {
    setSearch('')
    setStatus('all')
    setSelectedTags(new Set())
    setPage(1)
  }

  const toggleTag = (slug: string) =>
    setSelectedTags((s) => {
      const next = new Set(s)
      if (next.has(slug)) next.delete(slug)
      else next.add(slug)
      return next
    })

  // ─── Selection ─────────────────────────────────────────────────────

  const toggleOne = (id: string) =>
    setSelected((s) => {
      const next = new Set(s)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })

  // Selection operates over the rows currently visible in the active
  // view mode (paged flat list, or visible tree rows).
  const visiblePages = useMemo(
    () => (treeMode ? treeRows.map((r) => r.page) : pages),
    [treeMode, treeRows, pages],
  )

  const allOnPageSelected = visiblePages.length > 0 && visiblePages.every((p) => selected.has(p.id))
  const toggleAllOnPage = (checked: boolean) =>
    setSelected((s) => {
      const next = new Set(s)
      if (checked) for (const p of visiblePages) next.add(p.id)
      else for (const p of visiblePages) next.delete(p.id)
      return next
    })

  const selectedPages = useMemo(
    () => visiblePages.filter((p) => selected.has(p.id)),
    [visiblePages, selected],
  )
  const deletablePages = useMemo(() => selectedPages.filter((p) => !p.isHomepage), [selectedPages])
  const blockedHomepage = selectedPages.length !== deletablePages.length

  // ─── Bulk actions ──────────────────────────────────────────────────

  const afterBulk = () => {
    setSelected(new Set())
    setRefreshToken((t) => t + 1)
  }

  // Bulk service calls always resolve a { ok, failed } tally (they never
  // reject), so we promote a *total* failure to a rejection — that's what
  // makes `toast.promise`'s `error` branch reachable. The `await` is
  // wrapped so a rejection can't escape as an unhandled promise, and
  // `afterBulk` always runs to refresh the (possibly partially-changed) set.
  const allFailed = (r: BulkResult) => r.ok === 0 && r.failed > 0

  const runBulkStatus = async (target: PageStatus) => {
    const ids = selectedPages.map((p) => p.id)
    if (ids.length === 0) return
    const verb = target === 'PUBLISHED' ? 'Publishing' : 'Updating'
    const promise = bulkUpdatePages(ids, { status: target }).then((r) => {
      if (allFailed(r)) throw new Error('Bulk update failed')
      return r
    })
    toast.promise(promise, {
      loading: `${verb} ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
      success: (r) => `${r.ok} updated${r.failed ? `, ${r.failed} failed` : ''}`,
      error: 'Bulk update failed',
    })
    try {
      await promise
    } catch {
      /* surfaced via toast.promise */
    }
    afterBulk()
  }

  const runBulkTag = async (slug: string, mode: 'add' | 'remove') => {
    if (selectedPages.length === 0) return
    const promise = bulkChangePageTags(
      selectedPages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })),
      mode === 'add' ? { add: [slug] } : { remove: [slug] },
    ).then((r) => {
      if (allFailed(r)) throw new Error('Bulk tag change failed')
      return r
    })
    const tagName = tagBySlug.get(slug)?.name ?? slug
    toast.promise(promise, {
      loading: `${mode === 'add' ? 'Adding' : 'Removing'} "${tagName}"…`,
      success: (r) =>
        `${r.ok} page${r.ok === 1 ? '' : 's'} updated${r.failed ? `, ${r.failed} failed` : ''}`,
      error: 'Bulk tag change failed',
    })
    try {
      await promise
    } catch {
      /* surfaced via toast.promise */
    }
    afterBulk()
  }

  const runBulkDelete = async () => {
    const ids = deletablePages.map((p) => p.id)
    if (ids.length === 0) return
    const promise = bulkDeletePages(ids).then((r) => {
      if (allFailed(r)) throw new Error('Bulk delete failed')
      return r
    })
    toast.promise(promise, {
      loading: `Deleting ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
      success: (r) => `${r.ok} deleted${r.failed ? `, ${r.failed} failed` : ''}`,
      error: 'Bulk delete failed',
    })
    try {
      await promise
    } catch {
      /* surfaced via toast.promise */
    }
    afterBulk()
  }

  // ─── Row actions ───────────────────────────────────────────────────

  const runDuplicate = async (p: Page) => {
    const promise = duplicatePage(p.id).then((r) => {
      if (r.error) throw new Error(r.error)
      return r
    })
    toast.promise(promise, {
      loading: `Duplicating "${p.title}"…`,
      success: `"${p.title}" duplicated`,
      error: (e: Error) => e.message || 'Duplicate failed',
    })
    try {
      await promise
      setRefreshToken((t) => t + 1)
    } catch {
      /* surfaced via toast.promise */
    }
  }

  /** Drag-reparent from the tree view: `sourceId` becomes a child of `targetId`. */
  const runReparent = async (sourceId: string, targetId: string) => {
    const source = allPages.find((p) => p.id === sourceId)
    const target = allPages.find((p) => p.id === targetId)
    if (!source || !target) return
    const check = checkReparent(treeRoots, sourceId, targetId)
    if (!check.ok) {
      toast.error(check.reason)
      return
    }
    const promise = movePage(sourceId, targetId).then((r) => {
      if (r.error) throw new Error(r.error)
    })
    toast.promise(promise, {
      loading: `Moving "${source.title}"…`,
      success: `"${source.title}" moved under "${target.title}"`,
      error: (e: Error) => e.message || 'Move failed',
    })
    try {
      await promise
      // Make sure the new parent is expanded so the moved row stays visible.
      setExpandedOverride((prev) => {
        const next = new Set(prev ?? defaultExpanded)
        next.add(targetId)
        return next
      })
      setRefreshToken((t) => t + 1)
    } catch {
      /* surfaced via toast.promise */
    }
  }

  const runRowDelete = async (p: Page) => {
    const promise = deletePage(p.id).then((r) => {
      if (r.error) throw new Error(r.error)
    })
    toast.promise(promise, {
      loading: `Deleting "${p.title}"…`,
      success: `"${p.title}" moved to trash`,
      error: (e: Error) => e.message || 'Delete failed',
    })
    try {
      await promise
      setSelected((s) => {
        const next = new Set(s)
        next.delete(p.id)
        return next
      })
      setRefreshToken((t) => t + 1)
    } catch {
      /* surfaced via toast.promise */
    }
  }

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

  return (
    <div className="flex h-full flex-col gap-4 p-4 sm:p-6">
      {/* Header — the "+ New page" primary action sits on the right, per
          the design's `.page-hdr` / `.hdr-r` layout. */}
      <div className="flex flex-wrap items-start justify-between gap-3">
        <div className="flex flex-col gap-1">
          <h1 className="text-[var(--txt)]">Pages</h1>
          <p className="text-sm text-[var(--sub)]">
            {treeMode
              ? `${grandTotal} page${grandTotal === 1 ? '' : 's'} total`
              : `${grandTotal} page${grandTotal === 1 ? '' : 's'} · organized by tags`}
          </p>
        </div>
        <Button
          type="button"
          variant="primary"
          size="sm"
          className="shadow-sm"
          onClick={() => onNavigate('/pages/new')}
          leftIcon={<Plus size={16} aria-hidden />}
        >
          New page
        </Button>
      </div>

      {/* Controls row */}
      <div className="flex flex-wrap items-center gap-2">
        <div className="relative max-w-sm flex-1">
          <Search
            className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-[var(--sub)]"
            aria-hidden
          />
          <input
            type="search"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            aria-label="Search pages"
            placeholder="Search pages…"
            className="focus:border-primary focus:ring-primary/30 w-full rounded-lg border border-[var(--bdr)] bg-[var(--card)] py-2 pr-3 pl-9 text-sm text-[var(--txt)] shadow-sm placeholder:text-[var(--sub)] focus:ring-2 focus:outline-none"
          />
        </div>

        {/* Group-by only applies to the flat list — in tree mode the
            hierarchy is the grouping. */}
        {!treeMode && (
          <FilterSelect
            label="Group by"
            value={groupBy}
            onChange={(v) => setGroupBy(v as PageGroupBy)}
            options={[
              { value: 'none', label: 'None' },
              { value: 'tag', label: 'Tag' },
              { value: 'status', label: 'Status' },
              { value: 'author', label: 'Author' },
            ]}
          />
        )}

        <FilterSelect
          label="Status"
          value={status}
          onChange={(v) => {
            setStatus(v as PageStatus | 'all')
            setPage(1)
          }}
          options={[
            { value: 'all', label: 'All statuses' },
            { value: 'PUBLISHED', label: 'Published' },
            { value: 'DRAFT', label: 'Draft' },
            { value: 'SCHEDULED', label: 'Scheduled' },
          ]}
        />

        <ViewModeToggle mode={viewMode} onChange={changeViewMode} />

        {!treeMode && <ColumnsMenu hidden={hiddenCols} onToggle={toggleColumn} />}

        <span className="ml-auto text-sm text-[var(--sub)] tabular-nums">
          {treeMode
            ? treeFiltersActive
              ? `${treeRows.length} result${treeRows.length === 1 ? '' : 's'}`
              : `${grandTotal} page${grandTotal === 1 ? '' : 's'}`
            : `${total} of ${grandTotal}`}
        </span>
      </div>

      {/* Tag filter bar (flat list only — tree mode replaces tag grouping
          with the page hierarchy) */}
      {!treeMode && (
        <div className="flex flex-wrap items-center gap-2">
          <span className="text-[10px] font-medium tracking-wider text-[var(--sub)] uppercase">
            Tags
          </span>
          {tags.map((t) => (
            <PageTagChip
              key={t.slug}
              name={t.name}
              color={t.color}
              count={t.pageCount}
              selected={selectedTags.has(t.slug)}
              onToggle={() => toggleTag(t.slug)}
            />
          ))}
          {selectedTags.size > 0 && (
            <button
              type="button"
              onClick={() => setSelectedTags(new Set())}
              className="inline-flex items-center gap-1 text-xs text-[var(--sub)] hover:text-[var(--txt)]"
            >
              <X className="h-3 w-3" aria-hidden />
              Clear tags
            </button>
          )}
          <button
            type="button"
            onClick={() => onNavigate('/pages/tags')}
            className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
          >
            <TagIcon className="h-3 w-3" aria-hidden />
            Manage tags
          </button>
        </div>
      )}

      {/* Bulk action bar */}
      {selected.size > 0 && (
        <BulkActionBar
          count={selected.size}
          tags={tags}
          blockedHomepage={blockedHomepage}
          onClear={() => setSelected(new Set())}
          onPublish={() => runBulkStatus('PUBLISHED')}
          onDraft={() => runBulkStatus('DRAFT')}
          onAddTag={(slug) => runBulkTag(slug, 'add')}
          onRemoveTag={(slug) => runBulkTag(slug, 'remove')}
          onDelete={() => setPendingDelete(true)}
          deletableCount={deletablePages.length}
        />
      )}

      {/* Table card */}
      <div className="overflow-hidden rounded-xl border border-[var(--bdr)] bg-[var(--card)] shadow-sm">
        {treeMode ? (
          <PagesTreeTable
            rows={treeRows}
            loading={loading}
            error={error}
            flattened={treeFiltersActive}
            selected={selected}
            expanded={expanded}
            onToggleExpand={toggleExpand}
            onToggleSelect={toggleOne}
            onToggleAll={toggleAllOnPage}
            onOpen={(p) => onNavigate(`/pages/${p.id}/edit`)}
            onAddSubPage={(p) => onNavigate(`/pages/new?parent=${encodeURIComponent(p.id)}`)}
            onDuplicate={(p) => void runDuplicate(p)}
            onDelete={(p) => setRowDelete(p)}
            onReparent={(sourceId, targetId) => void runReparent(sourceId, targetId)}
            canReparent={(sourceId, targetId) => checkReparent(treeRoots, sourceId, targetId).ok}
            onRetry={() => setRefreshToken((t) => t + 1)}
            onClearFilters={clearFilters}
            onNewPage={() => onNavigate('/pages/new')}
          />
        ) : (
          <>
            {/* Horizontal scroll on narrow viewports: the fixed columns keep their
            width and the table scrolls sideways instead of crushing content. */}
            <div className="overflow-x-auto">
              <table
                className="w-full table-fixed"
                style={{ minWidth: tableMinWidth }}
                role="table"
                aria-label="Pages"
              >
                <colgroup>
                  <col style={{ width: '40px' }} />
                  <col />
                  {showTags && <col style={{ width: '200px' }} />}
                  {showAuthor && <col style={{ width: '170px' }} />}
                  <col style={{ width: '120px' }} />
                  <col style={{ width: '130px' }} />
                  {showSeo && <col style={{ width: '80px' }} />}
                  <col style={{ width: '110px' }} />
                </colgroup>
                <thead className="bg-muted/40 border-b border-[var(--bdr)]">
                  <tr className="text-xs text-[var(--sub)]">
                    <th scope="col" className="px-3 py-2.5 text-left">
                      <input
                        type="checkbox"
                        aria-label="Select all pages on this page"
                        checked={allOnPageSelected}
                        onChange={(e) => toggleAllOnPage(e.target.checked)}
                        className="accent-primary rounded border-[var(--bdr)]"
                      />
                    </th>
                    <th
                      scope="col"
                      aria-sort={ariaSortFor('title')}
                      className="px-3 py-2.5 text-left"
                    >
                      <SortHeader
                        label="Title"
                        field="title"
                        current={sortBy}
                        dir={dir}
                        onSort={handleSort}
                      />
                    </th>
                    {showTags && (
                      <th scope="col" className="px-3 py-2.5 text-left">
                        <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
                      </th>
                    )}
                    {showAuthor && (
                      <th
                        scope="col"
                        aria-sort={ariaSortFor('author')}
                        className="px-3 py-2.5 text-left"
                      >
                        <SortHeader
                          label="Author"
                          field="author"
                          current={sortBy}
                          dir={dir}
                          onSort={handleSort}
                        />
                      </th>
                    )}
                    <th
                      scope="col"
                      aria-sort={ariaSortFor('status')}
                      className="px-3 py-2.5 text-left"
                    >
                      <SortHeader
                        label="Status"
                        field="status"
                        current={sortBy}
                        dir={dir}
                        onSort={handleSort}
                      />
                    </th>
                    <th
                      scope="col"
                      aria-sort={ariaSortFor('modified')}
                      className="px-3 py-2.5 text-left"
                    >
                      <SortHeader
                        label="Last Modified"
                        field="modified"
                        current={sortBy}
                        dir={dir}
                        onSort={handleSort}
                      />
                    </th>
                    {showSeo && (
                      <th
                        scope="col"
                        aria-sort={ariaSortFor('seo')}
                        className="px-3 py-2.5 text-left"
                      >
                        <SortHeader
                          label="SEO"
                          field="seo"
                          current={sortBy}
                          dir={dir}
                          onSort={handleSort}
                        />
                      </th>
                    )}
                    <th scope="col" className="px-3 py-2.5 text-right">
                      <span className="sr-only">Actions</span>
                    </th>
                  </tr>
                </thead>
                <tbody>
                  {loading && pages.length === 0 && <LoadingRows columns={columnCount} />}
                  {!loading && error && (
                    <tr>
                      <td colSpan={columnCount} className="px-3 py-12 text-center">
                        <div className="mb-2 text-sm font-medium text-[var(--txt)]">{error}</div>
                        <button
                          onClick={() => setRefreshToken((t) => t + 1)}
                          className="text-primary text-sm hover:underline"
                        >
                          Try again
                        </button>
                      </td>
                    </tr>
                  )}
                  {!loading && !error && pages.length === 0 && (
                    <EmptyRow
                      columns={columnCount}
                      filtersActive={filtersAreActive}
                      onClear={clearFilters}
                      onNewPage={() => onNavigate('/pages/new')}
                    />
                  )}
                  {!loading &&
                    !error &&
                    displayGroups.map((group) => (
                      <GroupBlock
                        key={group.key}
                        showHeader={grouped}
                        label={group.label}
                        color={group.color}
                        count={group.pages.length}
                        columns={columnCount}
                      >
                        {group.pages.map((p) => (
                          <PageRow
                            key={p.id}
                            page={p}
                            selected={selected.has(p.id)}
                            showTags={showTags}
                            showAuthor={showAuthor}
                            showSeo={showSeo}
                            resolveTag={(slug) => tagBySlug.get(slug)}
                            onSelect={() => toggleOne(p.id)}
                            onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
                            onDuplicate={() => void runDuplicate(p)}
                            onDelete={() => setRowDelete(p)}
                          />
                        ))}
                      </GroupBlock>
                    ))}
                </tbody>
              </table>
            </div>

            {/* Pagination only for the flat (ungrouped) view. */}
            {!grouped && (
              <PaginationFooter
                page={page}
                totalPages={totalPages}
                total={total}
                pageSize={PAGE_SIZE}
                onPage={setPage}
              />
            )}
          </>
        )}
      </div>

      <ConfirmDialog
        open={rowDelete !== null}
        title={`Delete "${rowDelete?.title ?? ''}"?`}
        description="The page moves to trash and can be restored for 30 days."
        confirmLabel="Delete"
        destructive
        onClose={() => setRowDelete(null)}
        onConfirm={() => {
          if (rowDelete) void runRowDelete(rowDelete)
          setRowDelete(null)
        }}
      />

      <ConfirmDialog
        open={pendingDelete}
        title={`Delete ${deletablePages.length} page${deletablePages.length === 1 ? '' : 's'}?`}
        description={
          blockedHomepage
            ? 'The homepage cannot be deleted and will be skipped. The remaining pages move to trash and can be restored for 30 days.'
            : 'The selected pages move to trash and can be restored for 30 days.'
        }
        confirmLabel="Delete"
        destructive
        onClose={() => setPendingDelete(false)}
        onConfirm={() => {
          void runBulkDelete()
        }}
      />
    </div>
  )
}

// ─── Sub-components ──────────────────────────────────────────────────

function SortHeader({
  label,
  field,
  current,
  dir,
  onSort,
}: {
  label: string
  field: SortBy
  current: SortBy
  dir: Dir
  onSort: (f: SortBy) => void
}) {
  const active = current === field
  return (
    <button
      type="button"
      onClick={() => onSort(field)}
      className={`inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors hover:text-[var(--txt)] ${
        active ? 'text-[var(--txt)]' : 'text-[var(--sub)]'
      }`}
    >
      <span>{label}</span>
      {active &&
        (dir === 'asc' ? (
          <ChevronUp className="h-3 w-3" aria-hidden />
        ) : (
          <ChevronDown className="h-3 w-3" aria-hidden />
        ))}
    </button>
  )
}

/**
 * List / Tree segmented control (`.view-toggle` in the design). The
 * choice persists in localStorage so the preferred mode survives reloads.
 */
function ViewModeToggle({
  mode,
  onChange,
}: {
  mode: ViewMode
  onChange: (mode: ViewMode) => void
}) {
  const btn = (target: ViewMode, label: string, icon: React.ReactNode) => {
    const active = mode === target
    return (
      <button
        type="button"
        aria-label={label}
        title={label}
        aria-pressed={active}
        onClick={() => onChange(target)}
        className={`flex h-[30px] w-[34px] items-center justify-center transition-colors duration-[var(--motion-fast)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none ${
          active
            ? 'bg-[var(--acc-l)] text-[var(--acc)]'
            : 'bg-[var(--card)] text-[var(--muted)] hover:text-[var(--acc)]'
        }`}
      >
        {icon}
      </button>
    )
  }
  return (
    <div
      role="group"
      aria-label="View mode"
      className="flex overflow-hidden rounded-lg border border-[var(--bdr)] shadow-sm"
    >
      {btn('list', 'List view', <List size={15} aria-hidden />)}
      <span aria-hidden className="w-px bg-[var(--bdr)]" />
      {btn('tree', 'Tree view', <ListTree size={15} aria-hidden />)}
    </div>
  )
}

/**
 * Column visibility menu — the product translation of the design's
 * floating "Tweaks" panel. Optional columns can be hidden per user;
 * the choice persists in localStorage alongside the view mode.
 */
function ColumnsMenu({
  hidden,
  onToggle,
}: {
  hidden: Set<ToggleableColumn>
  onToggle: (key: ToggleableColumn) => void
}) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button
          type="button"
          aria-label="Choose visible columns"
          className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-sm text-[var(--txt)] shadow-sm transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
        >
          <Columns3 size={15} aria-hidden />
          Columns
          <ChevronDown className="h-3 w-3 text-[var(--sub)]" aria-hidden />
        </button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal container={adminPortalContainer()}>
        <DropdownMenu.Content
          align="end"
          sideOffset={4}
          className="bg-popover z-50 w-44 rounded-lg border border-[var(--bdr)] p-1 shadow-md"
        >
          <DropdownMenu.Label className="px-2 py-1.5 text-[10px] font-medium tracking-wider text-[var(--sub)] uppercase">
            Columns
          </DropdownMenu.Label>
          {TOGGLEABLE_COLUMNS.map((col) => {
            const visible = !hidden.has(col.key)
            return (
              <DropdownMenu.CheckboxItem
                key={col.key}
                checked={visible}
                onCheckedChange={() => onToggle(col.key)}
                onSelect={(e) => e.preventDefault()}
                className="focus:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-[var(--txt)] outline-none"
              >
                <span
                  aria-hidden
                  className={`flex h-4 w-4 items-center justify-center rounded border ${
                    visible
                      ? 'bg-primary border-transparent text-white'
                      : 'border-[var(--bdr)] bg-[var(--card)]'
                  }`}
                >
                  {visible && <Check size={11} strokeWidth={3} />}
                </span>
                {col.label}
              </DropdownMenu.CheckboxItem>
            )
          })}
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

function FilterSelect({
  label,
  value,
  onChange,
  options,
}: {
  label: string
  value: string
  onChange: (v: string) => void
  options: { value: string; label: string }[]
}) {
  return (
    <label className="inline-flex items-center gap-1.5 text-sm text-[var(--txt)]">
      <span className="text-[var(--sub)]">{label}:</span>
      <select
        value={value}
        onChange={(e) => onChange(e.target.value)}
        className="focus:border-primary focus:ring-primary/30 rounded-lg border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-sm text-[var(--txt)] shadow-sm focus:ring-2 focus:outline-none"
      >
        {options.map((o) => (
          <option key={o.value} value={o.value}>
            {o.label}
          </option>
        ))}
      </select>
    </label>
  )
}

function GroupBlock({
  showHeader,
  label,
  color,
  count,
  columns,
  children,
}: {
  showHeader: boolean
  label: string
  color: string | null
  count: number
  columns: number
  children: React.ReactNode
}) {
  return (
    <>
      {showHeader && (
        <tr className="bg-muted/30 border-b border-[var(--bdr)]">
          <td colSpan={columns} className="px-3 py-2">
            <span className="inline-flex items-center gap-2 text-xs font-medium tracking-wider text-[var(--txt)] uppercase">
              {color && <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(color)}`} />}
              {label}
              <span className="text-[var(--sub)] tabular-nums">{count}</span>
            </span>
          </td>
        </tr>
      )}
      {children}
    </>
  )
}

function dotFor(color: string): string {
  const map: Record<string, string> = {
    purple: 'bg-brand',
    green: 'bg-success',
    orange: 'bg-warning',
    red: 'bg-destructive',
    blue: 'bg-info',
    pink: 'bg-chart-4',
  }
  return map[color] ?? 'bg-muted-foreground'
}

function BulkActionBar({
  count,
  tags,
  blockedHomepage,
  deletableCount,
  onClear,
  onPublish,
  onDraft,
  onAddTag,
  onRemoveTag,
  onDelete,
}: {
  count: number
  tags: PageTag[]
  blockedHomepage: boolean
  deletableCount: number
  onClear: () => void
  onPublish: () => void
  onDraft: () => void
  onAddTag: (slug: string) => void
  onRemoveTag: (slug: string) => void
  onDelete: () => void
}) {
  return (
    <div className="border-primary/30 bg-primary/5 flex flex-wrap items-center gap-2 rounded-lg border px-4 py-2">
      <span className="text-sm font-medium text-[var(--txt)]">{count} selected</span>
      <div className="bg-border h-4 w-px" aria-hidden />
      <button
        type="button"
        onClick={onPublish}
        className="rounded-md px-2 py-1 text-sm text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)]"
      >
        Publish
      </button>
      <button
        type="button"
        onClick={onDraft}
        className="rounded-md px-2 py-1 text-sm text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)]"
      >
        Move to draft
      </button>
      <TagMenu label="Add tag" tags={tags} onPick={onAddTag} />
      <TagMenu label="Remove tag" tags={tags} onPick={onRemoveTag} />
      <button
        type="button"
        onClick={onDelete}
        disabled={deletableCount === 0}
        title={blockedHomepage ? 'The homepage is excluded from deletion' : undefined}
        className="text-destructive hover:bg-destructive/10 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
      >
        <Trash2 className="h-3.5 w-3.5" aria-hidden />
        Delete
      </button>
      <button
        type="button"
        onClick={onClear}
        className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-[var(--sub)] transition-colors hover:text-[var(--txt)]"
      >
        <X className="h-3.5 w-3.5" aria-hidden />
        Clear
      </button>
    </div>
  )
}

function TagMenu({
  label,
  tags,
  onPick,
}: {
  label: string
  tags: PageTag[]
  onPick: (slug: string) => void
}) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button
          type="button"
          className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)]"
        >
          {label}
          <ChevronDown className="h-3 w-3" aria-hidden />
        </button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal container={adminPortalContainer()}>
        <DropdownMenu.Content
          align="start"
          sideOffset={4}
          className="bg-popover z-50 max-h-64 w-44 overflow-auto rounded-lg border border-[var(--bdr)] p-1 shadow-md"
        >
          {tags.length === 0 ? (
            <div className="px-2 py-1.5 text-xs text-[var(--sub)]">No tags yet</div>
          ) : (
            tags.map((t) => (
              <DropdownMenu.Item
                key={t.slug}
                onSelect={() => onPick(t.slug)}
                className="focus:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-[var(--txt)] outline-none"
              >
                <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}`} />
                {t.name}
              </DropdownMenu.Item>
            ))
          )}
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

function PageRow({
  page,
  selected,
  showTags,
  showAuthor,
  showSeo,
  resolveTag,
  onSelect,
  onOpen,
  onDuplicate,
  onDelete,
}: {
  page: Page
  selected: boolean
  showTags: boolean
  showAuthor: boolean
  showSeo: boolean
  resolveTag: (slug: string) => { name: string; color: string | null } | undefined
  onSelect: () => void
  onOpen: () => void
  onDuplicate: () => void
  onDelete: () => void
}) {
  return (
    <tr
      className={`group border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)] ${
        selected ? 'bg-[var(--acc-l)]' : ''
      }`}
    >
      <td className="px-3 py-3" onClick={(e) => e.stopPropagation()}>
        <input
          type="checkbox"
          aria-label={`Select "${page.title}"`}
          checked={selected}
          onChange={onSelect}
          className="accent-primary rounded border-[var(--bdr)]"
        />
      </td>
      <td className="px-3 py-3">
        <button type="button" onClick={onOpen} className="block w-full min-w-0 text-left">
          <span className="block truncate text-sm font-medium text-[var(--txt)] group-hover:text-[var(--acc)]">
            {page.title}
          </span>
          <span className="block truncate text-xs text-[var(--sub)]">{page.path}</span>
        </button>
      </td>
      {showTags && (
        <td className="px-3 py-3">
          <PageTagsCell
            tagSlugs={page.tags}
            primaryTagId={page.primaryTagId}
            resolve={resolveTag}
          />
        </td>
      )}
      {showAuthor && (
        <td className="px-3 py-3">
          <div className="flex items-center gap-2">
            <AuthorAvatar name={page.authorName} size={28} />
            <span className="truncate text-sm text-[var(--txt)]">{page.authorName}</span>
          </div>
        </td>
      )}
      <td className="px-3 py-3">
        <PostStatusBadge status={page.status} />
      </td>
      <td className="px-3 py-3 text-sm text-[var(--sub)] tabular-nums">
        {formatDateShort(page.lastModified)}
      </td>
      {showSeo && (
        <td className="px-3 py-3">
          <SeoScoreIndicator score={page.seoScore} />
        </td>
      )}
      <td className="px-3 py-3">
        <RowActions>
          <RowActionButton aria-label={`Edit "${page.title}"`} title="Edit" onClick={onOpen}>
            <PenLine size={13} aria-hidden />
          </RowActionButton>
          <RowActionButton
            aria-label={`Duplicate "${page.title}"`}
            title="Duplicate"
            onClick={onDuplicate}
          >
            <Copy size={12} aria-hidden />
          </RowActionButton>
          <RowActionButton
            aria-label={`Delete "${page.title}"`}
            title={page.isHomepage ? 'The homepage cannot be deleted' : 'Delete'}
            danger
            disabled={page.isHomepage}
            onClick={onDelete}
          >
            <Trash2 size={12} aria-hidden />
          </RowActionButton>
        </RowActions>
      </td>
    </tr>
  )
}

function LoadingRows({ columns }: { columns: number }) {
  return (
    <>
      {Array.from({ length: 8 }).map((_, i) => (
        <tr key={i} className="border-b border-[var(--bdr)] last:border-b-0">
          {Array.from({ length: columns }).map((__, j) => (
            <td key={j} className="px-3 py-3">
              <div className="bg-muted h-4 w-full animate-pulse rounded" />
            </td>
          ))}
        </tr>
      ))}
    </>
  )
}

function EmptyRow({
  columns,
  filtersActive,
  onClear,
  onNewPage,
}: {
  columns: number
  filtersActive: boolean
  onClear: () => void
  onNewPage: () => void
}) {
  return (
    <tr>
      <td colSpan={columns} className="px-3 py-16 text-center">
        <div className="mb-2 text-sm font-medium text-[var(--txt)]">
          {filtersActive ? 'No pages match your filters' : 'No pages yet'}
        </div>
        <div className="mb-4 text-sm text-[var(--sub)]">
          {filtersActive
            ? 'Try clearing the search, tags, or status filter above.'
            : 'Create your first page to get started.'}
        </div>
        {filtersActive ? (
          <button type="button" onClick={onClear} className="text-primary text-sm hover:underline">
            Clear filters
          </button>
        ) : (
          <Button
            type="button"
            variant="primary"
            size="sm"
            className="shadow-sm"
            onClick={onNewPage}
            leftIcon={<Plus size={16} aria-hidden />}
          >
            New page
          </Button>
        )}
      </td>
    </tr>
  )
}

function PaginationFooter({
  page,
  totalPages,
  total,
  pageSize,
  onPage,
}: {
  page: number
  totalPages: number
  total: number
  pageSize: number
  onPage: (p: number) => void
}) {
  const start = total === 0 ? 0 : (page - 1) * pageSize + 1
  const end = Math.min(total, page * pageSize)
  return (
    <div className="flex items-center justify-between border-t border-[var(--bdr)] px-4 py-3 text-sm">
      <span className="text-[var(--sub)] tabular-nums">
        {start}–{end} of {total} page{total === 1 ? '' : 's'}
      </span>
      <nav className="flex items-center gap-1" aria-label="Pages pagination">
        <button
          type="button"
          onClick={() => onPage(Math.max(1, page - 1))}
          disabled={page === 1}
          aria-label="Previous page"
          className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--bdr)] bg-[var(--bg)] text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)] disabled:cursor-not-allowed disabled:opacity-40"
        >
          <ChevronLeft className="h-4 w-4" aria-hidden />
        </button>
        {pageNumbers(page, totalPages).map((p, idx) =>
          p === '…' ? (
            <span
              key={`gap-${idx}`}
              aria-hidden
              className="inline-flex h-8 w-8 items-center justify-center text-sm text-[var(--sub)]"
            >
              …
            </span>
          ) : (
            <button
              key={p}
              type="button"
              onClick={() => onPage(p)}
              aria-current={p === page ? 'page' : undefined}
              className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm tabular-nums transition-colors ${
                p === page
                  ? 'bg-primary text-primary-foreground'
                  : 'border border-[var(--bdr)] bg-[var(--bg)] text-[var(--txt)] hover:bg-[var(--acc-l)]'
              }`}
            >
              {p}
            </button>
          ),
        )}
        <button
          type="button"
          onClick={() => onPage(Math.min(totalPages, page + 1))}
          disabled={page === totalPages}
          aria-label="Next page"
          className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--bdr)] bg-[var(--bg)] text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)] disabled:cursor-not-allowed disabled:opacity-40"
        >
          <ChevronRight className="h-4 w-4" aria-hidden />
        </button>
      </nav>
    </div>
  )
}

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

function formatDateShort(input: string): string {
  const d = new Date(input)
  if (Number.isNaN(d.getTime())) return '—'
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}

export function pageNumbers(current: number, total: number): Array<number | '…'> {
  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
  const out: Array<number | '…'> = [1]
  const start = Math.max(2, current - 1)
  const end = Math.min(total - 1, current + 1)
  if (start > 2) out.push('…')
  for (let i = start; i <= end; i++) out.push(i)
  if (end < total - 1) out.push('…')
  out.push(total)
  return out
}
