'use client';

import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import {
  ChevronDown,
  ChevronUp,
  Clock,
  Copy,
  Info,
  Send,
  Type,
  X,
  XCircle,
} from 'lucide-react';
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useToast } from '../lib/toast';
import { ErrorBoundary } from './error-boundary';
import {
  EntityDetailPanel,
  type SelectedSpanInfo,
  type SpanSelectionInfo,
} from './sidebar/entity-detail-panel';
import { ResolveHookModal } from './sidebar/resolve-hook-modal';
import {
  TraceViewerContextProvider,
  TraceViewerTimeline,
  useTraceViewer,
} from './trace-viewer';
import type { Span } from './trace-viewer/types';
import { Skeleton } from './ui/skeleton';
import { Spinner } from './ui/spinner';
import {
  getCustomSpanClassName,
  getCustomSpanEventClassName,
} from './workflow-traces/trace-colors';
import { buildTrace, type TraceWithMeta } from '../lib/trace-builder';

/**
 * While a run is live, continuously grow root.duration and rescale so the
 * trace always fits within the viewport. Individual span widths are grown
 * by each SpanComponent's own useEffect (see node.tsx).
 */
function useLiveTick(isLive: boolean): void {
  const { state, dispatch } = useTraceViewer();
  const stateRef = useRef(state);
  stateRef.current = state;

  useEffect(() => {
    if (!isLive) return;

    // Grow root.duration on every frame so span rAFs see the latest time
    let rafId = 0;
    const tick = (): void => {
      const { root } = stateRef.current;
      if (root.startTime) {
        const nowMs = Date.now();
        if (nowMs > root.endTime) {
          root.endTime = nowMs;
          root.duration = root.endTime - root.startTime;
        }
      }
      rafId = requestAnimationFrame(tick);
    };
    rafId = requestAnimationFrame(tick);

    // Re-scale smoothly so the trace fits the viewport as it grows.
    // We dispatch detectBaseScale only when the computed baseScale has
    // changed enough to matter visually (>0.1% shift), avoiding
    // unnecessary React re-renders while keeping things smooth.
    let scaleRafId = 0;
    let lastBaseScale = 0;
    const scaleTick = (): void => {
      const s = stateRef.current;
      if (s.root.duration > 0) {
        const newBaseScale = (s.width - s.scrollbarWidth) / s.root.duration;
        const delta = Math.abs(newBaseScale - lastBaseScale);
        if (delta > lastBaseScale * 0.001 || lastBaseScale === 0) {
          lastBaseScale = newBaseScale;
          dispatch({ type: 'detectBaseScale' });
        }
      }
      scaleRafId = requestAnimationFrame(scaleTick);
    };
    scaleRafId = requestAnimationFrame(scaleTick);

    return () => {
      cancelAnimationFrame(rafId);
      cancelAnimationFrame(scaleRafId);
    };
  }, [isLive, dispatch]);
}

const DEFAULT_PANEL_WIDTH = 450;
const MIN_PANEL_WIDTH = 240;

// ──────────────────────────────────────────────────────────────────────────
// Right-click context menu for spans
// ──────────────────────────────────────────────────────────────────────────

type ResourceType = 'sleep' | 'step' | 'hook' | 'run' | 'unknown';

interface ContextMenuState {
  x: number;
  y: number;
  spanId: string;
  spanName: string;
  resourceType: ResourceType;
  /** Whether the span represents an active/pending resource (not yet completed) */
  isActive: boolean;
}

interface ContextMenuItem {
  label: string;
  icon?: ReactNode;
  action: () => void;
  destructive?: boolean;
  disabled?: boolean;
}

