import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { CheckCheck, Clock, Radio } from "lucide-react";

import {
  useActiveAgentTurns,
  type ActiveTurnSummary,
} from "@/features/agents/activeAgentTurnsStore";
import {
  subscribeAgentObserverStore,
  getLatestLiveSessionId,
} from "@/features/agents/observerRelayStore";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { useStableArrayShallow } from "@/shared/hooks/useStableReference";
import { cn } from "@/shared/lib/cn";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import {
  AgentSessionTranscriptVariantProvider,
  type AgentSessionTranscriptVariant,
  useAgentSessionTranscriptVariant,
} from "./agentSessionTranscriptContext";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
import {
  ActivityRow,
  ActivityRowContent,
  ActivityRowLabel,
  type ActivityRowStats,
  splitActivityRowCountedObject,
  splitActivityRowLabel,
} from "./activityRenderClasses/ActivityRow";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
import type { FileEditDiff } from "./agentSessionFileEditDiff";
import {
  buildTranscriptDisplayBlocks,
  formatTurnSetupLabel,
  getDisplayBlockKey,
  turnSetupDetail,
  turnSetupTimestamp,
  type TranscriptDisplayBlock,
  type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { hasFileEditLineDiff } from "./FileEditDiffView";
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";

const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";

const ROW_ENTER_SPRING = {
  damping: 38,
  stiffness: 480,
  type: "spring",
} as const;
const ROW_ENTER_FROM = { opacity: 0, y: 12 } as const;
const ROW_ENTER_TO = { opacity: 1, y: 0 } as const;

/**
 * False during the mount commit, true afterwards. Children mounted with the
 * initial batch (history load) read false and skip their enter animation;
 * children appended later read true and animate in.
 */
function useHasCompletedInitialRender() {
  const ref = React.useRef(false);
  React.useEffect(() => {
    ref.current = true;
  }, []);
  return ref;
}

/**
 * Opt-in only: source pills are useful while iterating on observer parsing, but
 * they should not appear for every local dev session.
 */
const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();

export type AgentSessionTranscriptEmptyState = "idle" | "loading";

function shouldShowTranscriptAcpSource() {
  const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
  if (envValue === "1" || envValue === "true") {
    return true;
  }

  if (typeof window === "undefined") {
    return false;
  }

  try {
    return (
      window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
    );
  } catch {
    return false;
  }
}

export function AgentSessionTranscriptList({
  agentAvatarUrl,
  agentName,
  agentPubkey,
  autoTail = false,
  channelId = null,
  emptyDescription,
  emptyState = "idle",
  items,
  profiles,
  contentContainerClassName,
  scrollScopeKey,
  variant = "default",
}: AgentTranscriptIdentityProps & {
  autoTail?: boolean;
  channelId?: string | null;
  emptyDescription: string;
  emptyState?: AgentSessionTranscriptEmptyState;
  items: TranscriptItem[];
  profiles?: UserProfileLookup;
  contentContainerClassName?: string;
  scrollScopeKey?: string | null;
  variant?: AgentSessionTranscriptVariant;
}) {
  const activeTurns = useActiveAgentTurns(agentPubkey);
  const isTurnLive = React.useMemo(
    () => isAgentTurnLive(activeTurns, channelId),
    [activeTurns, channelId],
  );

  // Subscribe to the observer relay store so we read the latest-live-session-id
  // reactively. We don't need the full snapshot — only the key for boundary labeling.
  const getLatestLive = React.useCallback(
    () => getLatestLiveSessionId(agentPubkey, channelId),
    [agentPubkey, channelId],
  );
  const latestLiveSessionId = React.useSyncExternalStore(
    subscribeAgentObserverStore,
    getLatestLive,
  );

  const displayBlocks = React.useMemo(
    () => buildTranscriptDisplayBlocks(items, latestLiveSessionId),
    [items, latestLiveSessionId],
  );
  // Derive the same block keys the DOM renders as `data-message-id` so
  // useAnchoredScroll anchors on real DOM rows. Value-stabilized so the
  // hook's restoration effect only fires when the ordered block sequence
  // actually changes (same pattern as AgentSessionThreadPanel).
  const blockKeys = React.useMemo(
    () => (autoTail ? displayBlocks.map(getDisplayBlockKey) : []),
    [autoTail, displayBlocks],
  );
  const stableBlockKeys = useStableArrayShallow(blockKeys);
  const scrollMessages = React.useMemo(
    () => stableBlockKeys.map((id) => ({ id })),
    [stableBlockKeys],
  );

  const scrollContainerRef = React.useRef<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);
  const anchoredScroll = useAnchoredScroll({
    channelId: autoTail ? (scrollScopeKey ?? agentPubkey) : null,
    contentRef,
    isLoading: false,
    messages: scrollMessages,
    scrollContainerRef,
  });

  const isCompactPreview = variant === "compactPreview";
  const animationPreferenceEnabled = useTranscriptAnimationEnabled();
  const shouldReduceMotion = useReducedMotion();
  const animationsDisabled =
    Boolean(shouldReduceMotion) || !animationPreferenceEnabled;
  // Position (layout) animations are only safe when this component owns the
  // scroll container: `layoutScroll` below tells motion to subtract our scroll
  // offset when measuring rows. When an ancestor scrolls instead (autoTail
  // off), scrolling would register as false position deltas and rows would
  // visibly spring back toward their pre-scroll position, so only the enter
  // animation runs there.
  const layoutAnimationsEnabled = !animationsDisabled && autoTail;
  const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
  const hasRenderableContent =
    items.length > 0 && hasRenderableDisplayContent(displayBlocks, variant);

  const scrollContainerClassNames = cn(
    "w-full",
    autoTail ? "h-full overflow-y-auto" : null,
  );

  if (!hasRenderableContent) {
    const isLoading = emptyState === "loading" || isTurnLive;

    return (
      <div className={scrollContainerClassNames}>
        <div className="flex h-full min-h-40 flex-col items-center justify-center px-6 py-10 text-center">
          {isLoading ? (
            <FuzzyLogo
              ariaLabel="Waiting for ACP activity"
              className="mx-auto text-muted-foreground"
              fuzz={false}
              loop
            />
          ) : (
            <>
              <Radio className="mx-auto h-4 w-4 text-muted-foreground" />
              <p className="mt-3 text-sm font-medium">No ACP activity yet</p>
              <p className="mt-1 text-sm text-muted-foreground">
                {emptyDescription}
              </p>
            </>
          )}
        </div>
      </div>
    );
  }

  return (
    <motion.div
      className={scrollContainerClassNames}
      layoutScroll
      onScroll={autoTail ? anchoredScroll.onScroll : undefined}
      ref={autoTail ? scrollContainerRef : undefined}
    >
      <div
        aria-label="Live ACP transcript"
        aria-live="polite"
        className={cn(
          "flex w-full flex-col",
          isCompactPreview ? "gap-1" : "gap-4",
          autoTail && "pb-4",
          contentContainerClassName,
        )}
        ref={autoTail ? contentRef : undefined}
        role="log"
      >
        <AgentSessionTranscriptVariantProvider value={variant}>
          {displayBlocks.map((block) => {
            const blockKey = getDisplayBlockKey(block);
            return (
              <motion.div
                animate={ROW_ENTER_TO}
                data-message-id={blockKey}
                initial={
                  animationsDisabled || !hasCompletedInitialRenderRef.current
                    ? false
                    : ROW_ENTER_FROM
                }
                key={blockKey}
                layout={layoutAnimationsEnabled ? "position" : false}
                transition={ROW_ENTER_SPRING}
              >
                {/* content-visibility stays on a non-animated child: motion
                    measures the outer wrapper for layout animations, which
                    would otherwise force skipped offscreen rows to render. */}
                <div className="content-visibility-auto">
                  <TranscriptDisplayBlockView
                    agentAvatarUrl={agentAvatarUrl}
                    agentName={agentName}
                    agentPubkey={agentPubkey}
                    block={block}
                    profiles={profiles}
                  />
                </div>
              </motion.div>
            );
          })}
          {isTurnLive && !isCompactPreview ? <TurnLivenessIndicator /> : null}
        </AgentSessionTranscriptVariantProvider>
      </div>
    </motion.div>
  );
}

