import type { Style } from "@simon_he/vue-tui";
import type { PropType } from "vue";
import type { Line, LineAction } from "../../../ui/chat/layout-model";
import type { SegmentAction } from "../../../ui/chat/types";
import { t } from "#cli/i18n";
import {
  padEndByCells,
  sliceByCellsRange,
  spaces,
  TText,
  TView,
  useLayout,
  useRenderNode,
  useTerminal,
} from "@simon_he/vue-tui";
import {
  computed,
  defineComponent,
  getCurrentInstance,
  h,
  inject,
  nextTick,
  ref,
  watch,
} from "vue";
import { openPathExternally, openUrlExternally } from "../../utils/open-external";
import { normalizeCellRangeToText } from "../../utils/selection";
import { ChatPageContextKey } from "../context";

const USER_HOVER_BG_ALLOWED: ReadonlySet<string> = new Set([
  "black",
  "red",
  "green",
  "yellow",
  "blue",
  "magenta",
  "cyan",
  "white",
  "blackBright",
  "redBright",
  "greenBright",
  "yellowBright",
  "blueBright",
  "magentaBright",
  "cyanBright",
  "whiteBright",
]);

function readUserHoverBgOverride(): Style["bg"] | null {
  const env = (globalThis as any).process?.env ?? {};
  const raw = String(env.DIMCODE_USER_HOVER_BG ?? "").trim();
  if (!raw) return null;
  return (USER_HOVER_BG_ALLOWED.has(raw) ? raw : null) as any;
}

const CACHED_HOVER_BG_OVERRIDE = readUserHoverBgOverride();

const lineScrollKeyCache = new WeakMap<Line, string>();

function styleScrollKey(style: Style | undefined): string {
  if (!style) return "";
  return [
    style.fg ?? "",
    style.bg ?? "",
    style.bold ? "1" : "0",
    style.dim ? "1" : "0",
    style.italic ? "1" : "0",
    style.underline ? "1" : "0",
    style.inverse ? "1" : "0",
    style.href ?? "",
  ].join("|");
}

function lineScrollKey(line: Line): string {
  const cached = lineScrollKeyCache.get(line);
  if (cached) return cached;
  const segmentsKey = line.segments
    .map((seg) =>
      [
        seg.x,
        seg.w,
        seg.zIndex ?? "",
        seg.text,
        styleScrollKey(seg.style),
        seg.action ? JSON.stringify(seg.action) : "",
      ].join("|"),
    )
    .join("||");
  const next = [
    line.text,
    line.messageId ?? "",
    line.hasBackground ? "1" : "0",
    styleScrollKey(line.style),
    line.action ? JSON.stringify(line.action) : "",
    segmentsKey,
  ].join("###");
  lineScrollKeyCache.set(line, next);
  return next;
}

function selectionRange(
  selStart: { row: number; col: number } | null,
  selEnd: { row: number; col: number } | null,
) {
  const active = Boolean(
    selStart && selEnd && (selStart.row !== selEnd.row || selStart.col !== selEnd.col),
  );
  if (!active) {
    return { active: false, startRow: -1, endRow: -1, startCol: 0, endCol: 0 };
  }
  const startRow = Math.min(selStart!.row, selEnd!.row);
  const endRow = Math.max(selStart!.row, selEnd!.row);
  const startCol =
    selStart!.row < selEnd!.row
      ? selStart!.col
      : selStart!.row > selEnd!.row
        ? selEnd!.col
        : Math.min(selStart!.col, selEnd!.col);
  const endCol =
    selStart!.row < selEnd!.row
      ? selEnd!.col
      : selStart!.row > selEnd!.row
        ? selStart!.col
        : Math.max(selStart!.col, selEnd!.col);
  return { active: true, startRow, endRow, startCol, endCol };
}

type HoveredLinkRange = Readonly<{
  row: number;
  startCol: number;
  endCol: number;
}>;

type MessageRenderState = Readonly<{
  lineW: number;
  selection: ReturnType<typeof selectionRange>;
  windowBg: Style["bg"];
  chromeBg: Style["bg"];
  hoverBgOverride: Style["bg"] | null;
  hoveredUserMessageId: string | null;
  hoveredLinkRange: HoveredLinkRange | null;
}>;