function SpanContextMenu({
  menu,
  items,
  onClose,
}: {
  menu: ContextMenuState;
  items: ContextMenuItem[];
  onClose: () => void;
}): ReactNode {
  const menuRef = useRef<HTMLDivElement>(null);

  // Close on outside click
  useEffect(() => {
    const handleClick = (e: MouseEvent): void => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        onClose();
      }
    };
    const handleKeyDown = (e: KeyboardEvent): void => {
      if (e.key === 'Escape') {
        onClose();
      }
    };
    const handleScroll = (): void => {
      onClose();
    };
    // Use a timeout so we don't close immediately from the same event
    const timeout = setTimeout(() => {
      window.addEventListener('mousedown', handleClick);
      window.addEventListener('keydown', handleKeyDown);
      window.addEventListener('scroll', handleScroll, true);
    }, 0);
    return () => {
      clearTimeout(timeout);
      window.removeEventListener('mousedown', handleClick);
      window.removeEventListener('keydown', handleKeyDown);
      window.removeEventListener('scroll', handleScroll, true);
    };
  }, [onClose]);

  // Adjust position if menu would overflow viewport
  const [adjustedPos, setAdjustedPos] = useState({ x: menu.x, y: menu.y });
  useEffect(() => {
    const el = menuRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    let { x, y } = menu;
    if (rect.right > window.innerWidth) {
      x = window.innerWidth - rect.width - 8;
    }
    if (rect.bottom > window.innerHeight) {
      y = window.innerHeight - rect.height - 8;
    }
    if (x !== menu.x || y !== menu.y) {
      setAdjustedPos({ x, y });
    }
  }, [menu]);

  if (items.length === 0) return null;

  return createPortal(
    <div
      ref={menuRef}
      style={{
        position: 'fixed',
        left: adjustedPos.x,
        top: adjustedPos.y,
        zIndex: 99999,
        boxShadow: 'var(--ds-shadow-menu)',
        borderRadius: 12,
        background: 'var(--ds-background-100)',
        padding: 'var(--geist-space-gap-quarter, 4px)',
        fontSize: 14,
        minWidth: 180,
        overflowX: 'hidden',
        overflowY: 'auto',
      }}
    >
      <div
        style={{
          display: 'block',
          color: 'var(--ds-gray-900)',
          fontSize: '0.75rem',
          padding: 'var(--geist-gap-quarter, 4px) var(--geist-space-2x, 8px)',
          fontWeight: 500,
          overflow: 'hidden',
          textOverflow: 'ellipsis',
          whiteSpace: 'nowrap',
          maxWidth: 240,
          borderBottom: '1px solid var(--ds-gray-alpha-400)',
          marginBottom: 4,
        }}
      >
        {menu.spanName}
      </div>
      {items.map((item) => (
        <button
          key={item.label}
          type="button"
          style={{
            outline: 'none',
            cursor: item.disabled ? 'not-allowed' : 'pointer',
            display: 'flex',
            alignItems: 'center',
            gap: 8,
            padding: '0 var(--geist-space-2x, 8px)',
            height: 40,
            textDecoration: 'none',
            borderRadius: 6,
            color: item.destructive
              ? 'var(--ds-red-900)'
              : 'var(--ds-gray-1000)',
            width: '100%',
            background: 'transparent',
            border: 'none',
            fontSize: 14,
            textAlign: 'left',
            opacity: item.disabled ? 0.4 : 1,
            transition: 'background 0.15s',
          }}
          disabled={item.disabled}
          onMouseEnter={(e) => {
            (e.currentTarget as HTMLButtonElement).style.background =
              'var(--ds-gray-alpha-100)';
          }}
          onMouseLeave={(e) => {
            (e.currentTarget as HTMLButtonElement).style.background =
              'transparent';
          }}
          onClick={() => {
            item.action();
            onClose();
          }}
        >
          {item.icon ?? null}
          {item.label}
        </button>
      ))}
    </div>,
    document.body
  );
}