function isAgentTurnLive(
  activeTurns: ActiveTurnSummary[],
  channelId: string | null,
) {
  if (activeTurns.length === 0) {
    return false;
  }
  if (!channelId) {
    return true;
  }
  return activeTurns.some((turn) => turn.channelId === channelId);
}

function hasRenderableDisplayContent(
  displayBlocks: TranscriptDisplayBlock[],
  variant: AgentSessionTranscriptVariant,
) {
  if (variant !== "compactPreview") {
    return displayBlocks.length > 0;
  }

  return displayBlocks.some(hasRenderableCompactBlock);
}

function hasRenderableCompactBlock(block: TranscriptDisplayBlock) {
  if (block.kind === "single") {
    return isRenderableCompactItem(block.item);
  }

  // session-boundary dividers are not renderable content in compact view.
  if (block.kind === "session-boundary") {
    return false;
  }

  return block.segments.some((segment) => {
    if (segment.kind === "item") {
      return isRenderableCompactItem(segment.item);
    }
    if (segment.kind === "prompt") {
      return true;
    }
    if (segment.kind === "summary") {
      return segment.summary.items.some(isRenderableCompactItem);
    }
    return false;
  });
}

function isRenderableCompactItem(item: TranscriptItem) {
  return item.renderClass !== "raw-rail" && item.renderClass !== "suppressed";
}

