import {
  type JSX,
  memo,
  type RefObject,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';

import { useAppTranslation } from '@features/localization/renderer';
import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
  CARD_BG,
  CARD_BG_ZEBRA,
  CARD_BORDER_STYLE,
  CARD_ICON_MUTED,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import {
  areStringArraysEqual,
  areStringMapsEqual,
  areThoughtMessagesEquivalentForRender,
} from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';

import { buildThoughtDisplayContent } from './activityMarkdown';
import {
  encodeCacheParts,
  extractMarkdownPlainTextCached,
  getCachedString,
} from './activityRenderCache';
import {
  AnimatedHeightReveal,
  ENTRY_REVEAL_ANIMATION_MS,
  ENTRY_REVEAL_EASING,
} from './AnimatedHeightReveal';
import { ThoughtBodyContent } from './ThoughtBodyContent';

import type { InboxMessage, ToolCallMeta } from '@shared/types';

export interface LeadThoughtGroup {
  type: 'lead-thoughts';
  thoughts: InboxMessage[];
}

/**
 * Check if a message is a context compaction boundary (system event from lead process).
 */
export function isCompactionMessage(msg: InboxMessage): boolean {
  return msg.from === 'system' && !!msg.messageId?.startsWith('compact-');
}

/**
 * Check if a message is an intermediate lead "thought" (assistant text) rather than
 * an official message (SendMessage, direct reply, inbox, etc.).
 */
export function isLeadThought(msg: InboxMessage): boolean {
  if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
  // Compaction boundary events are system messages, not lead thoughts
  if (isCompactionMessage(msg)) return false;
  if (msg.messageKind === 'slash_command_result') return false;
  // Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden
  if (isThoughtProtocolNoise(msg.text)) return false;
  if (isTeamInternalControlMessageText(msg.text)) return false;
  if (msg.source === 'lead_session') return true;
  if (msg.source === 'lead_process') return true;
  return false;
}

/**
 * Check if a message from lead session/process is protocol noise that should be
 * completely excluded from the timeline (not shown as thoughts OR standalone messages).
 *
 * When `isLeadThought` returns false due to `isThoughtProtocolNoise`, the message
 * falls through to become a standalone ActivityItem — but ActivityItem can't parse
 * noise JSON wrapped in `<teammate-message>` tags. This helper catches those cases
 * so `groupTimelineItems` can skip them entirely.
 */
function isLeadSessionNoise(msg: InboxMessage): boolean {
  if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false;
  if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
  return isThoughtProtocolNoise(msg.text) || isTeamInternalControlMessageText(msg.text);
}

export type TimelineItem =
  | { type: 'message'; message: InboxMessage }
  | { type: 'lead-thoughts'; group: LeadThoughtGroup };

/**
 * Use the oldest thought as the group's stable identity so live thoughts can prepend
 * without remounting the whole group on every update.
 */
export function getThoughtGroupKey(group: LeadThoughtGroup): string {
  const oldestThought = group.thoughts[group.thoughts.length - 1];
  return `thoughts-${toMessageKey(oldestThought)}`;
}

/**
 * Group consecutive lead thoughts into compact blocks.
 * Even a single thought gets its own group (rendered as LeadThoughtsGroupRow).
 */
export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
  const result: TimelineItem[] = [];
  let pendingThoughts: InboxMessage[] = [];
  const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean =>
    (a.leadSessionId ?? null) === (b.leadSessionId ?? null);

  const flushThoughts = (): void => {
    if (pendingThoughts.length === 0) return;
    result.push({
      type: 'lead-thoughts',
      group: { type: 'lead-thoughts', thoughts: pendingThoughts },
    });
    pendingThoughts = [];
  };

  for (const msg of messages) {
    if (isLeadThought(msg)) {
      const previousThought = pendingThoughts[pendingThoughts.length - 1];
      if (previousThought && !hasSameLeadSession(previousThought, msg)) {
        flushThoughts();
      }
      pendingThoughts.push(msg);
    } else {
      // Skip lead session/process messages that are protocol noise — they should
      // not appear in the timeline at all (neither as thoughts nor as standalone messages).
      // isLeadThought already rejects these from thoughts, but without this guard
      // they fall through as standalone ActivityItem cards that can't parse the noise JSON.
      // Check BEFORE flushThoughts() so noise between two thoughts doesn't split the group.
      if (isLeadSessionNoise(msg)) continue;
      flushThoughts();
      result.push({ type: 'message', message: msg });
    }
  }
  flushThoughts();
  return result;
}

