import { useEffect, useMemo, useState } from 'react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { withModuleNav } from '../../../modules/nav.js'
import { useFeatureGate } from '../../../modules/features.jsx'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js'
import { refreshNavOverrides } from '../../../lib/useNavOverrides.js'
import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx'
import { NAV as ADMIN_NAV, navItemVisibleTo } from '../AdminLayout.jsx'
import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'

// Admin · Navigation — phases 6-8 of docs/website/THEMING_AND_NAV.md.
//
// The three navs stay declared in code, each in the component that renders it;
// this screen writes an override *layer* over them (§7). It can relabel,
// reorder, hide and — on the admin sidebar — move a row into another existing
// section, and nothing else. It cannot introduce a route and it cannot touch a
// `roles` or `feature` gate, so the filters in the layouts still decide who sees
// what, and they run after the merge.
//
// Three things shape the screen:
//
//   • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
//     the base array run through their role and this shard's feature gates. An
//     admin cannot drag in, and so can never accidentally advertise, something
//     they cannot see themselves. An override on a row they cannot see is
//     carried through their save untouched rather than quietly reset.
//   • The rows come from the same merge the site renders (buildNavRows), hidden
//     ones included, so the editor cannot show an order the nav does not use.
//   • Saving writes a settings row; "reset" DELETES it. Absence of the row is
//     what selects the coded default, so reset cannot store a copy of it — and a
//     save whose result is empty deletes the row for the same reason (§4.1).

// The nav editor's own row. Hiding it would remove the only screen that can
// un-hide it, so its eye toggle is disabled here and the server drops `hidden`
// on it as well (server/src/utils/navOverrides.js) — a hand-written row cannot
// do what the UI refuses.
const SELF = '/admin/navigation'

const TABS = [
  { key: 'nav_public', label: 'Public site', hint: 'The header on every public page.' },
  { key: 'nav_admin', label: 'Admin', hint: 'This sidebar. Rows can also move between sections.' },
  { key: 'nav_player', label: 'Player portal', hint: 'The sidebar a signed-in player sees.' },
]

function DragHandle({ attributes, listeners, disabled }) {
  return (
    <button
      type="button"
      className="sans"
      aria-label="Reorder"
      disabled={disabled}
      {...attributes}
      {...listeners}
      style={{
        border: 'none',
        background: 'transparent',
        color: 'var(--dim)',
        cursor: disabled ? 'default' : 'grab',
        padding: '2px 4px',
        touchAction: 'none',
      }}
    >
      <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
        <circle cx="9" cy="6" r="1.6" />
        <circle cx="15" cy="6" r="1.6" />
        <circle cx="9" cy="12" r="1.6" />
        <circle cx="15" cy="12" r="1.6" />
        <circle cx="9" cy="18" r="1.6" />
        <circle cx="15" cy="18" r="1.6" />
      </svg>
    </button>
  )
}

function EyeIcon({ off }) {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
      <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
      <circle cx="12" cy="12" r="3" />
      {off && <path d="M3 3l18 18" />}
    </svg>
  )
}

/**
 * One editable nav row, shared by all three tabs.
 *
 * The destination control is generic because the two navs that have one mean
 * different things by it: the admin sidebar moves rows between the four coded
 * sections, the public header between admin-created dropdowns. Both are "pick a
 * container", so both get one `<select>` rather than cross-container dragging —
 * which is a lot of interaction surface for something an admin does once.
 *
 * @param {Array<{value: string, label: string}>} [destinations]  omit for a nav
 *   with no containers (the player portal)
 * @param {() => void} [onDelete]  only an admin-authored link can be deleted;
 *   a coded row is hidden, never removed
 */