function detectScrollDirtyViewportRows(
  prevLines: readonly Line[],
  nextLines: readonly Line[],
  scrollDelta: number,
): readonly number[] | null {
  const lineCount = nextLines.length;
  if (lineCount <= 0 || prevLines.length !== lineCount) return null;

  const detectForDelta = (delta: number): readonly number[] | null => {
    const absDelta = Math.abs(delta);
    if (!delta || absDelta >= lineCount) return null;

    let prevKeyCounts: Map<string, number> | null = null;
    let nextKeyCounts: Map<string, number> | null = null;
    let strongMatches = 0;
    let checked = 0;
    for (let row = 0; row < lineCount; row++) {
      const prevRow = row + delta;
      if (prevRow < 0 || prevRow >= lineCount) continue;
      checked++;
      const prevLine = prevLines[prevRow]!;
      const nextLine = nextLines[row]!;
      if (prevLine === nextLine) {
        strongMatches++;
        continue;
      }
      const prevKey = lineScrollKey(prevLine);
      const nextKey = lineScrollKey(nextLine);
      if (prevKey !== nextKey) return null;
      if (!prevKeyCounts || !nextKeyCounts) {
        prevKeyCounts = new Map();
        nextKeyCounts = new Map();
        for (let i = 0; i < lineCount; i++) {
          const prevCountKey = lineScrollKey(prevLines[i]!);
          prevKeyCounts.set(prevCountKey, (prevKeyCounts.get(prevCountKey) ?? 0) + 1);
          const nextCountKey = lineScrollKey(nextLines[i]!);
          nextKeyCounts.set(nextCountKey, (nextKeyCounts.get(nextCountKey) ?? 0) + 1);
        }
      }
      if ((prevKeyCounts.get(prevKey) ?? 0) === 1 && (nextKeyCounts.get(nextKey) ?? 0) === 1) {
        strongMatches++;
      }
    }
    if (checked <= 0) return null;
    const minStrongMatches = checked <= 2 ? checked : Math.min(3, checked);
    if (strongMatches < minStrongMatches) return null;

    const dirtyRows: number[] = [];
    if (delta > 0) {
      for (let row = lineCount - absDelta; row < lineCount; row++) dirtyRows.push(row);
    } else {
      for (let row = 0; row < absDelta; row++) dirtyRows.push(row);
    }
    return dirtyRows;
  };

  const preferredDelta = Math.trunc(scrollDelta);
  const tried = new Set<number>();
  if (preferredDelta) {
    tried.add(preferredDelta);
    const preferred = detectForDelta(preferredDelta);
    if (preferred) return preferred;
  }

  const maxAbsDelta = Math.min(6, lineCount - 1);
  const preferredSign = preferredDelta < 0 ? -1 : 1;
  for (let absDelta = 1; absDelta <= maxAbsDelta; absDelta++) {
    const candidates =
      preferredSign < 0 ? ([-absDelta, absDelta] as const) : ([absDelta, -absDelta] as const);
    for (const delta of candidates) {
      if (tried.has(delta)) continue;
      tried.add(delta);
      const detected = detectForDelta(delta);
      if (detected) return detected;
    }
  }

  return null;
}

function intersectBlockRect(
  rect: Readonly<{ x: number; y: number; w: number; h: number }>,
  clip: Readonly<{ x: number; y: number; w: number; h: number }> | null,
): Readonly<{ x: number; y: number; w: number; h: number }> {
  if (!clip) return rect;
  const x0 = Math.max(rect.x, clip.x);
  const y0 = Math.max(rect.y, clip.y);
  const x1 = Math.min(rect.x + rect.w, clip.x + clip.w);
  const y1 = Math.min(rect.y + rect.h, clip.y + clip.h);
  return {
    x: x0,
    y: y0,
    w: Math.max(0, x1 - x0),
    h: Math.max(0, y1 - y0),
  };
}