const VIEWPORT_THRESHOLD = 0.15;
const LIVE_WINDOW_MS = 5_000;
const COLLAPSED_THOUGHTS_HEIGHT = 200;
const AUTO_SCROLL_THRESHOLD = 30;
const THOUGHT_HEIGHT_ANIMATION_MS = ENTRY_REVEAL_ANIMATION_MS;
const leadThoughtTimeCache = new Map<string, string>();

interface LeadThoughtsGroupRowProps {
  group: LeadThoughtGroup;
  memberColor?: string;
  isNew?: boolean;
  onVisible?: (message: InboxMessage) => void;
  /**
   * Root element for IntersectionObserver-based visibility tracking. When
   * omitted, the observer falls back to the document viewport — correct for
   * top-level renders, incorrect when the row is inside a scroll container
   * (sidebar, bottom-sheet) that can clip the row while the document
   * viewport still contains it.
   */
  observerRoot?: RefObject<HTMLElement | null>;
  /** When false, the live indicator is always off (for historical thought groups). */
  canBeLive?: boolean;
  /** Whether the owning team is currently alive. */
  isTeamAlive?: boolean;
  /** Current lead activity status for the owning team. */
  leadActivity?: string;
  /** Latest lead context timestamp for the owning team. */
  leadContextUpdatedAt?: string;
  /** When true, apply a subtle lighter background for zebra-striped lists. */
  zebraShade?: boolean;
  /** Collapsed-mode primitives stabilized by ActivityTimeline. */
  collapseMode: 'default' | 'managed';
  isCollapsed: boolean;
  canToggleCollapse: boolean;
  collapseToggleKey?: string;
  onToggleCollapse?: (key: string) => void;
  /** Called when a task ID link (e.g. #10) is clicked in thought text. */
  onTaskIdClick?: (taskId: string) => void;
  /** Map of member name → color name for @mention badge rendering. */
  memberColorMap?: Map<string, string>;
  /** Team names used for mention/team-link rendering. */
  teamNames?: string[];
  /** Team color mapping used by markdown viewers. */
  teamColorByName?: ReadonlyMap<string, string>;
  /** Opens a team tab from cross-team badges or team:// links. */
  onTeamClick?: (teamName: string) => void;
  /** Called when user clicks the reply button on a thought. */
  onReply?: (message: InboxMessage) => void;
  /** Compact header mode for narrow message lists. */
  compactHeader?: boolean;
  /** Callback to expand this item into a fullscreen dialog. */
  onExpand?: (key: string) => void;
  /** Stable key for expand identification. */
  expandItemKey?: string;
}

function formatTime(timestamp: string): string {
  return getCachedString(leadThoughtTimeCache, encodeCacheParts(['minute', timestamp]), () => {
    const d = new Date(timestamp);
    if (Number.isNaN(d.getTime())) return timestamp;
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  });
}

export function formatTimeWithSec(timestamp: string): string {
  return getCachedString(leadThoughtTimeCache, encodeCacheParts(['second', timestamp]), () => {
    const d = new Date(timestamp);
    if (Number.isNaN(d.getTime())) return timestamp;
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
  });
}

function isRecentTimestamp(timestamp: string): boolean {
  const t = Date.parse(timestamp);
  if (Number.isNaN(t)) return false;
  return Date.now() - t <= LIVE_WINDOW_MS;
}

