import type { ComponentChildren } from "preact";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import {
  clearCardSelection,
  getCardLiveContent,
  getCardSelection,
  subscribeCardSelection,
} from "../cardRuntime/store";
import { AgentIndicator } from "../components/AgentIndicator";
import { CardFeed } from "../components/CardFeed";
import { ControlBar } from "../components/Controls";
import { LogPanel } from "../components/LogPanel";
import { useAudioMeter } from "../hooks/useAudioMeter";
import { usePTT } from "../hooks/usePTT";
import type { WebRTCState } from "../hooks/webrtc/types";
import type {
  AgentState,
  CardItem,
  CardMessageMetadata,
  CardSelectionRange,
  JsonValue,
  LogLine,
} from "../types";

const SWIPE_THRESHOLD_PX = 64;
const SWIPE_INTENT_PX = 12;
const SWIPE_COMMIT_RATIO = 0.18;
const SWIPE_DIRECTION_RATIO = 1.15;
const SESSION_DRAWER_OPEN_THRESHOLD_PX = 52;
const SESSION_DRAWER_MAX_WIDTH_PX = 336;
const SESSION_DRAWER_GUTTER_PX = 28;

type WorkspaceView = "agent" | "feed";

interface AppRtcActions {
  connect(): Promise<void>;
  sendJson(
    msg:
      | { type: "command"; command: string }
      | { type: "card-response"; card_id: string; value: string }
      | { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
  ): void;
  setTextOnly(enabled: boolean): void;
  sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
  connected: boolean;
  connecting: boolean;
}

interface SwipeState {
  pointerId: number;
  x: number;
  y: number;
  dragging: boolean;
}

interface EdgeSwipeState {
  identifier: number;
  x: number;
  y: number;
}

type EdgeSwipeOutcome = "idle" | "track" | "cancel" | "open";

function findTouchById(touches: TouchList, identifier: number): Touch | null {
  for (let i = 0; i < touches.length; i += 1) {
    const touch = touches.item(i);
    if (touch && touch.identifier === identifier) return touch;
  }
  return null;
}

function isSwipeInteractiveTarget(target: EventTarget | null): boolean {
  if (!(target instanceof Element)) return false;
  return Boolean(
    target.closest("textarea,input,select,[contenteditable='true'],[data-no-swipe='1']"),
  );
}

function getViewportWidth(): number {
  return window.innerWidth || document.documentElement.clientWidth || 1;
}

function toNullableNumber(value: JsonValue | undefined): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}

function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
  const raw = getCardSelection(cardId);
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
  const record = raw as Record<string, JsonValue>;
  if (record.kind !== "git_diff_range") return null;
  if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;

  const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
  const label =
    typeof record.label === "string"
      ? record.label
      : `${record.file_label} · ${record.range_label}`;

  return {
    kind: "git_diff_range",
    file_path: filePath,
    file_label: record.file_label,
    range_label: record.range_label,
    label,
    old_start: toNullableNumber(record.old_start),
    old_end: toNullableNumber(record.old_end),
    new_start: toNullableNumber(record.new_start),
    new_end: toNullableNumber(record.new_end),
  };
}

function buildCardMetadata(card: CardItem): CardMessageMetadata {
  const metadata: CardMessageMetadata = {
    card_id: card.serverId,
    card_slot: card.slot,
    card_title: card.title,
    card_lane: card.lane,
    card_template_key: card.templateKey,
    card_context_summary: card.contextSummary,
    card_response_value: card.responseValue,
  };
  const selection = card.serverId ? readCardSelection(card.serverId) : null;
  if (selection) {
    metadata.card_selection = selection as unknown as JsonValue;
    metadata.card_selection_label = selection.label;
  }
  const liveContent = card.serverId ? getCardLiveContent(card.serverId) : undefined;
  if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
  return metadata;
}

function formatCardContextLabel(
  card: CardItem | null,
  selection: CardSelectionRange | null,
): string | null {
  if (!card) return null;
  if (selection) {
    return `diff: ${selection.file_label}${selection.range_label ? ` ${selection.range_label}` : ""}`;
  }
  const title = card.title?.trim();
  if (!title) return "card";
  if (card.templateKey === "todo-item-live") return `task: ${title}`;
  if (card.templateKey === "upcoming-conditions-live") return `event: ${title}`;
  if (card.templateKey === "list-total-live") return `tracker: ${title}`;
  return `card: ${title}`;
}