/** Inner wrapper that has access to the TraceViewer context */
function TraceViewerWithContextMenu({
  trace,
  run,
  isLive,
  onWakeUpSleep,
  onCancelRun,
  onResolveHook,
  onLoadMoreSpans,
  hasMoreSpans = false,
  isLoadingMoreSpans = false,
  children,
}: {
  trace: { spans: Span[] };
  run: WorkflowRun;
  isLive: boolean;
  onWakeUpSleep?: (
    runId: string,
    correlationId: string
  ) => Promise<{ stoppedCount: number }>;
  onCancelRun?: (runId: string) => Promise<void>;
  onResolveHook?: (
    hookToken: string,
    payload: unknown,
    hook?: Hook
  ) => Promise<void>;
  onLoadMoreSpans?: () => void | Promise<void>;
  hasMoreSpans?: boolean;
  isLoadingMoreSpans?: boolean;
  children: ReactNode;
}): ReactNode {
  const toast = useToast();
  const { state, dispatch } = useTraceViewer();

  // Drive active span widths at 60fps without React re-renders
  useLiveTick(isLive);
  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
  const [resolveHookTarget, setResolveHookTarget] = useState<{
    hookId: string;
    token: string;
  } | null>(null);
  const [resolvingHook, setResolvingHook] = useState(false);
  // Track hooks resolved in this session so the context menu item hides immediately
  const [resolvedHookIds, setResolvedHookIds] = useState<Set<string>>(
    new Set()
  );

  // Build a lookup map: spanId -> span
  const spanLookup = useMemo(() => {
    const map = new Map<string, Span>();
    for (const span of trace.spans) {
      map.set(span.spanId, span);
    }
    return map;
  }, [trace.spans]);

  const handleResolveHook = useCallback(
    async (payload: unknown) => {
      if (resolvingHook || !resolveHookTarget || !onResolveHook) return;
      if (!resolveHookTarget.token) {
        toast.error('Unable to resolve hook', {
          description:
            'Missing hook token. Try refreshing the run data and retry.',
        });
        return;
      }
      try {
        setResolvingHook(true);
        await onResolveHook(resolveHookTarget.token, payload);
        toast.success('Hook resolved', {
          description: 'The payload has been sent and the hook resolved.',
        });
        // Mark this hook as resolved locally so the menu item hides immediately
        setResolvedHookIds((prev) =>
          new Set(prev).add(resolveHookTarget.hookId)
        );
        setResolveHookTarget(null);
      } catch (err) {
        console.error('Failed to resolve hook:', err);
        toast.error('Failed to resolve hook', {
          description:
            err instanceof Error ? err.message : 'An unknown error occurred',
        });
      } finally {
        setResolvingHook(false);
      }
    },
    [onResolveHook, resolveHookTarget, resolvingHook]
  );

  const handleContextMenu = useCallback(
    (e: ReactMouseEvent | MouseEvent): void => {
      const target = e.target as HTMLElement;
      const $button = target.closest<HTMLButtonElement>('[data-span-id]');
      if (!$button) return;

      const spanId = $button.dataset.spanId;
      if (!spanId) return;

      e.preventDefault();
      e.stopPropagation();

      const span = spanLookup.get(spanId);
      if (!span) return;

      const resourceType =
        (span.attributes.resource as ResourceType) ?? 'unknown';
      const spanData = span.attributes.data as
        | Record<string, unknown>
        | undefined;
      const isActive = !spanData?.completedAt && !spanData?.disposedAt;

      setContextMenu({
        x: e.clientX,
        y: e.clientY,
        spanId,
        spanName: span.name,
        resourceType,
        isActive,
      });
    },
    [spanLookup]
  );

  const containerRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const $container = containerRef.current;
    if (!$container) return;

    const onContextMenu = (event: MouseEvent): void => {
      handleContextMenu(event);
    };
    $container.addEventListener('contextmenu', onContextMenu);

    return () => {
      $container.removeEventListener('contextmenu', onContextMenu);
    };
  }, [handleContextMenu]);

  const loadingMoreRef = useRef(false);
  useEffect(() => {
    const timeline = state.timelineRef.current;
    if (!timeline || !onLoadMoreSpans || !hasMoreSpans) {
      return;
    }

    const thresholdPx = 200;
    const maybeLoadMore = () => {
      if (loadingMoreRef.current || isLoadingMoreSpans || !hasMoreSpans) {
        return;
      }
      const remaining =
        timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight;
      if (remaining > thresholdPx) {
        return;
      }

      loadingMoreRef.current = true;
      Promise.resolve(onLoadMoreSpans()).finally(() => {
        loadingMoreRef.current = false;
      });
    };

    timeline.addEventListener('scroll', maybeLoadMore);
    maybeLoadMore();
    return () => {
      timeline.removeEventListener('scroll', maybeLoadMore);
    };
  }, [state.timelineRef, onLoadMoreSpans, hasMoreSpans, isLoadingMoreSpans]);

  const closeMenu = useCallback(() => {
    setContextMenu(null);
  }, []);

  const getMenuItems = useCallback(
    (menu: ContextMenuState): ContextMenuItem[] => {
      const items: ContextMenuItem[] = [];

      // Sleep-specific: Wake Up (only on active runs)
      const isRunActive = !run.completedAt;
      if (
        menu.resourceType === 'sleep' &&
        menu.isActive &&
        isRunActive &&
        onWakeUpSleep
      ) {
        items.push({
          label: 'Wake Up Sleep',
          icon: <Clock className="h-3.5 w-3.5" />,
          action: () => {
            onWakeUpSleep(run.runId, menu.spanId)
              .then((result) => {
                if (result.stoppedCount > 0) {
                  toast.success('Sleep woken up', {
                    description: `Woke up ${String(result.stoppedCount)} sleep${result.stoppedCount > 1 ? 's' : ''}`,
                  });
                } else {
                  toast.info('No active sleeps found', {
                    description: 'The sleep may have already completed.',
                  });
                }
              })
              .catch((err: unknown) => {
                toast.error('Failed to wake up sleep', {
                  description:
                    err instanceof Error
                      ? err.message
                      : 'An unknown error occurred',
                });
              });
          },
        });
      }

      // Hook-specific: Resolve Hook (only on active, unresolved hooks)
      if (menu.resourceType === 'hook' && isRunActive && onResolveHook) {
        const span = spanLookup.get(menu.spanId);
        const hookData = span?.attributes?.data as
          | { token?: string; disposedAt?: unknown }
          | undefined;
        // Check data-level disposedAt, span events, AND local resolved state
        const isDisposed =
          Boolean(hookData?.disposedAt) ||
          Boolean(span?.events?.some((e) => e.name === 'hook_disposed')) ||
          resolvedHookIds.has(menu.spanId);
        if (hookData?.token && !isDisposed) {
          items.push({
            label: 'Resolve Hook',
            icon: <Send className="h-3.5 w-3.5" />,
            action: () => {
              setResolveHookTarget({
                hookId: menu.spanId,
                token: hookData.token!,
              });
            },
          });
        }
      }

      // Run-specific: Cancel (only on active runs)
      if (menu.resourceType === 'run' && isRunActive && onCancelRun) {
        items.push({
          label: 'Cancel Run',
          icon: <XCircle className="h-3.5 w-3.5" />,
          destructive: true,
          action: () => {
            onCancelRun(run.runId).catch((err: unknown) => {
              toast.error('Failed to cancel run', {
                description:
                  err instanceof Error
                    ? err.message
                    : 'An unknown error occurred',
              });
            });
          },
        });
      }

      // Common actions
      items.push({
        label: 'View Details',
        icon: <Info className="h-3.5 w-3.5" />,
        action: () => {
          dispatch({ type: 'select', id: menu.spanId });
        },
      });

      items.push({
        label: 'Copy Name',
        icon: <Type className="h-3.5 w-3.5" />,
        action: () => {
          navigator.clipboard
            .writeText(menu.spanName)
            .then(() => {
              toast.success('Name copied to clipboard');
            })
            .catch(() => {
              toast.error('Failed to copy name');
            });
        },
      });

      items.push({
        label: 'Copy ID',
        icon: <Copy className="h-3.5 w-3.5" />,
        action: () => {
          navigator.clipboard
            .writeText(menu.spanId)
            .then(() => {
              toast.success('ID copied to clipboard');
            })
            .catch(() => {
              toast.error('Failed to copy ID');
            });
        },
      });

      return items;
    },
    [
      dispatch,
      onWakeUpSleep,
      onCancelRun,
      onResolveHook,
      spanLookup,
      resolvedHookIds,
      run.runId,
      run.completedAt,
    ]
  );

  return (
    <div className="relative w-full h-full" ref={containerRef}>
      {children}
      {contextMenu ? (
        <SpanContextMenu
          menu={contextMenu}
          items={getMenuItems(contextMenu)}
          onClose={closeMenu}
        />
      ) : null}
      <ResolveHookModal
        isOpen={resolveHookTarget !== null}
        onClose={() => setResolveHookTarget(null)}
        onSubmit={handleResolveHook}
        isSubmitting={resolvingHook}
      />
    </div>
  );
}