export const ToolSummaryTooltipContent = ({
  toolCalls,
  toolSummary,
}: Readonly<{
  toolCalls?: ToolCallMeta[];
  toolSummary?: string;
}>): JSX.Element => {
  if (toolCalls && toolCalls.length > 0) {
    return (
      <div className="flex max-h-[300px] flex-col gap-0.5 overflow-y-auto">
        <div className="mb-0.5 text-[10px] text-text-secondary">
          {toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
        </div>
        {toolCalls.map((tc, i) => {
          const isAgent = tc.name === 'Agent' || tc.name === 'TaskCreate';
          return (
            <div key={i} className={isAgent ? 'mt-0.5' : 'flex items-baseline gap-2'}>
              <span className={`shrink-0 font-semibold ${isAgent ? 'text-violet-400' : ''}`}>
                {isAgent ? '🤖 ' : ''}
                {tc.name}
              </span>
              {tc.preview && (
                <span
                  className={`text-text-secondary ${isAgent ? 'mt-0.5 block text-[10px]' : 'truncate'}`}
                >
                  {tc.preview}
                </span>
              )}
            </div>
          );
        })}
      </div>
    );
  }

  if (toolSummary) {
    const parsed = parseToolSummary(toolSummary);
    if (parsed) {
      const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
      return (
        <div className="flex flex-col gap-0.5">
          <div className="mb-0.5 text-[10px] text-text-secondary">
            {parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
          </div>
          {sorted.map(([name, count]) => (
            <div key={name} className="flex justify-between gap-3">
              <span>{name}</span>
              <span className="text-text-secondary">×{count}</span>
            </div>
          ))}
        </div>
      );
    }
  }

  return <span>{toolSummary ?? ''}</span>;
};

interface LeadThoughtItemProps {
  thought: InboxMessage;
  showDivider: boolean;
  shouldAnimate: boolean;
  onTaskIdClick?: (taskId: string) => void;
  memberColorMap?: Map<string, string>;
  teamNames?: string[];
  teamColorByName?: ReadonlyMap<string, string>;
  onTeamClick?: (teamName: string) => void;
  onReply?: (message: InboxMessage) => void;
}

function hasSelectionWithin(container: HTMLElement | null): boolean {
  if (!container) return false;
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
    return false;
  }

  const anchorNode = selection.anchorNode;
  const focusNode = selection.focusNode;
  return (
    (!!anchorNode && container.contains(anchorNode)) ||
    (!!focusNode && container.contains(focusNode))
  );
}

function areThoughtGroupsEquivalent(prev: LeadThoughtGroup, next: LeadThoughtGroup): boolean {
  if (prev === next) return true;
  if (getThoughtGroupKey(prev) !== getThoughtGroupKey(next)) return false;
  if (prev.thoughts.length !== next.thoughts.length) return false;
  for (let i = 0; i < prev.thoughts.length; i++) {
    if (!areThoughtMessagesEquivalentForRender(prev.thoughts[i], next.thoughts[i])) {
      return false;
    }
  }
  return true;
}

const LeadThoughtItem = memo(
  function LeadThoughtItem({
    thought,
    showDivider,
    shouldAnimate,
    onTaskIdClick,
    memberColorMap,
    teamNames = [],
    teamColorByName,
    onTeamClick,
    onReply,
  }: LeadThoughtItemProps): JSX.Element {
    const wrapperRef = useRef<HTMLDivElement>(null);
    const contentRef = useRef<HTMLDivElement>(null);
    const previousHeightRef = useRef<number | null>(null);
    const animationFrameRef = useRef<number | null>(null);
    const cleanupTimerRef = useRef<number | null>(null);
    const initialAnimationCompletedRef = useRef(!shouldAnimate);
    const [shouldAnimateOnMount] = useState(() => shouldAnimate);

    const clearPendingAnimation = useCallback(() => {
      if (animationFrameRef.current !== null) {
        cancelAnimationFrame(animationFrameRef.current);
        animationFrameRef.current = null;
      }
      if (cleanupTimerRef.current !== null) {
        window.clearTimeout(cleanupTimerRef.current);
        cleanupTimerRef.current = null;
      }
    }, []);

    const resetWrapperStyles = useCallback(() => {
      const wrapper = wrapperRef.current;
      if (!wrapper) return;
      wrapper.style.height = 'auto';
      wrapper.style.opacity = '1';
      wrapper.style.overflow = 'visible';
      wrapper.style.transition = '';
      wrapper.style.willChange = '';
    }, []);

    useLayoutEffect(() => {
      const wrapper = wrapperRef.current;
      if (!wrapper) return;

      if (!shouldAnimateOnMount) {
        initialAnimationCompletedRef.current = true;
        resetWrapperStyles();
        return;
      }

      const content = contentRef.current;
      if (!content) return;

      const applyTransition = (targetHeight: number): void => {
        wrapper.style.transition = [
          `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`,
          `opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`,
        ].join(', ');
        wrapper.style.height = `${Math.max(targetHeight, 0)}px`;
        wrapper.style.opacity = '1';
      };

      const scheduleTransition = (targetHeight: number): void => {
        animationFrameRef.current = requestAnimationFrame(() => {
          applyTransition(targetHeight);
        });
      };

      const animateHeight = (
        targetHeight: number,
        startHeight: number,
        startOpacity: number
      ): void => {
        initialAnimationCompletedRef.current = false;
        clearPendingAnimation();
        wrapper.style.transition = 'none';
        wrapper.style.overflow = 'hidden';
        wrapper.style.height = `${Math.max(startHeight, 0)}px`;
        wrapper.style.opacity = `${startOpacity}`;
        wrapper.style.willChange = 'height, opacity';
        // Force layout reflow so the browser registers the starting values
        const _reflow = wrapper.offsetHeight;
        if (_reflow < -1) return; // unreachable — prevents unused-variable lint

        animationFrameRef.current = requestAnimationFrame(() => {
          scheduleTransition(targetHeight);
        });

        cleanupTimerRef.current = window.setTimeout(() => {
          resetWrapperStyles();
          initialAnimationCompletedRef.current = true;
          cleanupTimerRef.current = null;
        }, THOUGHT_HEIGHT_ANIMATION_MS + 40);
      };

      const syncHeight = (nextHeight: number, animateFromZero: boolean): void => {
        const previousHeight = previousHeightRef.current;
        previousHeightRef.current = nextHeight;

        if (previousHeight === null) {
          if (nextHeight > 0 && animateFromZero) {
            animateHeight(nextHeight, 0, 0);
          } else {
            initialAnimationCompletedRef.current = true;
            resetWrapperStyles();
          }
          return;
        }

        if (Math.abs(nextHeight - previousHeight) < 1) return;

        // Only the first reveal should animate. Late content growth (for example when
        // tool summary metadata appears after the text) should resize naturally.
        if (initialAnimationCompletedRef.current) {
          resetWrapperStyles();
          return;
        }

        const renderedHeight = wrapper.getBoundingClientRect().height;
        animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1);
      };

      syncHeight(content.getBoundingClientRect().height, true);

      const observer = new ResizeObserver((entries) => {
        const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height;
        syncHeight(nextHeight, false);
      });
      observer.observe(content);

      return () => {
        observer.disconnect();
        clearPendingAnimation();
        initialAnimationCompletedRef.current = true;
        resetWrapperStyles();
      };
    }, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]);

    useEffect(
      () => () => {
        clearPendingAnimation();
      },
      [clearPendingAnimation]
    );

    return (
      <div ref={wrapperRef}>
        <div ref={contentRef}>
          <ThoughtBodyContent
            thought={thought}
            showDivider={showDivider}
            onTaskIdClick={onTaskIdClick}
            onReply={onReply}
            memberColorMap={memberColorMap}
            teamNames={teamNames}
            teamColorByName={teamColorByName}
            onTeamClick={onTeamClick}
          />
        </div>
      </div>
    );
  },
  (prev, next) =>
    prev.showDivider === next.showDivider &&
    prev.shouldAnimate === next.shouldAnimate &&
    prev.onTaskIdClick === next.onTaskIdClick &&
    prev.memberColorMap === next.memberColorMap &&
    areStringArraysEqual(prev.teamNames, next.teamNames) &&
    areStringMapsEqual(prev.teamColorByName, next.teamColorByName) &&
    prev.onTeamClick === next.onTeamClick &&
    prev.onReply === next.onReply &&
    areThoughtMessagesEquivalentForRender(prev.thought, next.thought)
);

