import {
  createContext,
  memo,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type MutableRefObject,
} from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router-dom";
import { Pencil } from "lucide-react";
import {
  DndContext,
  MouseSensor,
  TouchSensor,
  useSensor,
  useSensors,
  closestCenter,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
  arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type {
  RepoGroup,
  SessionResponse,
  SessionStatus,
  Workspace,
} from "../lib/types";
import { MULTI_REPO_GROUP_ID } from "../hooks/useRepoGroups";
import {
  STATUS_DOT_CLASS,
  getStatusTextClass,
  isSessionActive,
} from "../lib/session";
import { useIdleDecayWindowMs } from "../lib/idleDecay";
import { renameSession, setSessionNotifications } from "../lib/api";
import { useServerDown, OFFLINE_TITLE } from "../lib/connectionState";
import { useHasDraftForSessions } from "../lib/cockpitDrafts";
import { StatusGlyph } from "./StatusGlyph";
import { OwnerAvatar } from "./OwnerAvatar";

const SIDEBAR_WIDTH_KEY = "aoe-sidebar-width";
const DEFAULT_WIDTH = 280;
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;

// Module-level bus for closing any open SessionRow context menu when a
// new one opens. Each SessionRow manages its own menu state; without
// this bus, long-pressing a second session on mobile leaves the first
// menu visible because document "click" listeners don't fire on
// touchstart. Publishing on open + subscribing here keeps "one menu at
// a time" without lifting state up to the parent.
const menuBus = new EventTarget();
function closeOtherContextMenus() {
  menuBus.dispatchEvent(new Event("close"));
}

interface Props {
  groups: RepoGroup[];
  onReorderWorkspaces: (newOrder: string[]) => void;
  activeId: string | null;
  open: boolean;
  onToggle: () => void;
  onSelect: (workspaceId: string) => void;
  onToggleRepo: (repoId: string) => void;
  onNew: () => void;
  onCreateSession: (repoPath: string) => void;
  onSettings: () => void;
  onProjects: () => void;
  onDeleteSession?: (workspaceId: string) => void;
  readOnly?: boolean;
}

function bestSession(
  ws: Workspace,
  idleDecayWindowMs: number,
): {
  status: SessionStatus;
  createdAt: string | null;
  idleEnteredAt: string | null;
} {
  const running = ws.sessions.find((s) => isSessionActive(s, idleDecayWindowMs));
  if (running)
    return {
      status: running.status,
      createdAt: running.created_at,
      idleEnteredAt: running.idle_entered_at ?? null,
    };
  const error = ws.sessions.find((s) => s.status === "Error");
  if (error)
    return {
      status: "Error",
      createdAt: error.created_at,
      idleEnteredAt: null,
    };
  const first = ws.sessions[0];
  return {
    status: first?.status ?? "Unknown",
    createdAt: first?.created_at ?? null,
    idleEnteredAt: first?.idle_entered_at ?? null,
  };
}

/** Derive which of the three context-menu presets best describes a
 *  session's current per-event notification overrides. If the three
 *  overrides aren't all the same value, the session is in a "custom"
 *  mixed state, which the context menu renders as "Default" too
 *  (selecting "Default" then resets it cleanly). */
type NotifyPreset = "off" | "default" | "all";
function detectNotifyPreset(
  waiting: boolean | null | undefined,
  idle: boolean | null | undefined,
  error: boolean | null | undefined,
): NotifyPreset {
  if (waiting === false && idle === false && error === false) return "off";
  if (waiting === true && idle === true && error === true) return "all";
  return "default";
}

function loadSavedWidth(): number {
  try {
    const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
    if (saved) {
      const w = parseInt(saved, 10);
      if (w >= MIN_WIDTH && w <= MAX_WIDTH) return w;
    }
  } catch {
    // ignore
  }
  return DEFAULT_WIDTH;
}

/** One-line sidebar affordance showing plan progress for cockpit
 *  sessions that have emitted a Plan. Quiet by default (renders only
 *  when `summary.total > 0`); mirrors the top-of-cockpit PlanStrip's
 *  visual language so the sidebar and main view stay consistent. See
 *  #1061. */
function PlanProgressMini({
  summary,
}: {
  summary: NonNullable<SessionResponse["plan_summary"]>;
}) {
  const pct =
    summary.total > 0
      ? Math.min(100, Math.round((summary.completed / summary.total) * 100))
      : 0;
  const title = summary.current_step_title ?? "plan in progress";
  const ariaLabel = summary.current_step_title
    ? `Plan progress: ${summary.completed} of ${summary.total} steps; current step ${summary.current_step_title}`
    : `Plan progress: ${summary.completed} of ${summary.total} steps`;
  return (
    <div className="mt-1 flex items-center gap-2" title={title}>
      <div
        role="progressbar"
        aria-valuenow={summary.completed}
        aria-valuemin={0}
        aria-valuemax={summary.total}
        aria-label={ariaLabel}
        className="h-1 flex-1 rounded-full bg-surface-800 overflow-hidden"
      >
        <div
          className="h-full bg-brand-400 transition-all"
          style={{ width: `${pct}%` }}
        />
      </div>
      <span className="text-[10px] font-mono tabular-nums text-text-dim shrink-0">
        {summary.completed}/{summary.total}
      </span>
    </div>
  );
}

/** Sidebar chip that ticks down to a `ScheduleWakeup` fire time. Self-
 *  destructs when the wake passes (sets local count to 0 and renders
 *  "waking…"; the next sessions-endpoint refresh removes the underlying
 *  field). 1Hz timer is local to the row so we don't fan out a global
 *  tick across the sidebar. See #1091. */
function WakeupCountdown({
  wakeAt,
  reason,
}: {
  wakeAt: string;
  reason: string | null | undefined;
}) {
  const targetMs = Date.parse(wakeAt);
  const [now, setNow] = useState(() => Date.now());
  const elapsed = !Number.isFinite(targetMs) || targetMs <= now;
  useEffect(() => {
    if (elapsed) return;
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, [elapsed]);
  if (!Number.isFinite(targetMs)) return null;
  const remaining = Math.max(0, Math.floor((targetMs - now) / 1000));
  const label = elapsed ? "waking…" : `in ${formatDurationSecondsShort(remaining)}`;
  const title = reason
    ? `Scheduled wakeup: ${reason}`
    : "Scheduled wakeup";
  return (
    <span
      title={title}
      aria-label={`Scheduled wakeup ${label}`}
      className="inline-flex shrink-0 items-center gap-0.5 rounded border border-sky-700/40 bg-sky-950/30 px-1 py-0 text-[10px] font-medium text-sky-300"
    >
      <span aria-hidden="true">⏰</span>
      {label}
    </span>
  );
}

/** Compact duration formatting used by the wakeup chip: `45s`, `3m`,
 *  `1h 7m`. Drops sub-minute resolution above one minute since the chip
 *  is read at a glance. */
function formatDurationSecondsShort(seconds: number): string {
  if (seconds < 60) return `${seconds}s`;
  const m = Math.floor(seconds / 60);
  if (m < 60) return `${m}m`;
  const h = Math.floor(m / 60);
  const remM = m % 60;
  return remM === 0 ? `${h}h` : `${h}h ${remM}m`;
}

function isPlainLeftClick(event: React.MouseEvent<HTMLAnchorElement>): boolean {
  return (
    event.button === 0 &&
    !event.defaultPrevented &&
    !event.metaKey &&
    !event.altKey &&
    !event.ctrlKey &&
    !event.shiftKey
  );
}

// Wraps a SessionRow with @dnd-kit sortable plumbing. The row itself
// is the drag handle: a short tap/click navigates as before, but a
// press-and-hold (sensor delay) lifts the row so the user can reorder.
// See #1169.

// "Drag just ended" timestamp shared by every sortable row and the
// document-level click suppressor. Lives as a ref on the sidebar so
// HMR resets don't leave it in a weird state and so siblings can't
// see each other through a module-scoped global. The document
// listener checks `ref.current` on every click; rows write to it
// while dragging and on release.
const DragSuppressContext = createContext<MutableRefObject<number> | null>(null);
function useDragSuppressRef(): MutableRefObject<number> {
  const ref = useContext(DragSuppressContext);
  if (!ref) {
    throw new Error("DragSuppressContext used outside provider");
  }
  return ref;
}

function useSuppressClickAfterDrag(ref: MutableRefObject<number>) {
  useEffect(() => {
    const handler = (e: MouseEvent) => {
      if (Date.now() < ref.current) {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();
      }
    };
    // The click Chromium dispatches after a drag-release can bypass
    // React's event delegation and land on the inner Link without
    // firing any wrapping capture handler. A document-level capture
    // listener catches it before navigation kicks in.
    document.addEventListener("click", handler, true);
    return () => document.removeEventListener("click", handler, true);
  }, [ref]);
}

function SortableSessionRow(props: {
  workspace: Workspace;
  isActive: boolean;
  onClick: () => void;
  onDelete?: (workspaceId: string) => void;
  readOnly?: boolean;
}) {
  const dragSuppressRef = useDragSuppressRef();
  // `disabled: readOnly` no-ops the sensor listeners, so a read-only
  // viewer can't drag the row (and can't fire the PUT, which the
  // server would reject anyway). Skipping the sortable wiring entirely
  // would also drop the click suppressor; that's harmless in read-only
  // since nothing else triggers a drag.
  const { listeners, setNodeRef, transform, transition, isDragging } =
    useSortable({ id: props.workspace.id, disabled: props.readOnly });
  useEffect(() => {
    if (isDragging) {
      // Keep extending the window while dragging so a slow drag still
      // suppresses the trailing click on release.
      dragSuppressRef.current = Date.now() + 1000;
    } else if (dragSuppressRef.current > Date.now()) {
      // Drag just ended; the click is on its way. Hold the suppression
      // for ~250ms after release (enough to swallow the synthetic click,
      // short enough that a real tap right after still navigates).
      dragSuppressRef.current = Date.now() + 250;
    }
  }, [isDragging, dragSuppressRef]);
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    touchAction: "manipulation",
    // Lift the active row above its siblings so the ring/shadow aren't
    // clipped by the next row in the list.
    zIndex: isDragging ? 10 : "auto",
    position: "relative",
  } as const;
  return (
    // We intentionally spread only `listeners` (pointer-down etc.) and
    // not dnd-kit's `attributes`. The latter inject role="button" and a
    // tabIndex which would duplicate the inner Link as a focusable,
    // button-styled affordance for assistive tech. Keyboard drag isn't
    // supported here, so the omitted attributes don't cost anything.
    <div
      ref={setNodeRef}
      style={style}
      {...(props.readOnly ? {} : listeners)}
      aria-roledescription={
        props.readOnly ? undefined : "Press and hold to reorder"
      }
      // While dragging, the row gets an amber ring (matches the active
      // session accent) and a soft shadow so it reads as elevated above
      // the rest of the list. ring-inset keeps the highlight tight to
      // the row rectangle; the transition runs in both directions so
      // the lift and the drop both feel intentional. The inner
      // SessionRow keeps its own background, so we only style the
      // outline here.
      className={
        "transition-shadow duration-150 " +
        (isDragging ? "ring-2 ring-inset ring-brand-500 shadow-lg" : "")
      }
    >
      <SessionRow {...props} indented />
    </div>
  );
}

const SessionRow = memo(function SessionRow({
  workspace,
  isActive,
  onClick,
  onDelete,
  readOnly,
  indented,
}: {
  workspace: Workspace;
  isActive: boolean;
  onClick: () => void;
  onDelete?: (workspaceId: string) => void;
  readOnly?: boolean;
  indented?: boolean;
}) {
  const idleDecayWindowMs = useIdleDecayWindowMs();
  const { status: sessionStatus, createdAt, idleEnteredAt } = bestSession(
    workspace,
    idleDecayWindowMs,
  );
  const textClass = getStatusTextClass(
    {
      status: sessionStatus,
      idle_entered_at: idleEnteredAt,
    },
    idleDecayWindowMs,
  );
  const runningSession = workspace.sessions.find((s) =>
    isSessionActive(s, idleDecayWindowMs),
  );
  const firstSession = workspace.sessions[0];
  const singleSession = workspace.sessions.length === 1;
  const sessionTitle = firstSession?.title.trim() ?? "";
  const branchLabel = workspace.branch ?? null;
  const baseBranch = firstSession?.base_branch ?? null;
  const label = singleSession
    ? sessionTitle || branchLabel || "default"
    : branchLabel || sessionTitle || "default";
  const subtitle = singleSession && sessionTitle && branchLabel && sessionTitle !== branchLabel
    ? branchLabel
    : null;
  const subtitleTitle = subtitle && baseBranch
    ? `${subtitle} (based on ${baseBranch})`
    : subtitle;
  const sessionId = firstSession?.id;
  const navigationSessionId = runningSession?.id ?? firstSession?.id ?? null;
  const sessionPath = navigationSessionId
    ? `/session/${encodeURIComponent(navigationSessionId)}`
    : "/";
  const isDeleting = sessionStatus === "Deleting";
  const notifyPreset = detectNotifyPreset(
    firstSession?.notify_on_waiting,
    firstSession?.notify_on_idle,
    firstSession?.notify_on_error,
  );
  // Surface an unsent cockpit-composer draft on this workspace's row.
  // Drafts live in localStorage under `cockpit:draft:<session_id>`; we
  // check every session id in the workspace so multi-session rows
  // (rare today) still light up if any of them has pending text.
  const sessionIds = useMemo(
    () => workspace.sessions.map((s) => s.id),
    [workspace.sessions],
  );
  const hasDraft = useHasDraftForSessions(sessionIds);

  const setNotifyPreset = async (preset: NotifyPreset) => {
    setContextMenu(null);
    if (!sessionId || preset === notifyPreset) return;
    await setSessionNotifications(sessionId, preset);
  };

  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
  const [renaming, setRenaming] = useState(false);
  const [renameValue, setRenameValue] = useState(label);
  const renameRef = useRef<HTMLInputElement>(null);
  const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const longPressFired = useRef(false);
  const touchOpenedAt = useRef(0);
  const menuRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    return () => {
      if (longPressTimer.current) clearTimeout(longPressTimer.current);
    };
  }, []);

  useEffect(() => {
    if (renaming) renameRef.current?.select();
  }, [renaming]);

  useEffect(() => {
    if (!contextMenu) return;
    const close = () => setContextMenu(null);
    const onDocClick = (e: MouseEvent) => {
      // Clicks inside the menu should be handled by item onClick
      // handlers, not by this dismiss listener.
      if (menuRef.current?.contains(e.target as Node)) return;
      // On mobile, lifting the finger after a long-press dispatches a
      // synthetic click even when touchend called preventDefault().
      // Ignore clicks that arrive shortly after a touch-triggered open.
      if (Date.now() - touchOpenedAt.current < 500) return;
      close();
    };
    // Defer so the event that opened the menu finishes bubbling first
    const id = requestAnimationFrame(() => {
      document.addEventListener("click", onDocClick);
      document.addEventListener("contextmenu", close);
    });
    // Listen for the "close" broadcast from any sibling SessionRow
    // that is opening its own menu.
    menuBus.addEventListener("close", close);
    return () => {
      cancelAnimationFrame(id);
      document.removeEventListener("click", onDocClick);
      document.removeEventListener("contextmenu", close);
      menuBus.removeEventListener("close", close);
    };
  }, [contextMenu]);

  const handleContextMenu = (e: React.MouseEvent) => {
    if (isDeleting) return;
    e.preventDefault();
    closeOtherContextMenus();
    setContextMenu({ x: e.clientX, y: e.clientY });
  };

  const clearLongPress = () => {
    if (longPressTimer.current) {
      clearTimeout(longPressTimer.current);
      longPressTimer.current = null;
    }
  };

  const handleTouchStart = (e: React.TouchEvent) => {
    clearLongPress();
    longPressFired.current = false;
    if (!sessionId || isDeleting) return;
    const touch = e.touches[0];
    if (!touch) return;
    const tx = touch.clientX;
    const ty = touch.clientY;
    longPressTimer.current = setTimeout(() => {
      longPressFired.current = true;
      touchOpenedAt.current = Date.now();
      closeOtherContextMenus();
      setContextMenu({ x: tx, y: ty });
    }, 500);
  };

  const handleTouchEnd = (e: React.TouchEvent) => {
    clearLongPress();
    if (longPressFired.current) {
      e.preventDefault();
    }
  };

  const startRename = () => {
    if (renaming) return;
    setContextMenu(null);
    setRenameValue(sessionTitle || label);
    setRenaming(true);
  };

  const commitRename = async () => {
    setRenaming(false);
    const trimmed = renameValue.trim();
    // Compare against the current title, not the displayed label: when a
    // single session has no title yet, label is the branch and accepting
    // the prefilled value should still set the title.
    if (!trimmed || trimmed === sessionTitle || !sessionId) return;
    await renameSession(sessionId, trimmed);
  };

  const handleDelete = () => {
    setContextMenu(null);
    onDelete?.(workspace.id);
  };

  if (renaming) {
    return (
      <div className={`py-1 ${indented ? "pl-6 pr-3" : "px-3"}`}>
        <input
          ref={renameRef}
          type="text"
          value={renameValue}
          onChange={(e) => setRenameValue(e.target.value)}
          onBlur={commitRename}
          onKeyDown={(e) => {
            if (e.key === "Enter") commitRename();
            if (e.key === "Escape") setRenaming(false);
          }}
          data-testid="sidebar-rename-input"
          className="w-full bg-surface-900 border border-brand-600 rounded px-2 py-1 text-[13px] md:text-[14px] font-mono text-text-primary focus:outline-none"
        />
      </div>
    );
  }

  return (
    <>
      <Link
        to={sessionPath}
        data-testid="sidebar-session-row"
        // Anchors are HTML5-draggable by default; the browser would
        // start its own drag-the-link gesture in parallel with dnd-kit
        // and finish with a synthetic click that bypassed React's event
        // delegation, navigating despite our suppression. See #1169.
        draggable={false}
        onClick={(e) => {
          if (longPressFired.current) {
            e.preventDefault();
            return;
          }
          if (isPlainLeftClick(e)) {
            e.preventDefault();
            onClick();
          }
        }}
        onContextMenu={handleContextMenu}
        onTouchStart={handleTouchStart}
        onTouchEnd={handleTouchEnd}
        onTouchMove={clearLongPress}
        onTouchCancel={clearLongPress}
        className={`block w-full text-left py-2 cursor-pointer select-none transition-colors duration-75 ${
          indented ? "pl-6 pr-3" : "px-3"
        } ${
          isActive
            ? "bg-surface-850 border-l-2 border-brand-600"
            : "border-l-2 border-transparent hover:bg-surface-700/40"
        } ${isDeleting ? "opacity-50 pointer-events-none" : ""}`}
      >
        <div className="flex items-center gap-2">
          <span
            className={`text-sm shrink-0 leading-none font-mono ${textClass}`}
          >
            <StatusGlyph
              status={sessionStatus}
              createdAt={createdAt}
              idleEnteredAt={idleEnteredAt}
            />
          </span>
          <div className="min-w-0 flex-1">
            <span className={`flex items-center gap-1.5 text-[13px] md:text-[14px] ${isSessionActive({ status: sessionStatus, idle_entered_at: idleEnteredAt }, idleDecayWindowMs) ? textClass : isActive ? "text-text-primary" : "text-text-secondary"}`}>
              <span className="truncate" title={label}>{label}</span>
              {hasDraft && (
                <span
                  title="Unsent draft"
                  aria-label="Unsent draft"
                  className="inline-flex shrink-0"
                >
                  <Pencil className="h-3 w-3 text-amber-400/90" />
                </span>
              )}
              {firstSession?.cockpit_mode &&
                firstSession.cockpit_worker_state === "resuming" && (
                  <span
                    title="Cockpit worker is resuming"
                    aria-label="Resuming"
                    className="inline-flex shrink-0 items-center gap-0.5 rounded border border-amber-700/40 bg-amber-950/30 px-1 py-0 text-[10px] font-medium text-amber-300"
                  >
                    <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400/80" />
                    Resuming
                  </span>
                )}
              {firstSession?.next_wakeup_at && (
                <WakeupCountdown
                  wakeAt={firstSession.next_wakeup_at}
                  reason={firstSession.next_wakeup_reason}
                />
              )}
            </span>
            {subtitle && (
              <span
                className="block text-[11px] font-mono text-text-dim truncate"
                title={subtitleTitle ?? subtitle}
              >
                {subtitle}
                {baseBranch && (
                  <span className="ml-1 text-text-dim/70">← {baseBranch}</span>
                )}
              </span>
            )}
            {firstSession?.plan_summary &&
              firstSession.plan_summary.total > 0 &&
              // Hide the completed-plan bar when the session is also
              // sitting idle waiting for the next prompt: at that
              // point the bar is a static "100% 5/5" line that adds
              // clutter without conveying anything actionable. The
              // bar reappears on the next prompt because the agent
              // either emits a new plan (resetting completed) or
              // stays on the old one but flips status back to Running.
              !(
                firstSession.plan_summary.completed >=
                  firstSession.plan_summary.total &&
                firstSession.status === "Idle"
              ) && <PlanProgressMini summary={firstSession.plan_summary} />}
            {firstSession && (firstSession.workspace_repos?.length ?? 0) > 1 && (
              <span
                className="mt-0.5 flex flex-wrap gap-1 text-[10px] font-mono text-text-dim"
                title={firstSession.workspace_repos.map((r) => r.source_path).join("\n")}
              >
                {firstSession.workspace_repos.map((r) => (
                  <span
                    key={r.source_path}
                    className="px-1 py-px bg-surface-800/50 border border-surface-700/40 rounded text-text-secondary"
                  >
                    {r.name}
                  </span>
                ))}
              </span>
            )}
          </div>
        </div>
      </Link>
      {contextMenu && createPortal(
        <div
          ref={menuRef}
          data-testid="sidebar-context-menu"
          className="fixed z-50 bg-surface-800 border border-surface-700 rounded-lg shadow-lg py-1 min-w-[180px]"
          style={{ left: contextMenu.x, top: contextMenu.y }}
        >
          <button
            onClick={startRename}
            data-testid="sidebar-context-menu-rename"
            className="w-full text-left px-3 py-2 md:py-2 max-md:py-3 text-sm text-text-secondary hover:bg-surface-700/50 cursor-pointer transition-colors"
          >
            Rename
          </button>
          <div className="border-t border-surface-700/20 my-1" />
          <div className="px-3 py-1 text-[11px] font-mono uppercase tracking-widest text-text-muted">
            Notifications
          </div>
          {(["off", "default", "all"] as const).map((preset) => {
            const label =
              preset === "off"
                ? "Off"
                : preset === "default"
                  ? "Default"
                  : "All events";
            const selected = notifyPreset === preset;
            return (
              <button
                key={preset}
                onClick={() => void setNotifyPreset(preset)}
                className={`w-full text-left pl-6 pr-3 py-2 md:py-2 max-md:py-3 text-sm hover:bg-surface-700/50 cursor-pointer transition-colors flex items-center gap-2 ${
                  selected ? "text-text-primary" : "text-text-secondary"
                }`}
              >
                <span className="w-3 text-brand-500">
                  {selected ? "✓" : ""}
                </span>
                {label}
              </button>
            );
          })}
          {!readOnly && (
            <>
              <div className="border-t border-surface-700/20 my-1" />
              <button
                onClick={handleDelete}
                data-testid="sidebar-context-menu-delete"
                className="w-full text-left px-3 py-2 md:py-2 max-md:py-3 text-sm text-status-error hover:bg-status-error/10 cursor-pointer transition-colors"
              >
                Delete
              </button>
            </>
          )}
        </div>,
        document.body,
      )}
    </>
  );
});