/** Re-export SpanSelectionInfo for consumers */
export type { SpanSelectionInfo };

// ---------------------------------------------------------------------------
// Bridge: reads selected span from trace viewer context and notifies parent
// ---------------------------------------------------------------------------

/**
 * Bridge component that lives INSIDE the TraceViewerContextProvider and
 * reads the selected span info, passing it up to the parent via callback.
 * This allows the parent to render the detail panel OUTSIDE the context.
 */
function SelectionBridge({
  onSelectionChange,
}: {
  onSelectionChange: (info: SelectedSpanInfo | null) => void;
}) {
  const { state } = useTraceViewer();
  const { selected } = state;
  const onSelectionChangeRef = useRef(onSelectionChange);
  onSelectionChangeRef.current = onSelectionChange;
  const prevSpanIdRef = useRef<string | undefined>(undefined);

  useEffect(() => {
    const currentSpanId = selected?.span.spanId;
    if (currentSpanId === prevSpanIdRef.current) return;
    prevSpanIdRef.current = currentSpanId;

    if (selected) {
      onSelectionChangeRef.current({
        data: selected.span.attributes?.data,
        resource: selected.span.attributes?.resource as string | undefined,
        spanId: selected.span.spanId,
      });
    } else {
      onSelectionChangeRef.current(null);
    }
  }, [selected]);

  return null;
}