function TranscriptAcpSourceBadge({ source }: { source: string }) {
  return (
    <span
      className="mb-1 inline-flex max-w-full rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-mono text-xs leading-none text-amber-800 dark:text-amber-200"
      data-testid="transcript-acp-source"
      title={`ACP wire source: ${source}`}
    >
      {source}
    </span>
  );
}

function TranscriptDisplayBlockView({
  agentAvatarUrl,
  agentName,
  agentPubkey,
  block,
  profiles,
}: AgentTranscriptIdentityProps & {
  block: TranscriptDisplayBlock;
  profiles?: UserProfileLookup;
}) {
  const variant = useAgentSessionTranscriptVariant();
  const isCompactPreview = variant === "compactPreview";
  const animationPreferenceEnabled = useTranscriptAnimationEnabled();
  const shouldReduceMotion = useReducedMotion();
  // Streaming tool calls land as new segments inside the current turn block
  // (the block key stays `turn:<id>`), so the list-level enter animation
  // never fires for them — each segment animates in here instead. Segments
  // present when the block mounts (history load, or the first paint of a new
  // turn — the block wrapper already animates that) skip the transition.
  const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
  const animateSegmentEnter = animationPreferenceEnabled && !shouldReduceMotion;

  if (block.kind === "session-boundary") {
    return (
      <SessionBoundaryDivider
        labelState={block.labelState}
        sessionStartTimestamp={block.sessionStartTimestamp}
      />
    );
  }

  if (block.kind === "single") {
    return (
      <TranscriptItemRow
        agentAvatarUrl={agentAvatarUrl}
        agentName={agentName}
        agentPubkey={agentPubkey}
        item={block.item}
        profiles={profiles}
      />
    );
  }

  return (
    <div
      className={cn("flex flex-col", isCompactPreview ? "gap-2.5" : "gap-4")}
      data-testid="transcript-turn-group"
      data-turn-id={block.turnId}
    >
      {block.segments.map((segment) => (
        <motion.div
          animate={ROW_ENTER_TO}
          initial={
            animateSegmentEnter && hasCompletedInitialRenderRef.current
              ? ROW_ENTER_FROM
              : false
          }
          key={getTurnSegmentKey(block.turnId, segment)}
          transition={ROW_ENTER_SPRING}
        >
          <TranscriptTurnSegmentView
            agentAvatarUrl={agentAvatarUrl}
            agentName={agentName}
            agentPubkey={agentPubkey}
            profiles={profiles}
            segment={segment}
          />
        </motion.div>
      ))}
    </div>
  );
}