const RepoGroupHeader = memo(function RepoGroupHeader({
  group,
  hasActiveChild,
  onClick,
  onNewSession,
  offline,
}: {
  group: RepoGroup;
  hasActiveChild: boolean;
  onClick: () => void;
  onNewSession: () => void;
  offline: boolean;
}) {
  const dotClass =
    STATUS_DOT_CLASS[
      group.status === "active" ? "Running" : "Idle"
    ] ?? "bg-status-idle";

  return (
    <div
      data-testid="sidebar-group-header"
      data-group-id={group.id}
      className={`flex items-center gap-2 px-3 py-2 transition-colors duration-75 text-text-secondary hover:bg-surface-800/50 ${
        hasActiveChild ? "border-l-2 border-brand-600" : ""
      }`}
    >
      <span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
      <button
        onClick={onClick}
        aria-expanded={!group.collapsed}
        className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer"
      >
        <svg
          width="10"
          height="10"
          viewBox="0 0 10 10"
          fill="currentColor"
          className={`shrink-0 text-text-dim transition-transform duration-75 ${
            group.collapsed ? "-rotate-90" : ""
          }`}
        >
          <path d="M2 3 L5 6.5 L8 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
        <OwnerAvatar owner={group.remoteOwner} size={16} />
        <span className="text-[13px] md:text-[14px] font-medium truncate flex-1" title={group.repoPath}>
          {group.displayName}
        </span>
      </button>
      <Tooltip text={offline ? OFFLINE_TITLE : "New session"}>
        <button
          onClick={onNewSession}
          disabled={offline}
          className="w-8 h-8 flex items-center justify-center shrink-0 rounded-md transition-colors text-text-muted hover:text-text-secondary hover:bg-surface-700/50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-text-muted disabled:hover:bg-transparent"
          aria-label={`New session in ${group.displayName}`}
        >
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
            <line x1="12" y1="5" x2="12" y2="19" />
            <line x1="5" y1="12" x2="19" y2="12" />
          </svg>
        </button>
      </Tooltip>
    </div>
  );
});