/**
 * Bridge component to deselect from outside the context.
 */
function DeselectBridge({ triggerDeselect }: { triggerDeselect: number }) {
  const { dispatch } = useTraceViewer();

  useEffect(() => {
    if (triggerDeselect > 0) {
      dispatch({ type: 'deselect' });
    }
  }, [triggerDeselect, dispatch]);

  return null;
}

/**
 * Bridge component to select a span from outside the context.
 */
function SelectBridge({
  selectRequest,
}: {
  selectRequest: { id: string; token: number } | null;
}) {
  const { dispatch } = useTraceViewer();

  useEffect(() => {
    if (selectRequest?.id) {
      dispatch({ type: 'select', id: selectRequest.id });
    }
  }, [dispatch, selectRequest]);

  return null;
}

// ---------------------------------------------------------------------------
// Panel chrome (header with name/duration, close button)
// ---------------------------------------------------------------------------

function PanelResizeHandle({
  onResize,
}: {
  onResize: (deltaX: number) => void;
}) {
  const handlePointerDown = useCallback(
    (e: React.PointerEvent) => {
      e.preventDefault();
      let lastX = e.clientX;
      const onPointerMove = (moveEvent: PointerEvent) => {
        const deltaX = lastX - moveEvent.clientX;
        lastX = moveEvent.clientX;
        onResize(deltaX);
      };
      const onPointerUp = () => {
        document.removeEventListener('pointermove', onPointerMove);
        document.removeEventListener('pointerup', onPointerUp);
      };
      document.addEventListener('pointermove', onPointerMove);
      document.addEventListener('pointerup', onPointerUp);
    },
    [onResize]
  );

  return (
    <div
      className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-400/50 z-10"
      onPointerDown={handlePointerDown}
    />
  );
}

// ---------------------------------------------------------------------------
// Trace viewer footer — loading / waiting indicator
// ---------------------------------------------------------------------------

function TraceViewerFooter({
  hasMore,
  isLive,
  isInitialLoading,
}: {
  hasMore: boolean;
  isLive: boolean;
  isInitialLoading: boolean;
}): ReactNode {
  const style = { color: 'var(--ds-gray-900)' };
  if (hasMore || isInitialLoading) {
    return (
      <div
        className="flex items-center justify-center gap-2 py-3 text-xs"
        style={style}
      >
        <Spinner size={14} />
        Loading more events…
      </div>
    );
  }
  if (isLive) {
    return (
      <div
        className="flex items-center justify-center py-3 text-xs"
        style={style}
      >
        Waiting for more events…
      </div>
    );
  }
  return (
    <div
      className="flex items-center justify-center py-3 text-xs"
      style={style}
    >
      End of run
    </div>
  );
}

// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------