function AgentCardContext({
  card,
  selection,
  onClear,
  textMode = false,
}: {
  card: CardItem;
  selection: CardSelectionRange | null;
  onClear(): void;
  textMode?: boolean;
}) {
  const label = textMode
    ? "Next message uses this context"
    : selection
      ? "Using diff context"
      : "Using card";
  const title = selection?.file_label || card.title;
  const meta = selection?.range_label || card.contextSummary || "";

  return (
    <div id="agent-card-context" data-no-swipe="1">
      <div class="agent-card-context-label">{label}</div>
      <div class="agent-card-context-row">
        <div class="agent-card-context-main">
          <div class="agent-card-context-title">{title}</div>
          {meta && <div class="agent-card-context-meta">{meta}</div>}
          {textMode ? (
            <div class="agent-card-context-note">
              Ask your follow-up and Nanobot will include this card in the conversation context.
            </div>
          ) : null}
        </div>
        <button
          class="agent-card-context-clear"
          type="button"
          aria-label="Clear selected card context"
          onClick={(e) => {
            e.preventDefault();
            e.stopPropagation();
            onClear();
          }}
        >
          Clear
        </button>
      </div>
    </div>
  );
}

function getSwipeDelta(swipe: SwipeState, event: PointerEvent): { dx: number; dy: number } {
  return {
    dx: event.clientX - swipe.x,
    dy: event.clientY - swipe.y,
  };
}

function hasSwipeIntent(dx: number, dy: number): boolean {
  return Math.abs(dx) >= SWIPE_INTENT_PX || Math.abs(dy) >= SWIPE_INTENT_PX;
}

function isVerticalSwipeDominant(dx: number, dy: number): boolean {
  return Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO;
}

function clampSwipeOffset(view: WorkspaceView, dx: number, width: number): number {
  const minOffset = view === "agent" ? -width : 0;
  const maxOffset = view === "agent" ? 0 : width;
  return Math.max(minOffset, Math.min(maxOffset, dx));
}

function getCommittedSwipeView(
  view: WorkspaceView,
  dx: number,
  width: number,
): WorkspaceView | null {
  const progress = width > 0 ? Math.abs(dx) / width : 0;
  const crossedThreshold = Math.abs(dx) >= SWIPE_THRESHOLD_PX || progress >= SWIPE_COMMIT_RATIO;
  if (!crossedThreshold) return null;
  if (view === "agent" && dx < 0) return "feed";
  if (view === "feed" && dx > 0) return "agent";
  return null;
}

function releaseCapturedPointer(currentTarget: EventTarget | null, pointerId: number): void {
  const element = currentTarget as HTMLElement | null;
  if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
}

export function useSwipeTrackStyle(view: WorkspaceView, swipeOffsetPx: number, swiping: boolean) {
  return useMemo(() => {
    const base = view === "feed" ? "-50%" : "0%";
    return {
      transform:
        swipeOffsetPx === 0
          ? `translateX(${base})`
          : `translateX(calc(${base} + ${swipeOffsetPx}px))`,
      transition: swiping ? "none" : "transform 0.28s ease",
    };
  }, [swipeOffsetPx, swiping, view]);
}

function getEdgeSwipeOutcome(dx: number, dy: number): EdgeSwipeOutcome {
  if (!hasSwipeIntent(dx, dy)) return "idle";
  if (isVerticalSwipeDominant(dx, dy) || dx <= 0) return "cancel";
  if (dx < SESSION_DRAWER_OPEN_THRESHOLD_PX) return "track";
  return "open";
}

function getEdgeSwipeTouch(
  touches: TouchList,
  swipe: EdgeSwipeState | null,
): { swipe: EdgeSwipeState; touch: Touch } | null {
  if (!swipe) return null;
  const touch = findTouchById(touches, swipe.identifier);
  if (!touch) return null;
  return { swipe, touch };
}

function getSessionDrawerWidth(viewportWidth: number): number {
  return Math.max(
    1,
    Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
  );
}

function getSessionDrawerProgress(dx: number, viewportWidth: number): number {
  return Math.max(0, Math.min(1, dx / getSessionDrawerWidth(viewportWidth)));
}