function Tooltip({ text, children }: { text: string; children: React.ReactNode }) {
  return (
    <span className="relative group/tip inline-flex">
      {children}
      <span className="pointer-events-none absolute left-1/2 -translate-x-1/2 top-full mt-1.5 px-2 py-1 rounded bg-surface-950 border border-surface-700 text-[11px] text-text-secondary whitespace-nowrap opacity-0 scale-95 transition-all duration-100 group-hover/tip:opacity-100 group-hover/tip:scale-100 z-50">
        {text}
      </span>
    </span>
  );
}

function workspaceMatchesFilter(ws: Workspace, q: string): boolean {
  return (
    ws.displayName.toLowerCase().includes(q) ||
    ws.projectPath.toLowerCase().includes(q) ||
    (ws.branch?.toLowerCase().includes(q) ?? false) ||
    ws.agents.some((a) => a.toLowerCase().includes(q)) ||
    ws.sessions.some((s) => s.title.toLowerCase().includes(q))
  );
}

export function WorkspaceSidebar({
  groups,
  onReorderWorkspaces,
  activeId,
  open,
  onToggle,
  onSelect,
  onToggleRepo,
  onNew,
  onCreateSession,
  onSettings,
  onProjects,
  onDeleteSession,
  readOnly,
}: Props) {
  const dragSuppressRef = useRef<number>(0);
  useSuppressClickAfterDrag(dragSuppressRef);
  const offline = useServerDown();
  const [width, setWidth] = useState(loadSavedWidth);
  const [filterOpen, setFilterOpen] = useState(false);
  const [filterQuery, setFilterQuery] = useState("");
  const filterRef = useRef<HTMLInputElement>(null);
  const dragging = useRef(false);

  // Whole-row press-and-hold drag. The delay lets a quick click or tap
  // pass through to the row's navigation link unchanged; only a held
  // pointer (150ms with <8px movement) flips the row into drag mode.
  // 150ms is the iOS Reminders/Notes ballpark: long enough to filter
  // out scroll-flicks and accidental taps, short enough that the lift
  // feels immediate. Same delay on mouse and touch so the gesture is
  // identical on desktop and mobile.
  const sensors = useSensors(
    useSensor(MouseSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
    useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
  );

  const handleDragEnd = useCallback(
    (e: DragEndEvent) => {
      const { active, over } = e;
      if (!over || active.id === over.id) return;

      // Drag is constrained to within a single repo group (each group
      // has its own SortableContext), so finding the active group and
      // reordering inside it is sufficient.
      const groupIndex = groups.findIndex((g) =>
        g.workspaces.some((w) => w.id === active.id),
      );
      const group = groups[groupIndex];
      if (groupIndex < 0 || !group) return;
      const oldIndex = group.workspaces.findIndex((w) => w.id === active.id);
      const newIndex = group.workspaces.findIndex((w) => w.id === over.id);
      if (oldIndex < 0 || newIndex < 0) return;

      // Build the new full visual order by replacing the affected
      // group's local order, then concat in the existing group order.
      // We persist the full flat list so cross-device clients can render
      // the same layout without re-deriving per-group ordering.
      const reordered = arrayMove(group.workspaces, oldIndex, newIndex);
      const flat: string[] = [];
      groups.forEach((g, i) => {
        const ws = i === groupIndex ? reordered : g.workspaces;
        ws.forEach((w) => flat.push(w.id));
      });
      onReorderWorkspaces(flat);
    },
    [groups, onReorderWorkspaces],
  );

  const q = filterQuery.trim().toLowerCase();

  const filteredGroups = q
    ? groups
        .map((g) => ({
          ...g,
          workspaces: g.workspaces.filter((ws) =>
            workspaceMatchesFilter(ws, q) ||
            g.displayName.toLowerCase().includes(q),
          ),
        }))
        .filter((g) => g.workspaces.length > 0)
    : groups;

  const hasResults = filteredGroups.length > 0;

  const toggleFilter = () => {
    setFilterOpen((o) => {
      if (o) setFilterQuery("");
      return !o;
    });
  };

  useEffect(() => {
    if (filterOpen) filterRef.current?.focus();
  }, [filterOpen]);

  const handleMouseDown = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    dragging.current = true;
    document.body.style.cursor = "col-resize";
    document.body.style.userSelect = "none";
  }, []);

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      if (!dragging.current) return;
      const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, e.clientX));
      setWidth(newWidth);
    };

    const handleMouseUp = () => {
      if (!dragging.current) return;
      dragging.current = false;
      document.body.style.cursor = "";
      document.body.style.userSelect = "";
      setWidth((w) => {
        localStorage.setItem(SIDEBAR_WIDTH_KEY, String(w));
        return w;
      });
    };

    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", handleMouseUp);
    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
      document.removeEventListener("mouseup", handleMouseUp);
    };
  }, []);

  return (
    <>
      <div
        className={`fixed top-12 inset-x-0 bottom-0 z-30 md:hidden transition-opacity duration-300 ${
          open ? "bg-black/50" : "opacity-0 pointer-events-none"
        }`}
        onClick={onToggle}
      />
      <div
        style={{ width }}
        className={`fixed top-12 bottom-0 left-0 z-40 md:static md:z-auto bg-surface-800 flex flex-col md:h-full shrink-0 transition-transform duration-300 ease-in-out md:transition-none ${
          open ? "translate-x-0" : "-translate-x-full md:hidden"
        }`}
      >
        <div className="px-3 pt-3 pb-1 flex items-center">
          <span className="text-sm text-text-muted flex-1">
            Projects
          </span>
          <Tooltip text="Filter">
            <button
              onClick={toggleFilter}
              className={`w-8 h-8 flex items-center justify-center cursor-pointer rounded-md transition-colors ${
                filterOpen
                  ? "text-text-secondary"
                  : "text-text-dim hover:text-text-secondary"
              }`}
              aria-label="Filter sessions"
            >
              <svg
                width="14"
                height="14"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
              </svg>
            </button>
          </Tooltip>
          <Tooltip text={offline ? OFFLINE_TITLE : "New session"}>
            <button
              onClick={onNew}
              disabled={offline}
              className="w-8 h-8 flex items-center justify-center text-text-muted hover:text-text-secondary hover:bg-surface-800 cursor-pointer rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-text-muted disabled:hover:bg-transparent"
              aria-label="New session"
            >
              <svg
                width="16"
                height="16"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
                <line x1="12" y1="11" x2="12" y2="17" />
                <line x1="9" y1="14" x2="15" y2="14" />
              </svg>
            </button>
          </Tooltip>
          <button
            onClick={onToggle}
            className="md:hidden w-8 h-8 flex items-center justify-center text-text-dim hover:text-text-secondary cursor-pointer rounded-md hover:bg-surface-800 ml-1"
          >
            &times;
          </button>
        </div>

        {filterOpen && (
          <div className="px-3 pb-2">
            <input
              ref={filterRef}
              type="text"
              value={filterQuery}
              onChange={(e) => setFilterQuery(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Escape") toggleFilter();
              }}
              placeholder="Filter by name, branch, agent..."
              data-testid="sidebar-filter-input"
              className="w-full bg-surface-800 border border-surface-700 rounded-md px-2.5 py-1.5 text-[13px] text-text-primary placeholder:text-text-dim focus:border-brand-600 focus:outline-none"
            />
          </div>
        )}

        <div className="flex-1 overflow-y-auto overflow-x-hidden">
          <DragSuppressContext.Provider value={dragSuppressRef}>
          <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragEnd={readOnly ? undefined : handleDragEnd}
          >
            {filteredGroups.map((group) => {
              const showExpanded = q ? true : !group.collapsed;
              const hasActiveChild = group.workspaces.some(
                (ws) => ws.id === activeId,
              );
              return (
                <div key={group.id}>
                  <RepoGroupHeader
                    group={{ ...group, collapsed: !showExpanded }}
                    hasActiveChild={!showExpanded && hasActiveChild}
                    onClick={() => !q && onToggleRepo(group.id)}
                    onNewSession={() =>
                      group.id === MULTI_REPO_GROUP_ID
                        ? onNew()
                        : onCreateSession(group.repoPath)
                    }
                    offline={offline}
                  />
                  {showExpanded && (
                    <SortableContext
                      items={group.workspaces.map((ws) => ws.id)}
                      strategy={verticalListSortingStrategy}
                    >
                      {group.workspaces.map((ws) => (
                        <SortableSessionRow
                          key={ws.id}
                          workspace={ws}
                          isActive={ws.id === activeId}
                          onClick={() => onSelect(ws.id)}
                          onDelete={onDeleteSession}
                          readOnly={readOnly}
                        />
                      ))}
                    </SortableContext>
                  )}
                </div>
              );
            })}
          </DndContext>
          </DragSuppressContext.Provider>

          {!hasResults && filterQuery && (
            <div className="px-4 py-8 text-center">
              <p className="text-sm text-text-muted">
                No matches for &ldquo;{filterQuery}&rdquo;
              </p>
            </div>
          )}
        </div>

        <div className="border-t border-surface-700/20 p-2 flex items-center gap-1">
          <button
            onClick={onProjects}
            className="w-8 h-8 flex items-center justify-center text-text-secondary hover:text-text-primary hover:bg-surface-800/50 cursor-pointer rounded-md transition-colors"
            title="Projects"
            aria-label="Projects"
          >
            <svg
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
            </svg>
          </button>
          <button
            onClick={onSettings}
            className="w-8 h-8 flex items-center justify-center text-text-secondary hover:text-text-primary hover:bg-surface-800/50 cursor-pointer rounded-md transition-colors"
            title="Settings"
            aria-label="Settings"
          >
            <svg
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
              <circle cx="12" cy="12" r="3" />
            </svg>
          </button>
        </div>
      </div>
      {/* Resize handle (desktop only) */}
      <div
        onMouseDown={handleMouseDown}
        className="hidden md:block w-1 cursor-col-resize shrink-0 bg-surface-800 hover:bg-brand-600/50 transition-colors duration-75"
      />
    </>
  );
}