function resolveSegmentClickBehavior(
  segmentAction: SegmentAction | undefined,
  lineAction: LineAction | undefined,
  event:
    | Readonly<{
        metaKey?: boolean;
        ctrlKey?: boolean;
        altKey?: boolean;
      }>
    | null
    | undefined,
): "external" | "line" | "hint" | "ignore" {
  if (!segmentAction) return lineAction ? "line" : "ignore";

  const modified = Boolean(event?.metaKey || event?.ctrlKey || event?.altKey);
  if (segmentAction.type === "openFile")
    return modified ? "external" : lineAction ? "line" : "hint";

  if (segmentAction.type === "openUrl") {
    if (!/^https?:\/\//i.test(segmentAction.url)) return lineAction ? "line" : "ignore";
    return modified ? "external" : lineAction ? "line" : "hint";
  }

  return lineAction ? "line" : "ignore";
}

function resolvePaintedBlockDirtyRowsHint(
  explicitHint: readonly number[] | null | undefined,
  pendingHint: readonly number[] | undefined,
): readonly number[] | undefined {
  return explicitHint?.length ? explicitHint : pendingHint;
}

function canReuseBlockLines(prev: readonly Line[] | undefined, next: readonly Line[]): boolean {
  if (!prev || prev.length !== next.length) return false;
  for (let i = 0; i < next.length; i++) {
    if (prev[i] !== next[i]) return false;
  }
  return true;
}

export const DimSdkChatMessages = defineComponent({
  name: "DimSdkChatMessages",
  setup() {
    const injected = inject(ChatPageContextKey);
    if (!injected) throw new Error("DimSdkChatMessages missing ChatPageContext");
    const ctx = injected;
    const instance = getCurrentInstance();

    const selection = computed(() =>
      selectionRange(ctx.messageSelectStart.value, ctx.messageSelectEnd.value),
    );

    const { render, scheduler } = useTerminal();
    const bgOnlyStyleCache = new Map<string, any>();
    let styleWithBgCache = new WeakMap<object, Map<string, any>>();

    // Clear style caches when theme changes
    watch(
      () => ctx.ui.version.value,
      () => {
        bgOnlyStyleCache.clear();
        styleWithBgCache = new WeakMap();
      },
    );

    const messageW = computed(() => Math.max(0, Math.max(0, ctx.mainW.value) - 5));

    // Memoize renderState so it stays referentially stable during pure scrolling.
    // scrollTop is intentionally excluded — it changes every scroll frame but is
    // only needed at paint time, where it's read directly from ctx.
    const renderStateComputed = computed((): MessageRenderState => {
      // Track theme version to re-evaluate when theme changes
      void ctx.ui.version.value;
      return {
        lineW: messageW.value,
        selection: selection.value,
        windowBg: (ctx.ui.token("bg.window")?.bg ?? "black") as any,
        chromeBg: (ctx.ui.token("bg.chrome")?.bg ?? "blackBright") as any,
        hoverBgOverride: CACHED_HOVER_BG_OVERRIDE,
        hoveredUserMessageId: ctx.hoveredUserMessageId.value,
        hoveredLinkRange: ctx.hoveredLinkRange.value,
      };
    });

    watch(
      () => renderStateComputed.value,
      () => {
        if (instance?.isMounted);
        (instance as any)?.update?.();
        render.invalidatePlane("transcript");
        scheduler.invalidate({ priority: "high", plane: "transcript" });
      },
      { flush: "post" },
    );

    let prevVisibleLinesForContentEpoch: readonly Line[] = [];
    let prevVisibleScrollTopForContentEpoch = 0;
    const visibleLinesVersion = ref(0);
    const transcriptContentEpoch = ref(0);

    function consumeScrollDirtyRowsHint(): readonly number[] | null {
      // Mixed transcript blocks can reflow without being a pure vertical shift.
      // For now, always repaint the visible transcript instead of reusing the
      // previous plane via inferred dirty rows.
      return null;
    }

    let isScrollbarDragging = false;
    let isScrollbarHovered = false;
    const linkHintCooldownMs = 3000;
    let lastLinkHintAt = 0;
    function showLinkOpenHint(): void {
      if (!ctx.nodeLike) return;
      const now = Date.now();
      if (now - lastLinkHintAt < linkHintCooldownMs) return;
      lastLinkHintAt = now;
      ctx.showCopyToast(t("chatPage.toast.linkOpenHint"));
    }
    async function openExternally(target: string): Promise<void> {
      await openPathExternally(target);
    }
    function styleWithBg(style: any, bg: string): any {
      if (!style) {
        const cached = bgOnlyStyleCache.get(bg);
        if (cached) return cached;
        const next = { bg };
        bgOnlyStyleCache.set(bg, next);
        return next;
      }
      if (style.bg === bg) return style;
      let byBg = styleWithBgCache.get(style);
      if (!byBg) {
        byBg = new Map();
        styleWithBgCache.set(style, byBg);
      }
      const cached = byBg.get(bg);
      if (cached) return cached;
      const next = { ...style, bg };
      byBg.set(bg, next);
      return next;
    }

    const scrollbar = computed(() => {
      const trackH = Math.max(0, Math.floor(ctx.mainH.value));
      const maxTop = Math.max(0, Math.floor(ctx.maxScroll?.value ?? 0));
      const scrollTop = Math.max(0, Math.min(maxTop, Math.floor(ctx.scrollTop.value)));
      if (trackH <= 0 || maxTop <= 0) {
        return {
          visible: false,
          trackH,
          maxTop,
          thumbH: 0,
          thumbTop: 0,
          thumbTravel: 0,
        };
      }
      const total = maxTop + trackH;
      const minThumbH = Math.min(trackH, 2);
      const thumbH = Math.max(
        minThumbH,
        Math.min(trackH, Math.round((trackH * trackH) / Math.max(1, total))),
      );
      const thumbTravel = Math.max(0, trackH - thumbH);
      const thumbTop = thumbTravel > 0 ? Math.round((scrollTop / maxTop) * thumbTravel) : 0;
      return {
        visible: true,
        trackH,
        maxTop,
        thumbH,
        thumbTop,
        thumbTravel,
      };
    });

    function syncStickToBottom(nextTop: number, maxTop: number): void {
      if (!ctx.stickToBottom) return;
      ctx.stickToBottom.value = nextTop >= maxTop;
    }

    function setScrollTopFromScrollbarCell(cellY: number): void {
      const metrics = scrollbar.value;
      if (!metrics.visible || metrics.trackH <= 0 || metrics.maxTop <= 0) return;
      const localY = Math.max(0, Math.min(metrics.trackH - 1, Math.floor(cellY - ctx.mainY)));
      const centeredTop = localY - Math.floor(metrics.thumbH / 2);
      const thumbTop = Math.max(0, Math.min(metrics.thumbTravel, centeredTop));
      const ratio = metrics.thumbTravel > 0 ? thumbTop / metrics.thumbTravel : 0;
      const nextTop = Math.max(0, Math.min(metrics.maxTop, Math.round(ratio * metrics.maxTop)));
      ctx.scrollTop.value = nextTop;
      syncStickToBottom(nextTop, metrics.maxTop);
      ctx.invalidate();
    }

    function onScrollbarPointerDown(e: any): void {
      if (!scrollbar.value.visible) return;
      e.preventDefault?.();
      e.stopPropagation?.();
      isScrollbarDragging = true;
      isScrollbarHovered = true;
      ctx.focusMode.value = "messages";
      setScrollTopFromScrollbarCell(e.cellY ?? ctx.mainY);
    }

    function onScrollbarPointerMove(e: any): void {
      if (!isScrollbarDragging) return;
      e.preventDefault?.();
      e.stopPropagation?.();
      setScrollTopFromScrollbarCell(e.cellY ?? ctx.mainY);
    }

    function onScrollbarPointerUp(e: any): void {
      if (!isScrollbarDragging) return;
      e.preventDefault?.();
      e.stopPropagation?.();
      isScrollbarDragging = false;
      setScrollTopFromScrollbarCell(e.cellY ?? ctx.mainY);
      const cellX = Math.floor(e.cellX ?? -1);
      isScrollbarHovered = isInScrollbarRevealZone(cellX);
      ctx.invalidate();
    }

    function onScrollbarPointerEnter(): void {
      if (isScrollbarHovered) return;
      isScrollbarHovered = true;
      ctx.invalidate();
    }

    function onScrollbarPointerLeave(): void {
      if (isScrollbarDragging || !isScrollbarHovered) return;
      isScrollbarHovered = false;
      ctx.invalidate();
    }

    function isInScrollbarRevealZone(cellX: number): boolean {
      const mainW = Math.max(0, Math.floor(ctx.mainW.value));
      if (mainW <= 0) return false;
      const scrollX = Math.max(0, mainW - 2);
      const revealX = Math.max(0, scrollX - 1);
      return cellX >= revealX && cellX < mainW;
    }

    function onScrollbarRevealPointerMove(e: any): void {
      if (!scrollbar.value.visible || isScrollbarDragging) return;
      const next = isInScrollbarRevealZone(Math.floor(e.cellX ?? -1));
      if (next === isScrollbarHovered) return;
      isScrollbarHovered = next;
      ctx.invalidate();
    }

    function onScrollbarRevealPointerLeave(): void {
      if (isScrollbarDragging || !isScrollbarHovered) return;
      isScrollbarHovered = false;
      ctx.invalidate();
    }

    watch(
      () => ctx.visibleLines.value,
      (lines) => {
        const scrollTop = ctx.scrollTop.value;
        let scheduledFullTranscriptRepaint = false;
        if (
          prevVisibleLinesForContentEpoch.length > 0 &&
          lines !== prevVisibleLinesForContentEpoch &&
          scrollTop === prevVisibleScrollTopForContentEpoch
        ) {
          transcriptContentEpoch.value++;
          scheduledFullTranscriptRepaint = true;
          void nextTick(() => {
            render.invalidatePlane("transcript");
            scheduler.invalidate({ priority: "high", plane: "transcript" });
          });
        }
        prevVisibleLinesForContentEpoch = lines;
        prevVisibleScrollTopForContentEpoch = scrollTop;
        visibleLinesVersion.value++;
        if (instance?.isMounted) (instance as any)?.update?.();
        if (!scheduledFullTranscriptRepaint) ctx.invalidate();
      },
      { immediate: true, flush: "sync" },
    );

    const DimSdkPaintedLinesBlock = defineComponent({
      name: "DimSdkPaintedLinesBlock",
      props: {
        startRow: { type: Number, required: true },
        lines: { type: Array as PropType<readonly Line[]>, required: true },
        dirtyRowsHint: {
          type: Array as PropType<readonly number[] | null>,
          default: null,
        },
        renderState: {
          type: Object as PropType<MessageRenderState>,
          required: true,
        },
      },
      setup(props) {
        const { terminal, render, scheduler } = useTerminal();
        const layout = useLayout();
        let pendingDirtyRowsHint: readonly number[] | undefined;
        const dirtyRowsHintVersion = ref(0);

        watch(
          () => props.dirtyRowsHint,
          (hint) => {
            if (!hint?.length) return;
            pendingDirtyRowsHint = hint;
            dirtyRowsHintVersion.value++;
          },
          { immediate: true, flush: "sync" },
        );

        const fullRect = computed(() => ({
          x: layout.originX,
          y: layout.originY + props.startRow,
          w: Math.max(0, props.renderState.lineW),
          h: props.lines.length,
        }));

        const absRect = computed(() => {
          if (props.startRow < 0 || props.startRow + props.lines.length > ctx.mainH.value) {
            return fullRect.value;
          }
          return intersectBlockRect(fullRect.value, layout.clipRect);
        });

        function lineSelectionBounds(
          viewportRow: number,
          scrollTop: number,
          state: MessageRenderState,
        ): { startCol: number; endCol: number } | null {
          const range = state.selection;
          if (!range.active) return null;
          const actualRow = scrollTop + viewportRow;
          if (actualRow < range.startRow || actualRow > range.endRow) return null;
          if (range.startRow === range.endRow) {
            return { startCol: range.startCol, endCol: range.endCol };
          }
          if (actualRow === range.startRow) {
            return { startCol: range.startCol, endCol: state.lineW };
          }
          if (actualRow === range.endRow) {
            return { startCol: 0, endCol: range.endCol };
          }
          return { startCol: 0, endCol: state.lineW };
        }

        const renderNode = useRenderNode(() => {
          // Only use dirty-row hints when a higher-level scroll operation already
          // shifted the plane. Inferring block-local shifts here is unsafe for
          // expand/collapse because later transcript blocks can move without an
          // accompanying plane scroll, which leaves stale rows behind.
          const fallbackDirtyRowsHint = resolvePaintedBlockDirtyRowsHint(
            props.dirtyRowsHint,
            pendingDirtyRowsHint,
          );

          const paint = (dirtyRows?: readonly number[]) => {
            pendingDirtyRowsHint = undefined;
            const rect = absRect.value;
            if (rect.w <= 0 || rect.h <= 0) return;
            const full = fullRect.value;
            const state = props.renderState;
            // Read scrollTop directly at paint time (not via renderState) so that
            // scrollTop changes alone don't invalidate the deps/repaint cycle.
            const scrollTop = ctx.scrollTop.value;
            const dx = Math.max(0, Math.floor(rect.x - full.x));
            const fullY = Math.floor(full.y);
            const blank = spaces(rect.w);
            const selectStyle = ctx.ui.style(["select.text"]);
            const hoverBorderBaseStyle = ctx.ui.style(["status.success"]);

            const paintRow = (y: number): void => {
              const lineIndex = y - fullY;
              if (lineIndex < 0 || lineIndex >= props.lines.length) return;
              const line = props.lines[lineIndex]!;
              const viewportRow = props.startRow + lineIndex;
              const actualRow = scrollTop + viewportRow;
              const baseBg = (line.style?.bg ??
                (line.hasBackground ? state.chromeBg : state.windowBg)) as any;
              const isHoveredUser = Boolean(
                line.hasBackground &&
                line.messageId &&
                state.hoveredUserMessageId === line.messageId,
              );
              const lineBg = isHoveredUser ? (state.hoverBgOverride ?? baseBg) : baseBg;
              const baseStyle = styleWithBg(line.style as any, lineBg);

              terminal.write(blank, { x: rect.x, y, style: { bg: lineBg } });
              terminal.write(padEndByCells(sliceByCellsRange(line.text, dx, dx + rect.w), rect.w), {
                x: rect.x,
                y,
                style: baseStyle,
              });

              for (let i = 0; i < line.segments.length; i++) {
                const seg = line.segments[i]!;
                const segStart = seg.x;
                const segEnd = seg.x + seg.w;
                const drawStart = Math.max(dx, segStart);
                const drawEnd = Math.min(dx + rect.w, segEnd);
                if (drawEnd <= drawStart) continue;
                let segStyle = (seg.style as any)?.bg
                  ? (seg.style as any)
                  : styleWithBg(seg.style as any, lineBg);
                if (
                  seg.action &&
                  (seg.action.type === "openFile" || seg.action.type === "openUrl")
                ) {
                  const hoveredRange = state.hoveredLinkRange;
                  const isHoveredLink = Boolean(
                    hoveredRange &&
                    hoveredRange.row === actualRow &&
                    hoveredRange.startCol < segEnd &&
                    hoveredRange.endCol > segStart,
                  );
                  if (isHoveredLink) segStyle = { ...segStyle, underline: true };
                }
                terminal.write(
                  sliceByCellsRange(seg.text, drawStart - segStart, drawEnd - segStart),
                  {
                    x: rect.x + (drawStart - dx),
                    y,
                    style: segStyle,
                  },
                );
              }

              const selectionBounds = lineSelectionBounds(viewportRow, scrollTop, state);
              if (selectionBounds) {
                const normalized = normalizeCellRangeToText(
                  line.text,
                  selectionBounds.startCol,
                  selectionBounds.endCol,
                );
                const drawStart = Math.max(0, Math.min(state.lineW, normalized.startCol));
                const drawEnd = Math.max(0, Math.min(state.lineW, normalized.endCol));
                const visibleStart = Math.max(dx, drawStart);
                const visibleEnd = Math.min(dx + rect.w, drawEnd);
                const visibleWidth = visibleEnd - visibleStart;
                if (visibleWidth > 0) {
                  terminal.write(
                    padEndByCells(
                      sliceByCellsRange(line.text, visibleStart, visibleEnd),
                      visibleWidth,
                    ),
                    {
                      x: rect.x + (visibleStart - dx),
                      y,
                      style: selectStyle,
                    },
                  );
                }
              }

              if (dx === 0 && isHoveredUser) {
                terminal.write("▏", {
                  x: rect.x,
                  y,
                  style: { ...hoverBorderBaseStyle, bg: lineBg },
                });
              }
            };

            if (!dirtyRows) {
              for (let y = rect.y; y < rect.y + rect.h; y++) paintRow(y);
              return;
            }

            for (const y of dirtyRows) paintRow(y);
          };

          return {
            zIndex: 1,
            rect: absRect.value,
            dirtyRowsHint: fallbackDirtyRowsHint,
            deps: [
              absRect.value,
              fullRect.value,
              props.lines,
              props.renderState,
              dirtyRowsHintVersion.value,
            ],
            paint,
          };
        });

        watch(
          () => renderNode.id.value,
          (id, prevId) => {
            if (!id || id === prevId) return;
            queueMicrotask(() => {
              render.invalidatePlane("transcript");
              scheduler.invalidate({ priority: "high", plane: "transcript" });
            });
          },
          { immediate: true, flush: "sync" },
        );

        return () => {
          const lineW = props.renderState.lineW;
          const nodes: any[] = [];
          if (lineW <= 0) return nodes;

          for (let lineIndex = 0; lineIndex < props.lines.length; lineIndex++) {
            const line = props.lines[lineIndex]!;
            const viewportRow = props.startRow + lineIndex;
            if (viewportRow < 0 || viewportRow >= ctx.mainH.value) continue;

            for (let i = 0; i < line.segments.length; i++) {
              const seg = line.segments[i]!;
              const hitW = Math.max(0, Math.min(lineW - seg.x, seg.w));
              if (hitW <= 0) continue;
              if (seg.action?.type === "openFile") {
                const { absPath } = seg.action;
                nodes.push(
                  h(TView, {
                    key: `r${viewportRow}_s${seg.x}_${i}_hit`,
                    x: seg.x,
                    y: viewportRow,
                    w: hitW,
                    h: 1,
                    zIndex: 11,
                    focusable: false,
                    onClick: async (e: any) => {
                      if (!ctx.nodeLike) return;
                      const behavior = resolveSegmentClickBehavior(seg.action, line.action, e);
                      if (behavior === "external") {
                        await openExternally(absPath);
                        return;
                      }
                      if (behavior === "line") {
                        ctx.onLineClick(line.action);
                        return;
                      }
                      if (behavior === "hint") showLinkOpenHint();
                    },
                  }),
                );
              } else if (seg.action?.type === "openUrl") {
                const { url } = seg.action;
                nodes.push(
                  h(TView, {
                    key: `r${viewportRow}_s${seg.x}_${i}_hit`,
                    x: seg.x,
                    y: viewportRow,
                    w: hitW,
                    h: 1,
                    zIndex: 11,
                    focusable: false,
                    onClick: async (e: any) => {
                      if (!ctx.nodeLike) return;
                      const behavior = resolveSegmentClickBehavior(seg.action, line.action, e);
                      if (behavior === "external") {
                        await openUrlExternally(url);
                        return;
                      }
                      if (behavior === "line") {
                        ctx.onLineClick(line.action);
                        return;
                      }
                      if (behavior === "hint") showLinkOpenHint();
                    },
                  }),
                );
              }
            }

            if (!line.action) continue;

            nodes.push(
              h(TView, {
                key: `r${viewportRow}_hit`,
                x: 0,
                y: viewportRow,
                w: lineW,
                h: 1,
                zIndex: 10,
                focusable: true,
                onFocusCapture: () => (ctx.focusMode.value = "messages"),
                onKeydown: ctx.onSelectionKeydown,
                onClick: () => ctx.onLineClick(line.action),
              }),
            );
          }

          return nodes;
        };
      },
    });

    return () => {
      void visibleLinesVersion.value;
      const contentEpoch = transcriptContentEpoch.value;
      const visibleLines = ctx.visibleLines.value;
      const renderState = renderStateComputed.value;
      const scrollDirtyRowsHint = consumeScrollDirtyRowsHint();
      const messageViewportChildren: any[] = [
        h(DimSdkPaintedLinesBlock, {
          key: `transcript_${contentEpoch}`,
          startRow: ctx.visibleStartRow.value,
          lines: visibleLines as Line[],
          dirtyRowsHint: scrollDirtyRowsHint,
          renderState,
        }),
      ];

      const mainW = Math.max(0, ctx.mainW.value);
      const scroll = scrollbar.value;
      const nodes: any[] = [
        h(
          TView,
          {
            key: "messages-viewport",
            x: 2,
            y: 0,
            w: messageW.value,
            h: ctx.mainH.value,
            zIndex: 1,
            selectable: true,
            focusable: true,
            onFocusCapture: () => (ctx.focusMode.value = "messages"),
            onWheel: ctx.onWheel,
            onKeydown: ctx.onSelectionKeydown,
            onClickCapture: ctx.onMessageClick,
            onPointerdownCapture: ctx.onMessagePointerDown,
            onContextmenuCapture: ctx.onMessageContextMenu,
            onPointerleave: () => {
              let dirty = false;
              if (ctx.hoveredUserMessageId.value) {
                ctx.hoveredUserMessageId.value = null;
                dirty = true;
              }
              if (ctx.hoveredLink.value) {
                ctx.hoveredLink.value = false;
                dirty = true;
              }
              if (ctx.hoveredLinkRange.value) {
                ctx.hoveredLinkRange.value = null;
                dirty = true;
              }
              if (dirty) ctx.invalidate();
            },
          },
          () => messageViewportChildren,
        ),
      ];

      if (scroll.visible) {
        const scrollX = Math.max(0, mainW - 2);
        const scrollHitX = Math.max(0, scrollX - 1);
        const scrollHitW = Math.max(1, mainW - scrollHitX);
        const line = " ";
        const trackFill = Array.from({ length: scroll.trackH }, () => line).join("\n");
        const thumbFill = Array.from({ length: scroll.thumbH }, () => line).join("\n");
        const accentFg = ctx.ui.token("accent.cyan")?.fg;
        const thumbBg = (
          typeof accentFg === "string" && accentFg ? accentFg : "cyanBright"
        ) as Style["bg"];
        const primaryFg = ctx.ui.token("text.primary")?.fg;
        const thumbHoverBg = (
          typeof primaryFg === "string" && primaryFg ? primaryFg : thumbBg
        ) as Style["bg"];
        const trackStyle = ctx.ui.style(["bg.chrome"]);
        const thumbStyle =
          isScrollbarHovered || isScrollbarDragging
            ? ctx.ui.style(["text.primary"], { bg: thumbHoverBg, bold: true })
            : ctx.ui.style(["accent.cyan"], { bg: thumbBg });
        if (isScrollbarHovered || isScrollbarDragging) {
          nodes.push(
            h(TText, {
              key: "chat-scrollbar-track",
              x: scrollX,
              y: 0,
              w: 1,
              h: scroll.trackH,
              value: trackFill,
              style: trackStyle,
              zIndex: 4,
              clear: true,
            }),
          );
          nodes.push(
            h(TText, {
              key: "chat-scrollbar-thumb",
              x: scrollX,
              y: scroll.thumbTop,
              w: 1,
              h: scroll.thumbH,
              value: thumbFill,
              style: thumbStyle,
              zIndex: 5,
              clear: true,
            }),
          );
        }
        nodes.push(
          h(TView, {
            key: "chat-scrollbar-hit",
            x: scrollHitX,
            y: 0,
            w: scrollHitW,
            h: scroll.trackH,
            zIndex: 6,
            focusable: false,
            onWheel: ctx.onWheel,
            onPointerdown: onScrollbarPointerDown,
            onPointermove: onScrollbarPointerMove,
            onPointerup: onScrollbarPointerUp,
            onPointerenter: onScrollbarPointerEnter,
            onPointerleave: onScrollbarPointerLeave,
          }),
        );
      } else {
        isScrollbarDragging = false;
        isScrollbarHovered = false;
      }

      return h(
        TView,
        {
          x: 0,
          y: ctx.mainY,
          w: mainW,
          h: ctx.mainH.value,
          zIndex: 1,
          focusable: false,
          onPointermove: onScrollbarRevealPointerMove,
          onPointerleave: onScrollbarRevealPointerLeave,
        },
        () => nodes,
      );
    };
  },
});

export const __test__ = {
  canReuseBlockLines,
  detectScrollDirtyViewportRows,
  resolveSegmentClickBehavior,
  resolvePaintedBlockDirtyRowsHint,
};