export function useSessionDrawerEdgeSwipe({
  enabled,
  onOpen,
}: {
  enabled: boolean;
  onOpen(): void;
}) {
  const edgeSwipeRef = useRef<EdgeSwipeState | null>(null);
  const [progress, setProgress] = useState(0);

  const clearEdgeSwipe = useCallback(() => {
    edgeSwipeRef.current = null;
    setProgress(0);
  }, []);

  const onTouchStart = useCallback(
    (e: Event) => {
      if (!enabled) return;
      const te = e as TouchEvent;
      if (te.touches.length !== 1) return;
      const touch = te.touches.item(0);
      if (!touch) return;
      edgeSwipeRef.current = {
        identifier: touch.identifier,
        x: touch.clientX,
        y: touch.clientY,
      };
    },
    [enabled],
  );

  const onTouchMove = useCallback(
    (e: Event) => {
      const te = e as TouchEvent;
      if (!enabled) return;
      const trackedTouch = getEdgeSwipeTouch(te.touches, edgeSwipeRef.current);
      if (!trackedTouch) return;

      const { swipe, touch } = trackedTouch;
      const dx = touch.clientX - swipe.x;
      const dy = touch.clientY - swipe.y;
      const outcome = getEdgeSwipeOutcome(dx, dy);
      if (outcome === "idle") return;

      if (outcome === "cancel") {
        clearEdgeSwipe();
        return;
      }

      if (te.cancelable) te.preventDefault();
      setProgress(
        getSessionDrawerProgress(
          dx,
          window.innerWidth || document.documentElement.clientWidth || 1,
        ),
      );
      if (outcome === "track") return;
    },
    [clearEdgeSwipe, enabled],
  );

  const onTouchEnd = useCallback(
    (e: Event) => {
      const te = e as TouchEvent;
      const trackedTouch = getEdgeSwipeTouch(te.changedTouches, edgeSwipeRef.current);
      if (!trackedTouch) {
        clearEdgeSwipe();
        return;
      }

      const { swipe, touch } = trackedTouch;
      const dx = touch.clientX - swipe.x;
      const shouldOpen = dx >= SESSION_DRAWER_OPEN_THRESHOLD_PX;
      clearEdgeSwipe();
      if (shouldOpen) onOpen();
    },
    [clearEdgeSwipe, onOpen],
  );

  return {
    onTouchStart,
    onTouchMove,
    onTouchEnd,
    onTouchCancel: clearEdgeSwipe,
    progress,
  };
}