export function Row({ row, id, destinations, destination, onDestination, onChange, onDelete }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
  const renamed = row.defaultLabel !== undefined && row.label !== row.defaultLabel
  const locked = row.to === SELF

  return (
    <li
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
        display: 'flex',
        alignItems: 'center',
        gap: 8,
        padding: '7px 10px',
        borderRadius: 'var(--radius-input)',
        border: '1px solid var(--line)',
        background: isDragging ? 'var(--blue)' : 'var(--panel-flat)',
        opacity: row.hidden ? 0.55 : 1,
        listStyle: 'none',
      }}
    >
      <DragHandle attributes={attributes} listeners={listeners} />
      <input
        className="input"
        value={row.label}
        placeholder={row.defaultLabel || row.to}
        maxLength={64}
        onChange={(e) => onChange({ ...row, label: e.target.value })}
        aria-label={`Label for ${row.defaultLabel || row.to}`}
        style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem' }}
      />
      {/* The route, for orientation — it is what the override is keyed by. Fixed
          and truncating rather than flexible: /admin/moderation/appeals would
          otherwise wrap and squeeze the label input it sits beside. */}
      <code
        className="sans dim"
        title={row.to}
        style={{
          flex: '0 0 auto',
          width: 130,
          fontSize: '0.7rem',
          opacity: 0.75,
          overflow: 'hidden',
          textOverflow: 'ellipsis',
          whiteSpace: 'nowrap',
          textAlign: 'right',
        }}
      >
        {row.to}
      </code>
      {renamed && (
        <button
          type="button"
          className="sans"
          title="Use the coded label again"
          onClick={() => onChange({ ...row, label: row.defaultLabel })}
          style={{ border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: '0.72rem', cursor: 'pointer', padding: 0 }}
        >
          reset
        </button>
      )}
      {destinations && destinations.length > 0 && (
        <select
          className="select"
          value={destination ?? ''}
          onChange={(e) => onDestination(e.target.value || null)}
          aria-label={`Section for ${row.defaultLabel || row.to}`}
          style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
        >
          {destinations.map((d) => (
            <option key={d.value} value={d.value}>
              {d.label}
            </option>
          ))}
        </select>
      )}
      {onDelete && (
        <button
          type="button"
          className="sans"
          title="Remove this link"
          onClick={onDelete}
          style={{
            border: '1px solid var(--line)',
            borderRadius: 'var(--radius-input)',
            background: 'transparent',
            color: 'var(--muted)',
            cursor: 'pointer',
            padding: '4px 8px',
            fontSize: '0.76rem',
          }}
        >
          ×
        </button>
      )}
      {/* A coded row is hidden, never removed — the route still exists. An
          admin-authored link is the opposite: there is nothing to fall back to,
          so it is deleted instead (the × above). */}
      {!onDelete && (
      <button
        type="button"
        className="sans"
        disabled={locked}
        title={
          locked
            ? 'This screen is the only way back — it cannot be hidden'
            : row.hidden
              ? 'Currently hidden. Show it again'
              : 'Hide from this nav'
        }
        aria-pressed={row.hidden}
        onClick={() => onChange({ ...row, hidden: !row.hidden })}
        style={{
          border: '1px solid var(--line)',
          borderRadius: 'var(--radius-input)',
          background: 'transparent',
          color: locked ? 'var(--dim)' : row.hidden ? 'var(--accent)' : 'var(--muted)',
          cursor: locked ? 'not-allowed' : 'pointer',
          padding: '4px 6px',
          display: 'flex',
          opacity: locked ? 0.5 : 1,
        }}
      >
        <EyeIcon off={row.hidden} />
      </button>
      )}
    </li>
  )
}