function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
  if (segment.kind === "setup") {
    return `turn:${turnId}:setup`;
  }
  if (segment.kind === "prompt") {
    // A turn can hold multiple prompt segments (initial prompt + mid-turn
    // steers), so key on the user message id rather than the bare turn id.
    return `turn:${turnId}:prompt:${segment.user.id}`;
  }
  if (segment.kind === "summary") {
    return segment.summary.id;
  }
  return segment.item.id;
}

function TranscriptTurnSegmentView({
  agentAvatarUrl,
  agentName,
  agentPubkey,
  profiles,
  segment,
}: AgentTranscriptIdentityProps & {
  profiles?: UserProfileLookup;
  segment: TranscriptTurnSegment;
}) {
  if (segment.kind === "prompt") {
    return (
      <TurnPromptBlock
        context={segment.context}
        profiles={profiles}
        setup={segment.setup}
        user={segment.user}
      />
    );
  }

  if (segment.kind === "setup") {
    return <TurnSetupStatus items={segment.items} />;
  }

  if (segment.kind === "summary") {
    return (
      <SameKindSummaryItem
        agentAvatarUrl={agentAvatarUrl}
        agentName={agentName}
        agentPubkey={agentPubkey}
        profiles={profiles}
        summary={segment.summary}
      />
    );
  }

  return (
    <TranscriptItemRow
      agentAvatarUrl={agentAvatarUrl}
      agentName={agentName}
      agentPubkey={agentPubkey}
      item={segment.item}
      profiles={profiles}
    />
  );
}

function SameKindSummaryItem({
  agentAvatarUrl,
  agentName,
  agentPubkey,
  profiles,
  summary,
}: AgentTranscriptIdentityProps & {
  profiles?: UserProfileLookup;
  summary: Extract<TranscriptTurnSegment, { kind: "summary" }>["summary"];
}) {
  const groupedFileEditDiffs = React.useMemo(
    () =>
      summary.renderClass === "file-edit" || summary.variant === "mixed"
        ? getGroupedFileEditDiffs(summary.items)
        : [],
    [summary.items, summary.renderClass, summary.variant],
  );
  const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
  const expandsToToolItems = summary.items.every(
    (item) => item.type === "tool",
  );
  const variant = useAgentSessionTranscriptVariant();
  const timestampsEnabled = useTranscriptTimestampsEnabled();
  const showTimestamp = timestampsEnabled && variant !== "compactPreview";
  // Mixed bursts expand to their child segments in original order: raw tool
  // rows plus nested same-kind summaries that joined the burst (which stay
  // expandable to their own child rows).
  const childSegments = summary.segments ?? null;

  return (
    <>
      <ActivityRow
        className="flex flex-col gap-0.5"
        openToneScope="summary"
        testId="transcript-same-kind-summary"
        title={formatTranscriptTimestampTitle(summary.timestamp)}
      >
        <ToolRunSummaryLabel
          label={summary.label}
          stats={groupedFileEditStats}
        />
        <ActivityRowContent
          className={cn(
            "flex flex-col",
            expandsToToolItems || childSegments ? "gap-0.5" : "gap-1 pl-5",
          )}
        >
          {childSegments
            ? childSegments.map((child) =>
                child.kind === "summary" ? (
                  <SameKindSummaryItem
                    agentAvatarUrl={agentAvatarUrl}
                    agentName={agentName}
                    agentPubkey={agentPubkey}
                    key={child.summary.id}
                    profiles={profiles}
                    summary={child.summary}
                  />
                ) : (
                  <TranscriptItemView
                    agentAvatarUrl={agentAvatarUrl}
                    agentName={agentName}
                    agentPubkey={agentPubkey}
                    item={child.item}
                    key={child.item.id}
                    profiles={profiles}
                  />
                ),
              )
            : expandsToToolItems
              ? summary.items.map((item) => (
                  <TranscriptItemView
                    agentAvatarUrl={agentAvatarUrl}
                    agentName={agentName}
                    agentPubkey={agentPubkey}
                    item={item}
                    key={item.id}
                    profiles={profiles}
                  />
                ))
              : summary.items.map((item) => (
                  <p
                    className="truncate text-xs text-muted-foreground"
                    key={item.id}
                  >
                    {item.type === "tool"
                      ? item.descriptor.preview || item.descriptor.label
                      : item.title}
                  </p>
                ))}
        </ActivityRowContent>
      </ActivityRow>
      {showTimestamp ? (
        <TranscriptRowTimestamp timestamp={summary.timestamp} />
      ) : null}
    </>
  );
}