const LiveThoughtStatusBadge = ({
  canBeLive,
  isTeamAlive,
  leadActivity,
  leadContextUpdatedAt,
  newestTimestamp,
}: {
  canBeLive?: boolean;
  isTeamAlive?: boolean;
  leadActivity?: string;
  leadContextUpdatedAt?: string;
  newestTimestamp: string;
}): JSX.Element | null => {
  const computeIsLive = useCallback(
    () =>
      canBeLive !== false &&
      !!isTeamAlive &&
      (leadActivity === 'active' ||
        (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
        isRecentTimestamp(newestTimestamp)),
    [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newestTimestamp]
  );

  const [isLive, setIsLive] = useState(computeIsLive);

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
    setIsLive(computeIsLive());
    const id = window.setInterval(() => setIsLive(computeIsLive()), 1000);
    return () => window.clearInterval(id);
  }, [computeIsLive]);

  if (!isLive) return null;

  return (
    <span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
      <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
      <span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
    </span>
  );
};

const LeadThoughtsGroupRowComponent = ({
  group,
  memberColor,
  isNew,
  onVisible,
  observerRoot,
  canBeLive,
  isTeamAlive,
  leadActivity,
  leadContextUpdatedAt,
  zebraShade,
  collapseMode,
  isCollapsed,
  canToggleCollapse,
  collapseToggleKey,
  onToggleCollapse,
  onTaskIdClick,
  memberColorMap,
  teamNames = [],
  teamColorByName,
  onTeamClick,
  onReply,
  compactHeader = false,
  onExpand,
  expandItemKey,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
  const { t } = useAppTranslation('team');
  const { isLight } = useTheme();
  const ref = useRef<HTMLDivElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const isUserScrolledUpRef = useRef(false);
  const distanceFromBottomRef = useRef(0);
  const scrollSyncFrameRef = useRef<number | null>(null);

  const colors = getTeamColorSet(memberColor ?? '');
  const { thoughts } = group;
  // thoughts is newest-first; first=newest, last=oldest
  const newest = thoughts[0];
  const oldest = thoughts[thoughts.length - 1];
  const leadName = newest.from;

  // Aggregate tool usage across all thoughts in this group
  const totalToolSummary = useMemo(() => {
    const merged: Record<string, number> = {};
    let total = 0;
    for (const t of thoughts) {
      const parsed = parseToolSummary(t.toolSummary);
      if (!parsed) continue;
      total += parsed.total;
      for (const [name, count] of Object.entries(parsed.byName)) {
        merged[name] = (merged[name] ?? 0) + count;
      }
    }
    if (total === 0) return null;
    return formatToolSummary({ total, byName: merged });
  }, [thoughts]);

  // Aggregate all toolCalls across thoughts for header tooltip
  const allToolCalls = useMemo(() => {
    const calls: ToolCallMeta[] = [];
    for (const t of thoughts) {
      if (t.toolCalls) calls.push(...t.toolCalls);
    }
    return calls.length > 0 ? calls : undefined;
  }, [thoughts]);

  const [expanded, setExpanded] = useState(false);
  const [needsTruncation, setNeedsTruncation] = useState(false);
  const isManaged = collapseMode === 'managed';
  const isBodyVisible = isManaged ? !isCollapsed : true;
  const canToggleBodyVisibility = isManaged && canToggleCollapse;
  const useCompactCollapsedHeader = compactHeader && !isBodyVisible;

  // Chronological order for rendering (oldest at top, newest at bottom)
  const chronologicalThoughts = useMemo(
    () => (isBodyVisible ? [...thoughts].reverse() : []),
    [isBodyVisible, thoughts]
  );

  // Reuse the same markdown preprocessing as the expanded thought body.
  const compactPreviewMarkdown = useMemo(() => {
    if (isBodyVisible) return null;
    // Try newest first (most relevant), then scan for any text
    for (const t of thoughts) {
      if (t.text && t.text.trim()) {
        const stripped = stripAgentBlocks(t.text).trim();
        if (stripped) {
          return buildThoughtDisplayContent(t, memberColorMap, teamNames, {
            preserveLineBreaks: false,
            stripAgentOnlyBlocks: true,
          })
            .replace(/\n+/g, ' ')
            .trim();
        }
      }
    }
    return totalToolSummary;
  }, [isBodyVisible, memberColorMap, teamNames, thoughts, totalToolSummary]);
  const compactPreviewTooltipText = useMemo(() => {
    if (!compactPreviewMarkdown) return null;
    const normalized = extractMarkdownPlainTextCached(compactPreviewMarkdown ?? '')
      .replace(/\n+/g, ' ')
      .trim();
    return normalized || compactPreviewMarkdown;
  }, [compactPreviewMarkdown]);

  // Detect if any thought in this group is an API error
  const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]);

  const handleBodyToggle = useCallback(() => {
    if (canToggleBodyVisibility && collapseToggleKey) {
      onToggleCollapse?.(collapseToggleKey);
    }
  }, [canToggleBodyVisibility, collapseToggleKey, onToggleCollapse]);
  const shouldAnimateLatestThought = canBeLive !== false && isRecentTimestamp(newest.timestamp);

  // Track how many thoughts have been reported as visible so far.
  const reportedCountRef = useRef(0);

  useEffect(() => {
    if (!onVisible) return;
    const el = ref.current;
    if (!el) return;
    // Resolve observer root at effect-time. Falls back to the document
    // viewport when no root is provided — preserves pre-contract behavior.
    const root = observerRoot?.current ?? null;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry?.isIntersecting) return;
        const alreadyReported = reportedCountRef.current;
        if (alreadyReported >= thoughts.length) return;
        for (let i = alreadyReported; i < thoughts.length; i++) {
          onVisible(thoughts[i]);
        }
        reportedCountRef.current = thoughts.length;
      },
      { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [onVisible, observerRoot, thoughts]);

  const clearPendingScrollSync = useCallback(() => {
    if (scrollSyncFrameRef.current !== null) {
      cancelAnimationFrame(scrollSyncFrameRef.current);
      scrollSyncFrameRef.current = null;
    }
  }, []);

  const queueScrollSync = useCallback(
    (mode: 'bottom' | 'preserve') => {
      clearPendingScrollSync();
      scrollSyncFrameRef.current = requestAnimationFrame(() => {
        scrollSyncFrameRef.current = requestAnimationFrame(() => {
          const scrollEl = scrollRef.current;
          if (!scrollEl || expanded || !isBodyVisible) {
            scrollSyncFrameRef.current = null;
            return;
          }
          if (hasSelectionWithin(scrollEl)) {
            scrollSyncFrameRef.current = null;
            return;
          }

          const nextScrollTop =
            mode === 'bottom'
              ? scrollEl.scrollHeight - scrollEl.clientHeight
              : scrollEl.scrollHeight - scrollEl.clientHeight - distanceFromBottomRef.current;

          scrollEl.scrollTop = Math.max(0, nextScrollTop);
          if (mode === 'bottom') {
            distanceFromBottomRef.current = 0;
            isUserScrolledUpRef.current = false;
          }
          scrollSyncFrameRef.current = null;
        });
      });
    },
    [clearPendingScrollSync, expanded, isBodyVisible]
  );

  const syncScrollableBody = useCallback(
    (forceScrollToBottom = false) => {
      const scrollEl = scrollRef.current;
      const contentEl = contentRef.current;
      if (!scrollEl || !contentEl) return;

      const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1;
      setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation));

      if (expanded || !isBodyVisible) return;
      if (!nextNeedsTruncation) {
        clearPendingScrollSync();
        distanceFromBottomRef.current = 0;
        isUserScrolledUpRef.current = false;
        return;
      }

      if (forceScrollToBottom || !isUserScrolledUpRef.current) {
        queueScrollSync('bottom');
        return;
      }

      queueScrollSync('preserve');
    },
    [clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
  );

  useLayoutEffect(() => {
    if (!isBodyVisible) return;
    const contentEl = contentRef.current;
    if (!contentEl) return;

    syncScrollableBody(true);

    const observer = new ResizeObserver(() => {
      syncScrollableBody();
    });
    observer.observe(contentEl);

    return () => observer.disconnect();
  }, [isBodyVisible, syncScrollableBody]);

  useEffect(
    () => () => {
      clearPendingScrollSync();
    },
    [clearPendingScrollSync]
  );

  useEffect(() => {
    if (isBodyVisible) return;
    clearPendingScrollSync();
  }, [clearPendingScrollSync, isBodyVisible]);

  const handleScroll = useCallback(() => {
    if (expanded) return;
    const el = scrollRef.current;
    if (!el) return;
    const distanceFromBottom = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight);
    distanceFromBottomRef.current = distanceFromBottom;
    isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
  }, [expanded]);

  const handleCollapse = useCallback(() => {
    isUserScrolledUpRef.current = false;
    distanceFromBottomRef.current = 0;
    setExpanded(false);

    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        const scrollEl = scrollRef.current;
        if (scrollEl && !hasSelectionWithin(scrollEl)) {
          scrollEl.scrollTop = scrollEl.scrollHeight;
        }
        ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      });
    });
  }, []);

  const timestampLabel = useMemo(() => {
    const oldestTime = formatTime(oldest.timestamp);
    const newestTime = formatTime(newest.timestamp);
    return oldestTime === newestTime ? oldestTime : `${oldestTime}–${newestTime}`;
  }, [newest.timestamp, oldest.timestamp]);
  return (
    <AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
      <article
        className="activity-timeline-card group rounded-md [overflow:clip]"
        style={{
          backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
          border: hasApiError ? '1px solid rgba(248, 113, 113, 0.3)' : CARD_BORDER_STYLE,
          borderLeft: `3px solid ${hasApiError ? '#f87171' : colors.border}`,
        }}
      >
        {/* Header */}
        {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
        <div
          role={canToggleBodyVisibility ? 'button' : undefined}
          tabIndex={canToggleBodyVisibility ? 0 : undefined}
          className={[
            useCompactCollapsedHeader
              ? 'select-none px-3 py-2'
              : 'flex select-none items-center gap-2 px-3 py-1.5',
            canToggleBodyVisibility ? 'cursor-pointer' : '',
          ].join(' ')}
          style={hasApiError ? { backgroundColor: 'rgba(248, 113, 113, 0.08)' } : undefined}
          onClick={handleBodyToggle}
          onKeyDown={
            canToggleBodyVisibility
              ? (e) => {
                  if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    handleBodyToggle?.();
                  }
                }
              : undefined
          }
        >
          {useCompactCollapsedHeader ? (
            <div className="min-w-0">
              <div className="flex min-w-0 items-start gap-3">
                <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
                  <MemberBadge name={leadName} color={memberColor} isLight={isLight} hideAvatar />
                  <span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
                    {t('activity.thoughts.count', { count: thoughts.length })}
                  </span>
                </div>
                <div className="relative flex shrink-0 items-center">
                  <span
                    className={
                      onExpand && expandItemKey
                        ? 'text-[10px] transition-opacity group-hover:opacity-0'
                        : 'text-[10px]'
                    }
                    style={{ color: CARD_ICON_MUTED }}
                  >
                    {timestampLabel}
                  </span>
                  {onExpand && expandItemKey && (
                    <button
                      type="button"
                      aria-label={t('activity.thoughts.expand')}
                      className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
                      style={{ color: CARD_ICON_MUTED }}
                      onClick={(e) => {
                        e.stopPropagation();
                        onExpand(expandItemKey);
                      }}
                      onKeyDown={(e) => e.stopPropagation()}
                    >
                      <Maximize2 size={12} />
                    </button>
                  )}
                </div>
              </div>
              {compactPreviewMarkdown ? (
                <div title={compactPreviewTooltipText ?? undefined}>
                  <CompactMarkdownPreview
                    content={compactPreviewMarkdown}
                    className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
                    teamColorByName={teamColorByName}
                    onTeamClick={onTeamClick}
                  />
                </div>
              ) : null}
            </div>
          ) : !isBodyVisible ? (
            <div className="min-w-0 flex-1">
              <div className="flex min-w-0 items-center gap-2">
                {canToggleBodyVisibility && !compactHeader ? (
                  <ChevronRight
                    className="size-3 shrink-0 transition-transform duration-150"
                    style={{
                      color: CARD_ICON_MUTED,
                      transform: isBodyVisible ? 'rotate(90deg)' : undefined,
                    }}
                  />
                ) : null}
                {!compactHeader ? (
                  <div className="relative shrink-0">
                    <img
                      src={agentAvatarUrl(leadName, 24)}
                      alt=""
                      className="size-5 rounded-full bg-[var(--color-surface-raised)]"
                      loading="lazy"
                    />
                    <LiveThoughtStatusBadge
                      canBeLive={canBeLive}
                      isTeamAlive={isTeamAlive}
                      leadActivity={leadActivity}
                      leadContextUpdatedAt={leadContextUpdatedAt}
                      newestTimestamp={newest.timestamp}
                    />
                  </div>
                ) : null}
                <MemberBadge name={leadName} color={memberColor} isLight={isLight} hideAvatar />
                <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
                  {t('activity.thoughts.count', { count: thoughts.length })}
                </span>
                <div className="relative ml-auto flex shrink-0 items-center">
                  <span
                    className={
                      onExpand && expandItemKey
                        ? 'text-[10px] transition-opacity group-hover:opacity-0'
                        : 'text-[10px]'
                    }
                    style={{ color: CARD_ICON_MUTED }}
                  >
                    {timestampLabel}
                  </span>
                  {onExpand && expandItemKey && (
                    <button
                      type="button"
                      aria-label={t('activity.thoughts.expand')}
                      className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
                      style={{ color: CARD_ICON_MUTED }}
                      onClick={(e) => {
                        e.stopPropagation();
                        onExpand(expandItemKey);
                      }}
                      onKeyDown={(e) => e.stopPropagation()}
                    >
                      <Maximize2 size={12} />
                    </button>
                  )}
                </div>
              </div>
              {compactPreviewMarkdown ? (
                <div title={compactPreviewTooltipText ?? undefined}>
                  <CompactMarkdownPreview
                    content={compactPreviewMarkdown}
                    className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
                    teamColorByName={teamColorByName}
                    onTeamClick={onTeamClick}
                  />
                </div>
              ) : null}
            </div>
          ) : (
            <>
              {canToggleBodyVisibility && !compactHeader ? (
                <ChevronRight
                  className="size-3 shrink-0 transition-transform duration-150"
                  style={{
                    color: CARD_ICON_MUTED,
                    transform: isBodyVisible ? 'rotate(90deg)' : undefined,
                  }}
                />
              ) : null}
              {!compactHeader ? (
                <div className="relative shrink-0">
                  <img
                    src={agentAvatarUrl(leadName, 24)}
                    alt=""
                    className="size-5 rounded-full bg-[var(--color-surface-raised)]"
                    loading="lazy"
                  />
                  <LiveThoughtStatusBadge
                    canBeLive={canBeLive}
                    isTeamAlive={isTeamAlive}
                    leadActivity={leadActivity}
                    leadContextUpdatedAt={leadContextUpdatedAt}
                    newestTimestamp={newest.timestamp}
                  />
                </div>
              ) : null}
              <MemberBadge name={leadName} color={memberColor} isLight={isLight} hideAvatar />
              <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
                {t('activity.thoughts.count', { count: thoughts.length })}
              </span>
              {totalToolSummary ? (
                <Tooltip>
                  <TooltipTrigger asChild>
                    <span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
                      {totalToolSummary}
                    </span>
                  </TooltipTrigger>
                  <TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
                    <ToolSummaryTooltipContent
                      toolCalls={allToolCalls}
                      toolSummary={totalToolSummary}
                    />
                  </TooltipContent>
                </Tooltip>
              ) : null}
              <div className="relative ml-auto flex shrink-0 items-center">
                <span
                  className={
                    onExpand && expandItemKey
                      ? 'text-[10px] transition-opacity group-hover:opacity-0'
                      : 'text-[10px]'
                  }
                  style={{ color: CARD_ICON_MUTED }}
                >
                  {timestampLabel}
                </span>
                {onExpand && expandItemKey && (
                  <button
                    type="button"
                    aria-label={t('activity.thoughts.expand')}
                    className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
                    style={{ color: CARD_ICON_MUTED }}
                    onClick={(e) => {
                      e.stopPropagation();
                      onExpand(expandItemKey);
                    }}
                    onKeyDown={(e) => e.stopPropagation()}
                  >
                    <Maximize2 size={12} />
                  </button>
                )}
              </div>
            </>
          )}
        </div>

        {/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
        {isBodyVisible ? (
          <div
            ref={scrollRef}
            className="border-t"
            style={{
              borderColor: 'var(--color-border-subtle)',
              maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
              overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
              scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
              scrollbarColor:
                expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
              overflowAnchor: 'none',
            }}
            onScroll={handleScroll}
          >
            <div ref={contentRef}>
              {chronologicalThoughts.map((thought, idx) => (
                <LeadThoughtItem
                  key={toMessageKey(thought)}
                  thought={thought}
                  showDivider={idx > 0}
                  shouldAnimate={
                    shouldAnimateLatestThought && idx === chronologicalThoughts.length - 1
                  }
                  onTaskIdClick={onTaskIdClick}
                  memberColorMap={memberColorMap}
                  teamNames={teamNames}
                  teamColorByName={teamColorByName}
                  onTeamClick={onTeamClick}
                  onReply={onReply}
                />
              ))}
            </div>
          </div>
        ) : null}
      </article>
      {isBodyVisible && !expanded && needsTruncation ? (
        <div
          className="pointer-events-none relative z-10 flex justify-center"
          style={{ marginTop: -15 }}
        >
          <button
            type="button"
            className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
            onClick={(e) => {
              e.stopPropagation();
              setExpanded(true);
            }}
          >
            <ChevronDown size={12} />
            {t('activity.thoughts.showMore')}
          </button>
        </div>
      ) : null}
      {isBodyVisible && expanded && needsTruncation ? (
        <div className="pointer-events-none sticky bottom-0 z-10 flex justify-center pb-1 pt-2">
          <button
            type="button"
            className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
            onClick={(e) => {
              e.stopPropagation();
              handleCollapse();
            }}
          >
            <ChevronUp size={12} />
            {t('activity.thoughts.showLess')}
          </button>
        </div>
      ) : null}
    </AnimatedHeightReveal>
  );
};