export default function NavEditor() {
  const { user } = useAuth()
  const { refresh: refreshSite } = useSite()
  const isVisible = useFeatureGate()
  const [tab, setTab] = useState('nav_public')
  // Per nav: the editable groups, the overrides as loaded (so a row this admin
  // cannot see survives their save), and whether a settings row exists at all.
  const [state, setState] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')
  const [busy, setBusy] = useState(false)
  const [saved, setSaved] = useState('')
  const [dirty, setDirty] = useState({})

  // The nav as coded, unfiltered. The palette below is what this admin may EDIT;
  // this is what still EXISTS, and the two are different questions. Saving needs
  // both: an entry for a row their palette filtered out must be carried through
  // rather than reset, and only an entry for a route the code no longer declares
  // at all should be dropped.
  //
  // Each nav is the coded array with every installed module's rows already
  // interleaved (modules/nav.js) — the same array the layout renders, which is
  // what makes a module row editable here at all: the override merge is keyed by
  // `to` and drops a key the base it is handed does not declare, so a nav built
  // from core alone would silently discard every stored override on a module row
  // the moment it was saved.
  const fullNavs = useMemo(
    () => ({
      nav_public: withModuleNav(PUBLIC_NAV, 'public'),
      nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
      nav_player: withModuleNav(PLAYER_NAV, 'player'),
    }),
    [],
  )

  // The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
  // gates, and neither is core's own opinion any more — `roles` on a row, and
  // the owning module's answer for a row that names a `feature`.
  const palettes = useMemo(
    () => ({
      nav_public: fullNavs.nav_public.filter(isVisible),
      nav_admin: fullNavs.nav_admin
        .map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
        .filter((g) => g.items.length > 0),
      nav_player: fullNavs.nav_player.filter(isVisible),
    }),
    [fullNavs, isVisible, user?.role],
  )

  useEffect(() => {
    let active = true
    api.admin
      .getSettings()
      .then((all) => {
        if (!active) return
        const next = {}
        for (const { key } of TABS) {
          const stored = parseJsonSetting(all[key])
          // The public header is a tree (sections are entries in the top-level
          // order); the other two are the fixed-frame grouped/flat shape.
          next[key] =
            key === 'nav_public'
              ? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) }
              : { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
        }
        setState(next)
      })
      .catch(() => active && setError('Could not load the navigation settings.'))
      .finally(() => active && setLoading(false))
    return () => {
      active = false
    }
    // Loaded once; the palettes settle before the fetch resolves in practice, and
    // re-running on a feature flip would discard the admin's unsaved edits.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  )

  if (loading) return <Loading />
  if (error && !state) return <ErrorState message={error} />

  const current = state[tab]
  const isPublic = tab === 'nav_public'
  const groupTitles = isPublic ? [] : current.groups.map((g) => g.title).filter(Boolean)
  // Where each row is declared in code, so the section dropdown can offer only
  // the destinations an override is able to express.
  const baseGroups = new Map(
    (!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items
      ? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
      : []),
  )
  // The admin sidebar can only move a row between the four coded sections, and
  // "(no section)" only for a row coded into an untitled one — for anything else
  // it is a move an override cannot express (§6.4), so offering it would
  // silently do nothing.
  const groupDestinations = (baseGroup) => [
    ...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []),
    ...groupTitles.map((t) => ({ value: t, label: t })),
  ]

  function mutate(updater) {
    setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
    setDirty((d) => ({ ...d, [tab]: true }))
    setSaved('')
  }

  function setTree(tree) {
    setState((s) => ({ ...s, [tab]: { ...s[tab], tree } }))
    setDirty((d) => ({ ...d, [tab]: true }))
    setSaved('')
  }

  const onRowChange = (next) =>
    mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) })))

  // Sections change by dropdown, not by dragging: a drag that could land in
  // another list is a lot of interaction surface for something an admin does
  // once, and this keeps every drag a simple reorder. The row goes to the end of
  // its new section, where it is visible and can then be dragged into place.
  const onMoveGroup = (to, title) =>
    mutate((groups) => {
      const moving = groups.flatMap((g) => g.items).find((i) => i.to === to)
      if (!moving) return groups
      return groups.map((g) => {
        if ((g.title ?? null) === title) return { ...g, items: [...g.items.filter((i) => i.to !== to), moving] }
        return { ...g, items: g.items.filter((i) => i.to !== to) }
      })
    })

  const onDragEnd = (groupIndex) => (event) => {
    const { active, over } = event
    if (!over || active.id === over.id) return
    mutate((groups) =>
      groups.map((g, i) => {
        if (i !== groupIndex) return g
        const from = g.items.findIndex((it) => it.to === active.id)
        const to = g.items.findIndex((it) => it.to === over.id)
        if (from < 0 || to < 0) return g
        return { ...g, items: arrayMove(g.items, from, to) }
      }),
    )
  }

  // Push a save into whatever is rendering that nav right now, so the admin sees
  // what they just did: the header re-reads the public settings, the two
  // authenticated sidebars re-read /settings/nav.
  async function propagate(key) {
    if (key === 'nav_public') await refreshSite()
    else await refreshNavOverrides()
  }

  async function save() {
    setBusy(true)
    setError('')
    try {
      const overrides = isPublic
        ? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored)
        : buildNavOverrides(current.groups, fullNavs[tab], current.stored)
      // A wrapper with an empty `items` and no sections/links says nothing
      // either, so "empty" is about the whole value, not just its key count.
      const empty =
        Object.keys(overrides).length === 0 ||
        (overrides.items !== undefined &&
          Object.keys(overrides.items).length === 0 &&
          !overrides.sections?.length &&
          !overrides.links?.length)
      // Nothing differs from the code default, so there is nothing to store —
      // and a row that says nothing would still read as "this nav was
      // customised". Delete it instead (§2, §4.1).
      if (empty) await api.admin.resetSetting(tab)
      else await api.admin.updateSettings({ [tab]: overrides })
      setState((s) => ({
        ...s,
        [tab]: { ...s[tab], stored: empty ? null : overrides, hasRow: !empty },
      }))
      setDirty((d) => ({ ...d, [tab]: false }))
      setSaved(tab)
      await propagate(tab)
    } catch (err) {
      setError(err.message || 'Could not save this navigation.')
    } finally {
      setBusy(false)
    }
  }

  async function resetNav() {
    setBusy(true)
    setError('')
    try {
      await api.admin.resetSetting(tab)
      setState((s) => ({
        ...s,
        [tab]: isPublic
          ? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) }
          : { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) },
      }))
      setDirty((d) => ({ ...d, [tab]: false }))
      setSaved('')
      await propagate(tab)
    } catch (err) {
      setError(err.message || 'Could not reset this navigation.')
    } finally {
      setBusy(false)
    }
  }

  const activeTab = TABS.find((t) => t.key === tab)

  return (
    <section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
      <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
        Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this
        only decides what is advertised, and it can never show anyone a link their role or this shard&rsquo;s
        visibility settings would hide.
      </p>

      {/* ── Tabs ───────────────────────────────────────────────── */}
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
        {TABS.map((t) => (
          <button
            key={t.key}
            type="button"
            className="sans"
            onClick={() => {
              setTab(t.key)
              setSaved('')
            }}
            aria-pressed={tab === t.key}
            style={{
              padding: '8px 14px',
              borderRadius: 'var(--radius-input)',
              border: `1px solid ${tab === t.key ? 'var(--accent)' : 'var(--line)'}`,
              background: tab === t.key ? 'var(--blue)' : 'transparent',
              color: tab === t.key ? 'var(--ink)' : 'var(--muted)',
              cursor: 'pointer',
              fontSize: '0.86rem',
            }}
          >
            {t.label}
            {dirty[t.key] && <span style={{ color: 'var(--accent)' }}> •</span>}
          </button>
        ))}
      </div>

      <span className="sans dim" style={{ fontSize: '0.76rem' }}>
        {activeTab.hint}{' '}
        {current.hasRow
          ? 'This nav has saved overrides.'
          : 'This nav has never been customised, so it renders exactly as coded.'}
      </span>

      {/* ── Rows ───────────────────────────────────────────────── */}
      {/* The public header gets its own editor: a section there is an entry in
          the top-level order that an admin created, not a fixed frame the code
          declares, so it is a tree rather than a list of groups. */}
      {isPublic ? (
        <PublicNavTree tree={current.tree} onChange={setTree} />
      ) : (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
        {current.groups.map((group, groupIndex) => (
          <div key={group.title ?? `group-${groupIndex}`}>
            {group.title && <span className="field-label">{group.title}</span>}
            <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(groupIndex)}>
              <SortableContext items={group.items.map((i) => i.to)} strategy={verticalListSortingStrategy}>
                <ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '8px 0 0', padding: 0 }}>
                  {group.items.map((row) => (
                    <Row
                      key={row.to}
                      id={row.to}
                      row={row}
                      destinations={groupTitles.length > 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null}
                      destination={group.title ?? ''}
                      onDestination={(value) => onMoveGroup(row.to, value)}
                      onChange={onRowChange}
                    />
                  ))}
                  {group.items.length === 0 && (
                    <li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '6px 2px' }}>
                      Empty — this section is not rendered until something is moved into it.
                    </li>
                  )}
                </ul>
              </SortableContext>
            </DndContext>
          </div>
        ))}
      </div>
      )}

      <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
        <button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
          {busy ? 'Saving…' : 'Save navigation'}
        </button>
        <button
          onClick={resetNav}
          disabled={busy || !current.hasRow}
          className="pill"
          title={current.hasRow ? 'Delete the saved overrides for this nav' : 'Nothing to reset'}
        >
          Reset to default
        </button>
        {saved === tab && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
        {error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
      </div>

      <p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
        Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard
        Visibility keeps whatever it was already set to.
      </p>
    </section>
  )
}