function getGroupedFileEditDiffs(items: TranscriptItem[]): FileEditDiff[] {
  return items.flatMap((item) => {
    if (item.type !== "tool" || item.isError) {
      return [];
    }

    const diff = buildCompactToolSummary(item).fileEditDiff;
    return diff && hasFileEditLineDiff(diff) ? [diff] : [];
  });
}

function summarizeFileEditDiffs(
  diffs: FileEditDiff[],
): ActivityRowStats | null {
  if (diffs.length === 0) {
    return null;
  }

  return diffs.reduce(
    (stats, diff) => ({
      additions: stats.additions + diff.additions,
      deletions: stats.deletions + diff.deletions,
    }),
    { additions: 0, deletions: 0 },
  );
}

function ToolRunSummaryLabel({
  label,
  stats,
}: {
  label: string;
  stats?: ActivityRowStats | null;
}) {
  const animationPreferenceEnabled = useTranscriptAnimationEnabled();
  const parts = splitActivityRowLabel(label);

  if (!parts) {
    return <span className="truncate text-sm font-medium">{label}</span>;
  }

  // Streaming bursts grow their count in place ("Ran 16 tool calls" →
  // "Ran 17 tool calls"); rolling the digits odometer-style makes the
  // increment legible. AnimatedCount keeps an sr-only static value and
  // falls back to static text under prefers-reduced-motion.
  const countedObject =
    animationPreferenceEnabled && typeof parts.object === "string"
      ? splitActivityRowCountedObject(parts.object)
      : null;
  const object = countedObject ? (
    <>
      <AnimatedCount value={countedObject.count} />
      {countedObject.rest}
    </>
  ) : (
    parts.object
  );

  return (
    <ActivityRowLabel
      object={object}
      openToneScope="summary"
      stats={stats}
      verb={parts.verb}
    />
  );
}

function TurnPromptBlock({
  context,
  profiles,
  setup,
  user,
}: {
  context: Extract<TranscriptItem, { type: "metadata" }> | null;
  profiles?: UserProfileLookup;
  setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
  user: Extract<TranscriptItem, { type: "message" }>;
}) {
  return (
    <div data-testid="transcript-prompt-bundle">
      {SHOW_TRANSCRIPT_ACP_SOURCE ? (
        <div className="mb-1 flex flex-wrap gap-1">
          <TranscriptAcpSourceBadge source="session/prompt:user" />
          {context ? (
            <TranscriptAcpSourceBadge source="session/prompt:context" />
          ) : null}
        </div>
      ) : null}
      <PromptUserMessage
        context={context}
        item={user}
        profiles={profiles}
        setup={setup}
      />
    </div>
  );
}