// biome-ignore lint/complexity/noExcessiveLinesPerFunction: touch and pointer swipe paths share one state machine here
export function useSwipeHandlers(
  view: WorkspaceView,
  setView: (view: WorkspaceView) => void,
  isInteractiveTarget: (target: EventTarget | null) => boolean,
  getViewportWidthFn: () => number,
) {
  const swipeRef = useRef<SwipeState | null>(null);
  const [swipeOffsetPx, setSwipeOffsetPx] = useState(0);
  const [swiping, setSwiping] = useState(false);

  const clearSwipe = useCallback(() => {
    swipeRef.current = null;
    setSwiping(false);
    setSwipeOffsetPx(0);
  }, []);

  const onSwipeStart = useCallback(
    (e: Event) => {
      const pe = e as PointerEvent;
      if (pe.pointerType === "touch") return;
      if (pe.pointerType === "mouse" && pe.button !== 0) return;
      if (isInteractiveTarget(pe.target)) return;
      swipeRef.current = { pointerId: pe.pointerId, x: pe.clientX, y: pe.clientY, dragging: false };
      (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(pe.pointerId);
    },
    [isInteractiveTarget],
  );

  const onSwipeMove = useCallback(
    (e: Event) => {
      const pe = e as PointerEvent;
      if (pe.pointerType === "touch") return;
      const swipe = swipeRef.current;
      if (!swipe || swipe.pointerId !== pe.pointerId) return;

      const { dx, dy } = getSwipeDelta(swipe, pe);

      if (!swipe.dragging) {
        if (!hasSwipeIntent(dx, dy)) return;
        if (isVerticalSwipeDominant(dx, dy)) {
          clearSwipe();
          return;
        }
        swipe.dragging = true;
        setSwiping(true);
      }

      setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
      if (pe.cancelable) pe.preventDefault();
    },
    [clearSwipe, getViewportWidthFn, view],
  );

  const finishSwipe = useCallback(
    (e: Event, commit: boolean) => {
      const pe = e as PointerEvent;
      if (pe.pointerType === "touch") return;
      const swipe = swipeRef.current;
      if (!swipe || swipe.pointerId !== pe.pointerId) return;

      releaseCapturedPointer(e.currentTarget, pe.pointerId);
      if (commit) {
        const { dx } = getSwipeDelta(swipe, pe);
        const nextView = swipe.dragging
          ? getCommittedSwipeView(view, dx, getViewportWidthFn())
          : null;
        if (nextView) setView(nextView);
      }

      clearSwipe();
    },
    [clearSwipe, getViewportWidthFn, setView, view],
  );

  const onSwipeEnd = useCallback((e: Event) => finishSwipe(e, true), [finishSwipe]);
  const onSwipeCancel = useCallback((e: Event) => finishSwipe(e, false), [finishSwipe]);

  const onTouchStart = useCallback(
    (e: Event) => {
      const te = e as TouchEvent;
      if (te.touches.length !== 1) return;
      const touch = te.touches.item(0);
      if (!touch) return;
      if (isInteractiveTarget(te.target)) return;
      swipeRef.current = {
        pointerId: touch.identifier,
        x: touch.clientX,
        y: touch.clientY,
        dragging: false,
      };
    },
    [isInteractiveTarget],
  );

  const onTouchMove = useCallback(
    (e: Event) => {
      const te = e as TouchEvent;
      const swipe = swipeRef.current;
      if (!swipe) return;
      const touch = findTouchById(te.touches, swipe.pointerId);
      if (!touch) return;

      const dx = touch.clientX - swipe.x;
      const dy = touch.clientY - swipe.y;

      if (!swipe.dragging) {
        if (!hasSwipeIntent(dx, dy)) return;
        if (isVerticalSwipeDominant(dx, dy)) {
          clearSwipe();
          return;
        }
        swipe.dragging = true;
        setSwiping(true);
      }

      setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
      if (te.cancelable) te.preventDefault();
    },
    [clearSwipe, getViewportWidthFn, view],
  );

  const onTouchEnd = useCallback(
    (e: Event) => {
      const te = e as TouchEvent;
      const swipe = swipeRef.current;
      if (!swipe) return;
      const touch = findTouchById(te.changedTouches, swipe.pointerId);
      if (!touch) return;

      if (swipe.dragging) {
        const dx = touch.clientX - swipe.x;
        const nextView = getCommittedSwipeView(view, dx, getViewportWidthFn());
        if (nextView) setView(nextView);
      }

      clearSwipe();
    },
    [clearSwipe, getViewportWidthFn, setView, view],
  );

  const onTouchCancel = useCallback(() => {
    clearSwipe();
  }, [clearSwipe]);

  return {
    onSwipeStart,
    onSwipeMove,
    onSwipeEnd,
    onSwipeCancel,
    onTouchStart,
    onTouchMove,
    onTouchEnd,
    onTouchCancel,
    swipeOffsetPx,
    swiping,
  };
}

function useCardActions(
  setView: (view: WorkspaceView) => void,
  setSelectedCardId: (cardId: string | null) => void,
) {
  const handleAskCard = useCallback(
    (card: CardItem) => {
      if (!card.serverId) return;
      setSelectedCardId(card.serverId);
      setView("agent");
    },
    [setSelectedCardId, setView],
  );

  return { handleAskCard };
}

function useGlobalPointerBindings({
  handlePointerDown,
  handlePointerMove,
  handlePointerUp,
}: {
  handlePointerDown: (event: Event) => void;
  handlePointerMove: (event: Event) => void;
  handlePointerUp: (event: Event) => void;
}) {
  useEffect(() => {
    document.addEventListener("pointerdown", handlePointerDown, { passive: false });
    document.addEventListener("pointermove", handlePointerMove, { passive: true });
    document.addEventListener("pointerup", handlePointerUp, { passive: false });
    document.addEventListener("pointercancel", handlePointerUp, { passive: false });
    return () => {
      document.removeEventListener("pointerdown", handlePointerDown);
      document.removeEventListener("pointermove", handlePointerMove);
      document.removeEventListener("pointerup", handlePointerUp);
      document.removeEventListener("pointercancel", handlePointerUp);
    };
  }, [handlePointerDown, handlePointerMove, handlePointerUp]);
}

function useControlActions(rtc: AppRtcActions) {
  const handleToggleTextOnly = useCallback(
    async (enabled: boolean) => {
      rtc.setTextOnly(enabled);
      if (!enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
    },
    [rtc],
  );

  return { handleToggleTextOnly };
}

function useSelectedCardActions({
  rtc,
  selectedCardId,
  setSelectedCardId,
  selectedCardMetadata,
}: {
  rtc: AppRtcActions & {
    sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise<void>;
  };
  selectedCardId: string | null;
  setSelectedCardId: (cardId: string | null) => void;
  selectedCardMetadata: () => CardMessageMetadata | undefined;
}) {
  const clearSelectedCardContext = useCallback(() => {
    if (selectedCardId) clearCardSelection(selectedCardId);
    setSelectedCardId(null);
  }, [selectedCardId, setSelectedCardId]);

  const handleCardChoice = useCallback(
    (cardId: string, value: string) => {
      rtc.sendJson({ type: "card-response", card_id: cardId, value });
    },
    [rtc],
  );

  const handleSendMessage = useCallback(
    async (text: string) => {
      await rtc.sendTextMessage(text, selectedCardMetadata());
    },
    [rtc, selectedCardMetadata],
  );

  return {
    clearSelectedCardContext,
    handleCardChoice,
    handleSendMessage,
  };
}

function useSelectedCardContext(
  cards: CardItem[],
  selectedCardId: string | null,
  selectionVersion: number,
) {
  const selectedCard = useMemo(
    () =>
      selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
    [cards, selectedCardId],
  );
  const selectedCardSelection = useMemo(
    () => (selectedCardId ? readCardSelection(selectedCardId) : null),
    [selectedCardId, selectionVersion],
  );
  const selectedCardMetadata = useCallback(
    () => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
    [selectedCard, selectionVersion],
  );

  return { selectedCard, selectedCardSelection, selectedCardMetadata };
}

function useCardSelectionLifecycle({
  cards,
  selectedCardId,
  setSelectedCardId,
  setSelectionVersion,
}: {
  cards: CardItem[];
  selectedCardId: string | null;
  setSelectedCardId: (cardId: string | null) => void;
  setSelectionVersion: (updater: (current: number) => number) => void;
}) {
  useEffect(() => {
    if (!selectedCardId) return;
    if (cards.some((card) => card.serverId === selectedCardId)) return;
    setSelectedCardId(null);
  }, [cards, selectedCardId, setSelectedCardId]);

  useEffect(() => {
    const unsubscribe = subscribeCardSelection((cardId, selection) => {
      setSelectionVersion((current) => current + 1);
      if (cardId && selection) setSelectedCardId(cardId);
    });
    return unsubscribe;
  }, [setSelectedCardId, setSelectionVersion]);
}

export function AgentWorkspace({
  active,
  selectedCard,
  selectedCardSelection,
  selectedCardContextLabel,
  onClearSelectedCardContext,
  textOnly,
  onToggleTextOnly,
  sessionDrawerEdge,
  logLines,
  connected,
  onSendMessage,
  effectiveAgentState,
  textStreaming,
  connecting,
  audioLevel,
  sessionDrawer,
  workbenchOverlay,
}: {
  active: boolean;
  selectedCard: CardItem | null;
  selectedCardSelection: CardSelectionRange | null;
  selectedCardContextLabel: string | null;
  onClearSelectedCardContext(): void;
  textOnly: boolean;
  onToggleTextOnly(enabled: boolean): Promise<void>;
  sessionDrawerEdge: ComponentChildren;
  logLines: LogLine[];
  connected: boolean;
  onSendMessage(text: string): Promise<void>;
  effectiveAgentState: AgentState;
  textStreaming: boolean;
  connecting: boolean;
  audioLevel: number;
  sessionDrawer: ComponentChildren;
  workbenchOverlay: ComponentChildren;
}) {
  return (
    <section
      class={`workspace-panel workspace-agent${textOnly ? " text-mode" : ""}${
        textOnly && selectedCard ? " has-card-context" : ""
      }`}
    >
      {active && <ControlBar textOnly={textOnly} onToggleTextOnly={onToggleTextOnly} />}
      {active && sessionDrawerEdge}
      {active && sessionDrawer}
      {active && workbenchOverlay}
      {active && selectedCard && !textOnly && (
        <AgentCardContext
          card={selectedCard}
          selection={selectedCardSelection}
          onClear={onClearSelectedCardContext}
          textMode={textOnly}
        />
      )}
      {active && (
        <LogPanel
          lines={logLines}
          disabled={false}
          onSendMessage={onSendMessage}
          onOpenVoiceMode={() => {
            void onToggleTextOnly(false);
          }}
          contextLabel={selectedCardContextLabel}
          onClearContext={selectedCard ? onClearSelectedCardContext : undefined}
          agentState={effectiveAgentState}
          textStreaming={textStreaming}
          fullScreen={textOnly}
        />
      )}
      {!textOnly && (
        <AgentIndicator
          state={effectiveAgentState}
          connected={connected}
          connecting={connecting}
          audioLevel={audioLevel}
          viewActive
          onPointerDown={() => {}}
          onPointerUp={() => {}}
        />
      )}
    </section>
  );
}

export function FeedWorkspace({
  cards,
  onDismiss,
  onChoice,
  onAskCard,
}: {
  cards: CardItem[];
  onDismiss(id: number): void;
  onChoice(cardId: string, value: string): void;
  onAskCard(card: CardItem): void;
}) {
  return (
    <section class="workspace-panel workspace-feed">
      <CardFeed
        cards={cards}
        viewActive
        onDismiss={onDismiss}
        onChoice={onChoice}
        onAskCard={onAskCard}
      />
    </section>
  );
}

export function SwipeWorkspace({
  view,
  trackStyle,
  onSwipeStart,
  onSwipeMove,
  onSwipeEnd,
  onSwipeCancel,
  onTouchStart,
  onTouchMove,
  onTouchEnd,
  onTouchCancel,
  agentWorkspace,
  feedWorkspace,
}: {
  view: WorkspaceView;
  trackStyle: Record<string, string>;
  onSwipeStart: (event: Event) => void;
  onSwipeMove: (event: Event) => void;
  onSwipeEnd: (event: Event) => void;
  onSwipeCancel: (event: Event) => void;
  onTouchStart: (event: Event) => void;
  onTouchMove: (event: Event) => void;
  onTouchEnd: (event: Event) => void;
  onTouchCancel: (event: Event) => void;
  agentWorkspace: ComponentChildren;
  feedWorkspace: ComponentChildren;
}) {
  return (
    <div
      id="swipe-shell"
      onPointerDown={onSwipeStart}
      onPointerMove={onSwipeMove}
      onPointerUp={onSwipeEnd}
      onPointerCancel={onSwipeCancel}
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      onTouchCancel={onTouchCancel}
    >
      <div id="swipe-track" data-view={view} style={trackStyle}>
        {agentWorkspace}
        {feedWorkspace}
      </div>
    </div>
  );
}

export function useAppPresentation(rtc: WebRTCState) {
  const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
  const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;

  const [view, setView] = useState<WorkspaceView>("agent");
  const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
  const [selectionVersion, setSelectionVersion] = useState(0);
  const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
    rtc.cards,
    selectedCardId,
    selectionVersion,
  );
  const selectedCardContextLabel = useMemo(
    () => formatCardContextLabel(selectedCard, selectedCardSelection),
    [selectedCard, selectedCardSelection],
  );

  const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
    connected: rtc.connected && !rtc.textOnly,
    currentAgentState: rtc.agentState,
    onSendPtt: (pressed) =>
      rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
    onBootstrap: rtc.textOnly ? async () => {} : rtc.connect,
    onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
  });
  const effectiveAgentState = agentStateOverride ?? rtc.agentState;
  const {
    onSwipeStart,
    onSwipeMove,
    onSwipeEnd,
    onSwipeCancel,
    onTouchStart,
    onTouchMove,
    onTouchEnd,
    onTouchCancel,
    swipeOffsetPx,
    swiping,
  } = useSwipeHandlers(view, setView, isSwipeInteractiveTarget, getViewportWidth);
  useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
  useCardSelectionLifecycle({
    cards: rtc.cards,
    selectedCardId,
    setSelectedCardId,
    setSelectionVersion,
  });

  const { handleToggleTextOnly } = useControlActions(rtc);
  const { handleAskCard } = useCardActions(setView, setSelectedCardId);
  const { clearSelectedCardContext, handleCardChoice, handleSendMessage } = useSelectedCardActions({
    rtc,
    selectedCardId,
    setSelectedCardId,
    selectedCardMetadata: useCallback(() => {
      const metadata = selectedCardMetadata();
      if (!metadata) return metadata;
      return {
        ...metadata,
        context_label: selectedCardContextLabel ?? undefined,
      };
    }, [selectedCardContextLabel, selectedCardMetadata]),
  });

  const trackStyle = useSwipeTrackStyle(view, swipeOffsetPx, swiping);

  return {
    audioLevel,
    view,
    selectedCard,
    selectedCardSelection,
    selectedCardContextLabel,
    effectiveAgentState,
    handleToggleTextOnly,
    handleAskCard,
    clearSelectedCardContext,
    handleCardChoice,
    handleSendMessage,
    onSwipeStart,
    onSwipeMove,
    onSwipeEnd,
    onSwipeCancel,
    onTouchStart,
    onTouchMove,
    onTouchEnd,
    onTouchCancel,
    trackStyle,
  };
}