export const WorkflowTraceViewer = ({
  run,
  events,
  isLoading,
  error,
  spanDetailData,
  spanDetailLoading,
  spanDetailError,
  onWakeUpSleep,
  onResolveHook,
  onCancelRun,
  onStreamClick,
  onSpanSelect,
  onLoadEventData,
  onLoadMoreSpans,
  hasMoreSpans = false,
  isLoadingMoreSpans = false,
  encryptionKey,
  onDecrypt,
  isDecrypting = false,
  hasEncryptedData = false,
}: {
  run: WorkflowRun;
  events: Event[];
  isLoading?: boolean;
  error?: Error | null;
  spanDetailData?: WorkflowRun | Step | Hook | Event | null;
  spanDetailLoading?: boolean;
  spanDetailError?: Error | null;
  onWakeUpSleep?: (
    runId: string,
    correlationId: string
  ) => Promise<{ stoppedCount: number }>;
  onResolveHook?: (
    hookToken: string,
    payload: unknown,
    hook?: Hook
  ) => Promise<void>;
  /** Callback to cancel the current run */
  onCancelRun?: (runId: string) => Promise<void>;
  /** Callback when a stream reference is clicked in the detail panel */
  onStreamClick?: (streamId: string) => void;
  /** Callback when a span is selected. */
  onSpanSelect?: (info: SpanSelectionInfo) => void;
  /** Callback to load event data for a specific event (lazy loading in sidebar) */
  onLoadEventData?: (
    correlationId: string,
    eventId: string
  ) => Promise<unknown | null>;
  /** Load next trace page when vertical scroll reaches bottom. */
  onLoadMoreSpans?: () => void | Promise<void>;
  /** Whether trace pagination has more data to load. */
  hasMoreSpans?: boolean;
  /** Whether trace pagination is currently fetching another page. */
  isLoadingMoreSpans?: boolean;
  /** Encryption key (available after Decrypt), threaded to event list for re-loading */
  encryptionKey?: Uint8Array;
  /** Callback to initiate decryption of encrypted run data */
  onDecrypt?: () => void;
  /** Whether the encryption key is currently being fetched */
  isDecrypting?: boolean;
  /** Run-level hint: the run contains encrypted data (from probe). */
  hasEncryptedData?: boolean;
}) => {
  const toast = useToast();
  const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
    null
  );
  const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
  const [deselectTrigger, setDeselectTrigger] = useState(0);
  const [selectRequest, setSelectRequest] = useState<{
    id: string;
    token: number;
  } | null>(null);

  const isLive = Boolean(run && !run.completedAt);

  // Build trace only when actual data changes — no timer-driven rebuilds.
  // Active span widths are animated imperatively by useLiveTick at 60fps.
  const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
    if (!run?.runId) {
      return undefined;
    }
    return buildTrace(run, events, new Date());
    // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth
  }, [run, events]);
  const trace = traceWithMeta;

  useEffect(() => {
    if (error && !isLoading) {
      console.error(error);
      toast.error('Error loading workflow trace data', {
        description: error.message,
      });
    }
  }, [error, isLoading]);

  const handleSpanSelect = useCallback(
    (info: SpanSelectionInfo) => {
      onSpanSelect?.(info);
    },
    [onSpanSelect]
  );

  const handleSelectionChange = useCallback(
    (info: SelectedSpanInfo | null) => {
      if (info) {
        // Filter raw events by the selected span's correlationId (stepId/hookId)
        // This bypasses the trace worker pipeline entirely.
        const correlationId = info.spanId;
        const rawEvents = correlationId
          ? events.filter((e) => e.correlationId === correlationId)
          : [];
        setSelectedSpan({ ...info, rawEvents });
      } else {
        setSelectedSpan(null);
      }
    },
    [events]
  );

  // Keep selected span raw events live as polling updates arrive.
  useEffect(() => {
    const correlationId = selectedSpan?.spanId;
    if (!correlationId) return;

    const nextRawEvents = events.filter(
      (e) => e.correlationId === correlationId
    );
    setSelectedSpan((prev) => {
      if (!prev || prev.spanId !== correlationId) {
        return prev;
      }

      const prevRawEvents = prev.rawEvents ?? [];
      const isSame =
        prevRawEvents.length === nextRawEvents.length &&
        prevRawEvents.every(
          (event, index) => event.eventId === nextRawEvents[index]?.eventId
        );
      if (isSame) {
        return prev;
      }

      return {
        ...prev,
        rawEvents: nextRawEvents,
      };
    });
  }, [events, selectedSpan?.spanId]);

  const handleClose = useCallback(() => {
    setSelectedSpan(null);
    setDeselectTrigger((n) => n + 1);
  }, []);

  const handleResize = useCallback((deltaX: number) => {
    setPanelWidth((w) => Math.max(MIN_PANEL_WIDTH, w + deltaX));
  }, []);
  const selectedSpanIndex = useMemo(() => {
    if (!selectedSpan?.spanId || !trace) return -1;
    return trace.spans.findIndex((span) => span.spanId === selectedSpan.spanId);
  }, [selectedSpan?.spanId, trace]);
  const canSelectPrevSpan = selectedSpanIndex > 0;
  const canSelectNextSpan =
    selectedSpanIndex >= 0 &&
    trace != null &&
    selectedSpanIndex < trace.spans.length - 1;
  const handleSelectPrevSpan = useCallback(() => {
    if (!trace || selectedSpanIndex <= 0) return;
    const targetId = trace.spans[selectedSpanIndex - 1]?.spanId;
    if (!targetId) return;
    setSelectRequest({ id: targetId, token: Date.now() });
  }, [selectedSpanIndex, trace]);
  const handleSelectNextSpan = useCallback(() => {
    if (!trace || selectedSpanIndex < 0) return;
    const targetId = trace.spans[selectedSpanIndex + 1]?.spanId;
    if (!targetId) return;
    setSelectRequest({ id: targetId, token: Date.now() });
  }, [selectedSpanIndex, trace]);

  // Get the selected span name for the panel header
  const selectedSpanName = useMemo(() => {
    if (!selectedSpan?.data) return undefined;
    const data = selectedSpan.data as Record<string, unknown>;
    const stepName = data.stepName as string | undefined;
    const workflowName = data.workflowName as string | undefined;
    return (
      (stepName ? parseStepName(stepName)?.shortName : undefined) ??
      (workflowName ? parseWorkflowName(workflowName)?.shortName : undefined) ??
      stepName ??
      workflowName ??
      (data.hookId as string) ??
      'Details'
    );
  }, [selectedSpan?.data]);

  if (!trace) {
    return (
      <div className="relative w-full h-full">
        <div className="border-b border-gray-alpha-400 w-full" />
        <Skeleton className="w-full ml-2 mt-1 mb-1 h-[56px]" />
        <div className="p-2 relative w-full">
          <Skeleton className="w-full mt-6 h-[20px]" />
          <Skeleton className="w-[10%] mt-2 ml-6 h-[20px]" />
          <Skeleton className="w-[10%] mt-2 ml-12 h-[20px]" />
          <Skeleton className="w-[20%] mt-2 ml-16 h-[20px]" />
        </div>
      </div>
    );
  }

  return (
    <div className="relative w-full h-full flex flex-row">
      {/* Timeline (takes remaining space) */}
      <div className="flex-1 min-w-0 relative">
        <TraceViewerContextProvider
          customSpanClassNameFunc={getCustomSpanClassName}
          customSpanEventClassNameFunc={getCustomSpanEventClassName}
        >
          <SelectionBridge onSelectionChange={handleSelectionChange} />
          <DeselectBridge triggerDeselect={deselectTrigger} />
          <SelectBridge selectRequest={selectRequest} />
          <TraceViewerWithContextMenu
            trace={trace}
            run={run}
            isLive={isLive}
            onWakeUpSleep={onWakeUpSleep}
            onCancelRun={onCancelRun}
            onResolveHook={onResolveHook}
            onLoadMoreSpans={onLoadMoreSpans}
            hasMoreSpans={hasMoreSpans}
            isLoadingMoreSpans={isLoadingMoreSpans}
          >
            <TraceViewerTimeline
              eagerRender
              height="100%"
              isLive={isLive}
              trace={trace}
              knownDurationMs={traceWithMeta?.knownDurationMs}
              hasMoreData={hasMoreSpans || Boolean(isLoading)}
              footer={
                <TraceViewerFooter
                  hasMore={hasMoreSpans}
                  isLive={isLive}
                  isInitialLoading={Boolean(isLoading)}
                />
              }
            />
          </TraceViewerWithContextMenu>
        </TraceViewerContextProvider>
      </div>

      {/* Detail panel (rendered outside the context, as a sibling) */}
      {selectedSpan && (
        <div
          className="relative border-l flex-shrink-0 flex flex-col"
          style={{
            width: panelWidth,
            borderColor: 'var(--ds-gray-200)',
            backgroundColor: 'var(--ds-background-100)',
          }}
        >
          <PanelResizeHandle onResize={handleResize} />
          {/* Panel header */}
          <div
            className="flex items-center justify-between px-3 py-2 border-y flex-shrink-0"
            style={{ borderColor: 'var(--ds-gray-200)' }}
          >
            <div className="min-w-0 flex-1">
              <div
                className="text-sm font-medium truncate"
                style={{ color: 'var(--ds-gray-1000)' }}
                title={selectedSpanName}
              >
                {selectedSpanName}
              </div>
            </div>
            <div className="flex items-center">
              <button
                type="button"
                aria-label="Previous span"
                onClick={handleSelectPrevSpan}
                disabled={!canSelectPrevSpan}
                style={{
                  display: 'flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  marginLeft: 6,
                  width: 24,
                  height: 24,
                  padding: 0,
                  borderRadius: 6,
                  border: 'none',
                  background: 'transparent',
                  color: 'var(--ds-gray-700)',
                  cursor: canSelectPrevSpan ? 'pointer' : 'not-allowed',
                  opacity: canSelectPrevSpan ? 1 : 0.45,
                  flexShrink: 0,
                  transition: 'background 0.15s',
                }}
                onMouseEnter={(e) => {
                  if (!canSelectPrevSpan) return;
                  e.currentTarget.style.background = 'var(--ds-gray-alpha-100)';
                }}
                onMouseLeave={(e) => {
                  e.currentTarget.style.background = 'transparent';
                }}
              >
                <ChevronUp size={16} />
              </button>
              <button
                type="button"
                aria-label="Next span"
                onClick={handleSelectNextSpan}
                disabled={!canSelectNextSpan}
                style={{
                  display: 'flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  marginLeft: 2,
                  width: 24,
                  height: 24,
                  padding: 0,
                  borderRadius: 6,
                  border: 'none',
                  background: 'transparent',
                  color: 'var(--ds-gray-700)',
                  cursor: canSelectNextSpan ? 'pointer' : 'not-allowed',
                  opacity: canSelectNextSpan ? 1 : 0.45,
                  flexShrink: 0,
                  transition: 'background 0.15s',
                }}
                onMouseEnter={(e) => {
                  if (!canSelectNextSpan) return;
                  e.currentTarget.style.background = 'var(--ds-gray-alpha-100)';
                }}
                onMouseLeave={(e) => {
                  e.currentTarget.style.background = 'transparent';
                }}
              >
                <ChevronDown size={16} />
              </button>
            </div>
            <div
              className="flex items-center"
              style={{
                borderLeft: '1px solid var(--ds-gray-300)',
                marginLeft: 6,
              }}
            >
              <button
                type="button"
                aria-label="Close panel"
                onClick={handleClose}
                style={{
                  display: 'flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  marginLeft: 6,
                  width: 24,
                  height: 24,
                  padding: 0,
                  borderRadius: 6,
                  border: 'none',
                  background: 'transparent',
                  color: 'var(--ds-gray-700)',
                  cursor: 'pointer',
                  flexShrink: 0,
                  transition: 'background 0.15s',
                }}
                onMouseEnter={(e) => {
                  e.currentTarget.style.background = 'var(--ds-gray-alpha-100)';
                }}
                onMouseLeave={(e) => {
                  e.currentTarget.style.background = 'transparent';
                }}
              >
                <X size={16} />
              </button>
            </div>
          </div>
          {/* Panel body */}
          <div className="flex-1 overflow-y-auto">
            <ErrorBoundary title="Failed to load entity details">
              <EntityDetailPanel
                run={run}
                onStreamClick={onStreamClick}
                spanDetailData={spanDetailData ?? null}
                spanDetailError={spanDetailError}
                spanDetailLoading={spanDetailLoading}
                onSpanSelect={handleSpanSelect}
                onWakeUpSleep={onWakeUpSleep}
                onLoadEventData={onLoadEventData}
                onResolveHook={onResolveHook}
                encryptionKey={encryptionKey}
                onDecrypt={onDecrypt}
                isDecrypting={isDecrypting}
                selectedSpan={selectedSpan}
                hasEncryptedData={hasEncryptedData}
              />
            </ErrorBoundary>
          </div>
        </div>
      )}
    </div>
  );
};