function PromptUserMessage({
  context = null,
  item,
  profiles,
  setup = [],
}: {
  context?: Extract<TranscriptItem, { type: "metadata" }> | null;
  item: Extract<TranscriptItem, { type: "message" }>;
  profiles?: UserProfileLookup;
  setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
  const [contextOpen, setContextOpen] = React.useState(false);
  const contextSections = React.useMemo(
    () => [...(context?.sections ?? [])],
    [context],
  );

  return (
    <>
      <UserMessageBubble
        bubbleClassName="p-2.5"
        footer={
          <TurnSetupFooter
            contextOpen={contextOpen}
            hasContext={contextSections.length > 0}
            items={setup}
            messageLink={getTranscriptMessageLink(item)}
            onContextOpenChange={setContextOpen}
            timestamp={item.timestamp}
          />
        }
        item={item}
        profiles={profiles}
      />
      <PromptContextDialog
        onOpenChange={setContextOpen}
        open={contextOpen}
        sections={contextSections}
        setup={setup}
      />
    </>
  );
}

function PromptContextDialog({
  onOpenChange,
  open,
  sections,
  setup,
}: {
  onOpenChange: (open: boolean) => void;
  open: boolean;
  sections: PromptSection[];
  setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
  if (!open || sections.length === 0) {
    return null;
  }

  const setupText = formatPromptSetupSummary(setup);

  return (
    <Dialog onOpenChange={onOpenChange} open={open}>
      <DialogContent className="max-w-xl overflow-hidden p-0">
        <div className="flex min-w-0 max-h-[85vh] flex-col">
          <DialogHeader className="px-6 pb-3 pt-5 pr-14">
            <DialogTitle>Prompt context</DialogTitle>
            {setupText ? (
              <div className="flex items-center gap-1.5">
                <CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
                <DialogDescription>{setupText}</DialogDescription>
              </div>
            ) : null}
          </DialogHeader>
          <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
            <PromptContextSections sections={sections} />
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

function formatPromptSetupSummary(
  items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
  const label = formatTurnSetupLabel(items);
  const detail = turnSetupDetail(items);
  return [label, detail].filter(Boolean).join(" · ");
}

function TurnSetupFooter({
  contextOpen = false,
  hasContext = false,
  items,
  messageLink = null,
  onContextOpenChange,
  showTimestamp = true,
  timestamp,
}: {
  contextOpen?: boolean;
  hasContext?: boolean;
  items: Extract<TranscriptItem, { type: "lifecycle" }>[];
  messageLink?: { channelId: string; messageId: string } | null;
  onContextOpenChange?: (open: boolean) => void;
  showTimestamp?: boolean;
  timestamp: string;
}) {
  const label = formatTurnSetupLabel(items);
  const detail = turnSetupDetail(items);
  const tooltipText = [label, detail].filter(Boolean).join(" · ");
  const showSetup = items.length > 0;
  const showContext = hasContext && onContextOpenChange != null;

  if (!showSetup && !showContext) {
    return showTimestamp ? (
      <TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
    ) : null;
  }

  return (
    <div
      className="flex items-center gap-1.5 text-muted-foreground/80"
      data-testid="transcript-turn-setup"
    >
      {showContext ? (
        <Toggle
          aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
          className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
          data-testid="transcript-prompt-context-toggle"
          onPressedChange={onContextOpenChange}
          pressed={contextOpen}
          size="xs"
          title={tooltipText || "Show prompt context"}
          variant="ghost"
        >
          <CheckCheck aria-hidden="true" />
        </Toggle>
      ) : (
        <span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
          <CheckCheck className="h-3.5 w-3.5" />
          <span className="sr-only">{tooltipText}</span>
        </span>
      )}
      {showTimestamp ? (
        <TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
      ) : null}
    </div>
  );
}

function getTranscriptMessageLink(
  item: Extract<TranscriptItem, { type: "message" }>,
) {
  if (!item.channelId || !item.messageId) return null;
  return {
    channelId: item.channelId,
    messageId: item.messageId,
  };
}

function TranscriptItemRow({
  agentAvatarUrl,
  agentName,
  agentPubkey,
  item,
  profiles,
}: AgentTranscriptIdentityProps & {
  item: TranscriptItem;
  profiles?: UserProfileLookup;
}) {
  const variant = useAgentSessionTranscriptVariant();
  const timestampsEnabled = useTranscriptTimestampsEnabled();
  const showTimestamp = shouldShowTranscriptRowTimestamp(item, {
    enabled: timestampsEnabled,
    variant,
  });

  return (
    <div key={item.id}>
      {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
        <TranscriptAcpSourceBadge source={item.acpSource} />
      ) : null}
      <TranscriptItemView
        agentAvatarUrl={agentAvatarUrl}
        agentName={agentName}
        agentPubkey={agentPubkey}
        item={item}
        profiles={profiles}
      />
      {showTimestamp ? (
        <TranscriptRowTimestamp
          messageLink={
            item.type === "message" ? getTranscriptMessageLink(item) : null
          }
          timestamp={item.timestamp}
        />
      ) : null}
    </div>
  );
}

/**
 * Opt-in per-row timestamp, anchored bottom-left under the row content and
 * styled to match the chat/transcript timestamps.
 */
function TranscriptRowTimestamp({
  messageLink = null,
  timestamp,
}: {
  messageLink?: { channelId: string; messageId: string } | null;
  timestamp: string;
}) {
  return (
    <div
      className="mt-0.5 flex justify-start"
      data-testid="transcript-row-timestamp"
    >
      <TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
    </div>
  );
}

function TurnSetupStatus({
  items,
}: {
  items: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
  const timestamp = turnSetupTimestamp(items);
  if (items.length === 0 || !timestamp) {
    return null;
  }

  return (
    <div
      className="rounded-md px-2"
      title={formatTranscriptTimestampTitle(timestamp)}
    >
      <TurnSetupFooter
        items={items}
        showTimestamp={false}
        timestamp={timestamp}
      />
    </div>
  );
}

/**
 * Horizontal rule rendered between session runs in the observer transcript.
 *
 * Three label states (based on live-frame observation, not harness affinity):
 *  - `"current"`     — most recent session observed via the live relay subscription.
 *  - `"most-recent"` — newest visible session with no matching live frames
 *                      (loaded from archive or session ended before observation).
 *  - `"earlier"`     — an older session preceding the most-recent one.
 */
function SessionBoundaryDivider({
  labelState,
  sessionStartTimestamp,
}: {
  labelState: "current" | "most-recent" | "earlier";
  sessionStartTimestamp: string;
}) {
  const label =
    labelState === "current"
      ? "Latest live-observed session"
      : labelState === "most-recent"
        ? "Most recent observed session"
        : "Earlier observed session";
  const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
  return (
    <div
      className="flex items-center gap-2 px-3 py-2"
      data-testid="session-boundary-divider"
    >
      <div className="h-px flex-1 bg-border" />
      <span className="flex items-center gap-1 text-xs text-muted-foreground">
        {labelState === "current" ? (
          <Radio aria-hidden="true" className="h-3 w-3" />
        ) : (
          <Clock aria-hidden="true" className="h-3 w-3" />
        )}
        {label}
        {" · "}
        {formattedDate}
      </span>
      <div className="h-px flex-1 bg-border" />
    </div>
  );
}

const TranscriptItemView = React.memo(function TranscriptItemView({
  agentAvatarUrl,
  agentName,
  agentPubkey,
  item,
  profiles,
}: AgentTranscriptIdentityProps & {
  item: TranscriptItem;
  profiles?: UserProfileLookup;
}) {
  return (
    <TranscriptActivityItem
      agentAvatarUrl={agentAvatarUrl}
      agentName={agentName}
      agentPubkey={agentPubkey}
      item={item}
      profiles={profiles}
    />
  );
});