export const LeadThoughtsGroupRow = memo(
  LeadThoughtsGroupRowComponent,
  (prev, next) =>
    prev.memberColor === next.memberColor &&
    prev.isNew === next.isNew &&
    prev.onVisible === next.onVisible &&
    prev.canBeLive === next.canBeLive &&
    prev.isTeamAlive === next.isTeamAlive &&
    prev.leadActivity === next.leadActivity &&
    prev.leadContextUpdatedAt === next.leadContextUpdatedAt &&
    prev.zebraShade === next.zebraShade &&
    prev.collapseMode === next.collapseMode &&
    prev.isCollapsed === next.isCollapsed &&
    prev.canToggleCollapse === next.canToggleCollapse &&
    prev.collapseToggleKey === next.collapseToggleKey &&
    prev.onToggleCollapse === next.onToggleCollapse &&
    prev.onTaskIdClick === next.onTaskIdClick &&
    prev.memberColorMap === next.memberColorMap &&
    areStringArraysEqual(prev.teamNames, next.teamNames) &&
    areStringMapsEqual(prev.teamColorByName, next.teamColorByName) &&
    prev.onTeamClick === next.onTeamClick &&
    prev.onReply === next.onReply &&
    prev.compactHeader === next.compactHeader &&
    prev.onExpand === next.onExpand &&
    prev.expandItemKey === next.expandItemKey &&
    prev.observerRoot === next.observerRoot &&
    areThoughtGroupsEquivalent(prev.group, next.group)
);
