/**
 * Render pipeline: TermNode tree → Yoga layout → FrameBuffer → ANSI output.
 *
 * Connects the Solid reactive tree to terminal output:
 * 1. applyProps() maps JSX props to yoga layout properties
 * 2. setupTextMeasure() wires Bun.stringWidth into yoga measure functions
 * 3. layout() runs yoga calculateLayout on the tree
 * 4. paint() renders positioned nodes into a FrameBuffer
 * 5. renderFrame() diffs + serializes to an ANSI string
 */

import { render } from "../renderer/term-node.jsx";
import { Attr, color, diffFrames, FrameBuffer, NO_COLOR, serializeChanges, wrapSynchronized } from "./frame-buffer.jsx";
import { applySelectionOverlay, createSelection } from "./selection.jsx";
import { Align, Direction, Display, Edge, FlexDirection, Gutter, Justify, MeasureMode, Overflow, PositionType, Wrap, Node as YogaNode } from "./yoga.jsx";

// --- Prop → Yoga mapping ---

const FLEX_DIR_MAP = {
  row: FlexDirection.Row,
  column: FlexDirection.Column,
  "row-reverse": FlexDirection.RowReverse,
  "column-reverse": FlexDirection.ColumnReverse
};
const ALIGN_MAP = {
  auto: Align.Auto,
  "flex-start": Align.FlexStart,
  center: Align.Center,
  "flex-end": Align.FlexEnd,
  stretch: Align.Stretch,
  baseline: Align.Baseline
};
const JUSTIFY_MAP = {
  "flex-start": Justify.FlexStart,
  center: Justify.Center,
  "flex-end": Justify.FlexEnd,
  "space-between": Justify.SpaceBetween,
  "space-around": Justify.SpaceAround,
  "space-evenly": Justify.SpaceEvenly
};
const WRAP_MAP = {
  nowrap: Wrap.NoWrap,
  wrap: Wrap.Wrap,
  "wrap-reverse": Wrap.WrapReverse
};

/** Apply a TermNode's props to its yoga node. */
export function applyProps(node) {
  const yoga = node.yogaNode;
  if (!yoga) return;
  const p = node.props;

  // Flex container
  if (p.flexDirection !== undefined) yoga.setFlexDirection(FLEX_DIR_MAP[p.flexDirection] ?? FlexDirection.Column);
  if (p.flexGrow !== undefined) yoga.setFlexGrow(p.flexGrow);
  if (p.flexShrink !== undefined) yoga.setFlexShrink(p.flexShrink);
  if (p.flexBasis !== undefined) yoga.setFlexBasis(p.flexBasis);
  if (p.flexWrap !== undefined) yoga.setFlexWrap(WRAP_MAP[p.flexWrap] ?? Wrap.NoWrap);
  if (p.alignItems !== undefined) yoga.setAlignItems(ALIGN_MAP[p.alignItems] ?? Align.Stretch);
  if (p.alignSelf !== undefined) yoga.setAlignSelf(ALIGN_MAP[p.alignSelf] ?? Align.Auto);
  if (p.justifyContent !== undefined) yoga.setJustifyContent(JUSTIFY_MAP[p.justifyContent] ?? Justify.FlexStart);

  // Dimensions
  if (p.width !== undefined) yoga.setWidth(p.width);
  if (p.height !== undefined) yoga.setHeight(p.height);
  if (p.minWidth !== undefined) yoga.setMinWidth(p.minWidth);
  if (p.minHeight !== undefined) yoga.setMinHeight(p.minHeight);
  if (p.maxWidth !== undefined) yoga.setMaxWidth(p.maxWidth);
  if (p.maxHeight !== undefined) yoga.setMaxHeight(p.maxHeight);

  // Padding
  if (p.padding !== undefined) yoga.setPadding(Edge.All, p.padding);
  if (p.paddingTop !== undefined) yoga.setPadding(Edge.Top, p.paddingTop);
  if (p.paddingBottom !== undefined) yoga.setPadding(Edge.Bottom, p.paddingBottom);
  if (p.paddingLeft !== undefined) yoga.setPadding(Edge.Left, p.paddingLeft);
  if (p.paddingRight !== undefined) yoga.setPadding(Edge.Right, p.paddingRight);

  // Margin
  if (p.margin !== undefined) yoga.setMargin(Edge.All, p.margin);
  if (p.marginTop !== undefined) yoga.setMargin(Edge.Top, p.marginTop);
  if (p.marginBottom !== undefined) yoga.setMargin(Edge.Bottom, p.marginBottom);
  if (p.marginLeft !== undefined) yoga.setMargin(Edge.Left, p.marginLeft);
  if (p.marginRight !== undefined) yoga.setMargin(Edge.Right, p.marginRight);

  // Gap
  if (p.gap !== undefined) yoga.setGap(Gutter.All, p.gap);

  // Borders — reserve cells for border characters
  if (p.borderStyle !== undefined && p.borderStyle !== "none") {
    if (p.borderStyle === "horizontal") {
      yoga.setBorder(Edge.Top, 1);
      yoga.setBorder(Edge.Bottom, 1);
    } else {
      yoga.setBorder(Edge.All, 1);
    }
  }

  // Overflow
  if (p.overflow !== undefined) {
    const overflowMap = {
      visible: Overflow.Visible,
      hidden: Overflow.Hidden,
      scroll: Overflow.Scroll
    };
    yoga.setOverflow(overflowMap[p.overflow] ?? Overflow.Visible);
  }

  // Display
  if (p.display !== undefined) yoga.setDisplay(p.display === "none" ? Display.None : Display.Flex);

  // Position
  if (p.position !== undefined) {
    const posMap = {
      relative: PositionType.Relative,
      absolute: PositionType.Absolute
    };
    yoga.setPositionType(posMap[p.position] ?? PositionType.Relative);
  }
  if (p.top !== undefined) yoga.setPosition(Edge.Top, p.top);
  if (p.bottom !== undefined) yoga.setPosition(Edge.Bottom, p.bottom);
  if (p.left !== undefined) yoga.setPosition(Edge.Left, p.left);
  if (p.right !== undefined) yoga.setPosition(Edge.Right, p.right);
}

// --- Prop helpers ---

/** Extract a color prop value, returning NO_COLOR if unset. */
function propColor(props, key) {
  const v = props[key];
  if (v == null) return NO_COLOR;
  return color(v);
}

// --- Text measurement ---

/** Collect text content from a text node's children. Cached on the node. */
export function collectText(node) {
  if (node._cachedText !== null) return node._cachedText;
  let result = "";
  for (const child of node.children) {
    if (child.type === "_text") {
      result += child.props.content ?? "";
    } else if (child.type !== "_slot") {
      result += collectText(child);
    }
  }
  node._cachedText = result;
  return result;
}

/**
 * Set up yoga measure functions for text nodes in the tree.
 * Must be called after the tree is built but before layout.
 */
export function setupMeasureFunctions(node) {
  if (!node.dirty) return; // clean subtree — no new text/input nodes
  if ((node.type === "text" || node.type === "hyperlink") && node.yogaNode) {
    if (node.yogaNode.measureFunc) {
      // Already installed — closure reads from node dynamically, no need to replace
      for (const child of node.children) setupMeasureFunctions(child);
      return;
    }
    node.yogaNode.setMeasureFunc((width, widthMode, _height, _heightMode) => {
      const content = collectText(node);
      if (!content) return {
        width: 0,
        height: 0
      };
      const maxWidth = widthMode === MeasureMode.Undefined ? Number.POSITIVE_INFINITY : width;
      const sourceLines = content.split("\n");
      const mw = Math.floor(maxWidth);
      let maxLineWidth = 0;
      let height = 0;
      for (const line of sourceLines) {
        const lw = Bun.stringWidth(line);
        maxLineWidth = Math.max(maxLineWidth, lw);
        if (mw > 0 && mw < Number.POSITIVE_INFINITY && lw > mw) {
          // Use Bun.wrapAnsi to count lines — matches paint's word-wrap
          height += Bun.wrapAnsi(line, mw).split("\n").length;
        } else {
          height += 1;
        }
      }
      return {
        width: Math.min(maxLineWidth, mw),
        height
      };
    });
  } else if (node.type === "input" && node.yogaNode) {
    if (node.yogaNode.measureFunc) {
      for (const child of node.children) setupMeasureFunctions(child);
      return;
    }
    node.yogaNode.setMeasureFunc((width, widthMode, _height, _heightMode) => {
      const value = node.props.value ?? "";
      const placeholder = node.props.placeholder ?? "";
      const display = value || placeholder;
      if (!display) return {
        width: 1,
        height: 1
      }; // cursor only

      const maxWidth = widthMode === MeasureMode.Undefined ? Number.POSITIVE_INFINITY : width;

      // Measure per-line (not Bun.wrapAnsi which strips trailing spaces)
      const sourceLines = display.split("\n");
      const mw = Math.floor(maxWidth);
      let maxLineWidth = 0;
      let height = 0;
      for (const line of sourceLines) {
        const lw = Bun.stringWidth(line);
        maxLineWidth = Math.max(maxLineWidth, lw);
        height += mw > 0 ? Math.max(1, Math.ceil(lw / mw)) : 1;
      }
      return {
        width: Math.min(maxLineWidth, mw) + 1,
        // +1 for cursor
        height
      };
    });
  }
  for (const child of node.children) {
    setupMeasureFunctions(child);
  }
}

// --- Layout ---

/** Apply props and calculate layout for the entire tree. */
export function layout(root, width, height) {
  applyAllProps(root);
  setupMeasureFunctions(root);
  root.yogaNode?.calculateLayout(width, height, Direction.LTR);
}
function applyAllProps(node) {
  if (!node.dirty) return; // clean subtree — no props changed
  applyProps(node);
  for (const child of node.children) {
    applyAllProps(child);
  }
}

// --- Text truncation ---

/** Truncate a single line to fit within `maxWidth` columns. */
function truncateLine(line, maxWidth, mode) {
  const lineWidth = Bun.stringWidth(line);
  if (lineWidth <= maxWidth) return line;
  if (maxWidth <= 0) return "";
  switch (mode) {
    case "truncate":
    case "truncate-end":
      return `${Bun.sliceAnsi(line, 0, maxWidth - 1)}…`;
    case "truncate-start":
      {
        // Show the tail of the line with ellipsis at start
        const rightCols = maxWidth - 1;
        const startOffset = Bun.stringWidth(line) - rightCols;
        return `…${Bun.sliceAnsi(line, Math.max(0, startOffset))}`;
      }
    case "truncate-middle":
      {
        const half = Math.floor((maxWidth - 1) / 2);
        const left = Bun.sliceAnsi(line, 0, half);
        const rightCols = maxWidth - 1 - half;
        const rightStart = Bun.stringWidth(line) - rightCols;
        return `${left}…${Bun.sliceAnsi(line, Math.max(0, rightStart))}`;
      }
    default:
      return Bun.sliceAnsi(line, 0, maxWidth);
  }
}

// --- Border characters ---

const BORDER_CHARS = {
  single: {
    tl: "┌",
    tr: "┐",
    bl: "└",
    br: "┘",
    h: "─",
    v: "│"
  },
  double: {
    tl: "╔",
    tr: "╗",
    bl: "╚",
    br: "╝",
    h: "═",
    v: "║"
  },
  round: {
    tl: "╭",
    tr: "╮",
    bl: "╰",
    br: "╯",
    h: "─",
    v: "│"
  },
  bold: {
    tl: "┏",
    tr: "┓",
    bl: "┗",
    br: "┛",
    h: "━",
    v: "┃"
  }
};

// --- Paint: layout tree → frame buffer ---

/**
 * Compute cursor row and column within a multi-line wrapped string.
 * cursorPos is the character offset in the original (unwrapped) value.
 *
 * Walks the original value character-by-character instead of relying on
 * Bun.wrapAnsi, which strips trailing spaces and would cause the cursor
 * to not advance when the user types a space.
 */
function cursorToRowCol(value, cursorPos, colWidth) {
  if (colWidth <= 0) return {
    row: 0,
    col: cursorPos
  };
  let row = 0;
  let col = 0;
  for (let i = 0; i < cursorPos && i < value.length; i++) {
    if (value[i] === "\n") {
      row++;
      col = 0;
    } else {
      const w = Bun.stringWidth(value[i] ?? "");
      if (col + w > colWidth) {
        row++;
        col = w;
      } else {
        col += w;
      }
    }
  }
  return {
    row,
    col
  };
}

/**
 * Inverse of cursorToRowCol — convert a (row, col) screen position
 * to a character offset in the value string. Clamps to value.length
 * if the click is past the end of the text.
 */
export function rowColToCursor(value, targetRow, targetCol, colWidth) {
  if (colWidth <= 0) return Math.min(targetCol, value.length);
  let row = 0;
  let col = 0;
  for (let i = 0; i < value.length; i++) {
    // If we've reached the target row and are at or past the target column
    if (row === targetRow && col >= targetCol) return i;
    // If we've passed the target row, return start of this position
    if (row > targetRow) return i;
    if (value[i] === "\n") {
      if (row === targetRow) return i; // clicked past end of this line
      row++;
      col = 0;
    } else {
      const w = Bun.stringWidth(value[i] ?? "");
      if (col + w > colWidth) {
        if (row === targetRow) return i; // clicked past end of wrapped line
        row++;
        col = w;
      } else {
        col += w;
      }
    }
  }

  // Clicked past end of text
  return value.length;
}

/** Paint an <input> element — supports multi-line text with cursor. */
function paintInput(node, buf, x, y, w, h, clip) {
  const value = node.props.value ?? "";
  const placeholder = node.props.placeholder ?? "";
  const focused = node.props.focused ?? true;
  const rawCursor = node.props._cursorPos ?? value.length;
  const cursorPos = Math.max(0, Math.min(rawCursor, value.length));
  const fg = propColor(node.props, "color");
  const bg = propColor(node.props, "bg");
  const placeholderFg = propColor(node.props, "placeholderColor");
  const startCol = Math.floor(x);
  const startRow = Math.floor(y);
  const colWidth = Math.floor(w);
  const maxRow = Math.min(startRow + Math.floor(h), buf.rows);
  if (startRow >= buf.rows) return;
  if (value) {
    // Wrap and render all lines
    const wrapped = colWidth > 0 ? Bun.wrapAnsi(value, colWidth) : value;
    const lines = wrapped.split("\n");
    for (let i = 0; i < lines.length && startRow + i < maxRow; i++) {
      buf.writeString(startCol, startRow + i, lines[i] ?? "", fg, bg, Attr.None, clip);
    }

    // Overlay cursor
    if (focused) {
      const cur = cursorToRowCol(value, cursorPos, colWidth);
      const curRow = startRow + cur.row;
      if (curRow < maxRow) {
        const curCol = startCol + cur.col;
        const charAtCursor = curCol < buf.cols ? buf.get(curCol, curRow).char : " ";
        const display = charAtCursor === " " || charAtCursor === "" ? " " : charAtCursor;
        buf.writeString(curCol, curRow, display, fg, bg, Attr.Inverse, clip);
      }
    }
  } else if (placeholder) {
    // Render placeholder with dim
    const phFg = placeholderFg !== NO_COLOR ? placeholderFg : fg;
    const truncated = Bun.sliceAnsi(placeholder, 0, colWidth);
    buf.writeString(startCol, startRow, truncated, phFg, bg, Attr.Dim, clip);
    if (focused) {
      const first = placeholder[0] ?? " ";
      buf.writeString(startCol, startRow, first, phFg, bg, Attr.Inverse, clip);
    }
  } else if (focused) {
    // Empty + focused: just cursor
    buf.writeString(startCol, startRow, " ", fg, bg, Attr.Inverse, clip);
  }
}

/** Resolve a TermNode's text style props to Cell attributes. */
function resolveAttrs(props) {
  let attrs = Attr.None;
  if (props.bold) attrs |= Attr.Bold;
  if (props.dim) attrs |= Attr.Dim;
  if (props.italic) attrs |= Attr.Italic;
  if (props.underline) attrs |= Attr.Underline;
  if (props.inverse) attrs |= Attr.Inverse;
  if (props.strikethrough) attrs |= Attr.Strikethrough;
  return attrs;
}

/** Paint the positioned TermNode tree into a frame buffer. */
/**
 * A paint-time color filter: maps a glyph cell's local position within a
 * width×height box to a packed 24-bit fg, or `undefined` to leave it untouched.
 */

/**
 * Recolor the glyph cells inside a box's painted region using `field`, after
 * its subtree has painted. Spaces and wide-char continuation cells are skipped,
 * and the box's clip rect is respected. This is how grouped animations
 * (shimmer/wave/pulse) sweep across heterogeneous children — text, spinners,
 * nested boxes — purely by screen position, with no per-node awareness.
 */
function applyColorField(buf, col, row, w, h, field, clip) {
  const x0 = Math.max(col, clip?.left ?? 0, 0);
  const y0 = Math.max(row, clip?.top ?? 0, 0);
  const x1 = Math.min(col + w, clip?.right ?? buf.cols, buf.cols);
  const y1 = Math.min(row + h, clip?.bottom ?? buf.rows, buf.rows);
  for (let y = y0; y < y1; y++) {
    for (let x = x0; x < x1; x++) {
      const cell = buf.get(x, y);
      if (cell.width === 0 || cell.char === " ") continue;
      const fg = field(x - col, y - row, w, h);
      if (fg !== undefined) {
        cell.fg = fg;
        buf.dirtyRows.add(y);
      }
    }
  }
}
export function paint(node, buf, offsetX = 0, offsetY = 0, clip) {
  const yoga = node.yogaNode;
  if (!yoga) return;
  const x = offsetX + yoga.getComputedLeft();
  const y = offsetY + yoga.getComputedTop();
  const w = yoga.getComputedWidth();
  const h = yoga.getComputedHeight();

  // Cache screen-space rect for hit-testing. Mutate the existing rect in place
  // when present so a repaint doesn't allocate a new object per node per frame.
  const left = Math.floor(x);
  const top = Math.floor(y);
  const right = Math.floor(x + w);
  const bottom = Math.floor(y + h);
  if (node.screenRect) {
    node.screenRect.left = left;
    node.screenRect.top = top;
    node.screenRect.right = right;
    node.screenRect.bottom = bottom;
  } else {
    node.screenRect = {
      left,
      top,
      right,
      bottom
    };
  }
  if (node.type === "box") {
    const col = Math.floor(x);
    const row = Math.floor(y);
    const bw = Math.floor(w);
    const bh = Math.floor(h);

    // Paint background if set
    const bgColor = propColor(node.props, "bg");
    if (bgColor !== NO_COLOR) {
      buf.fillRect(col, row, bw, bh, " ", NO_COLOR, bgColor, Attr.None, clip);
    }

    // Paint border if set
    const borderStyle = node.props.borderStyle;
    const hasBorder = borderStyle != null && borderStyle !== "none";
    if (hasBorder) {
      const isHorizontal = borderStyle === "horizontal";
      const chars = BORDER_CHARS[borderStyle] ?? BORDER_CHARS.single;
      const borderFg = propColor(node.props, "color");
      if (isHorizontal) {
        // Horizontal-only: full-width lines, no corners or vertical edges
        const hBar = chars.h.repeat(Math.max(0, bw));
        buf.writeString(col, row, hBar, borderFg, bgColor, Attr.None, clip);
        buf.writeString(col, row + bh - 1, hBar, borderFg, bgColor, Attr.None, clip);
      } else {
        // Corners
        buf.writeString(col, row, chars.tl, borderFg, bgColor, Attr.None, clip);
        buf.writeString(col + bw - 1, row, chars.tr, borderFg, bgColor, Attr.None, clip);
        buf.writeString(col, row + bh - 1, chars.bl, borderFg, bgColor, Attr.None, clip);
        buf.writeString(col + bw - 1, row + bh - 1, chars.br, borderFg, bgColor, Attr.None, clip);

        // Horizontal edges
        const hBar = chars.h.repeat(Math.max(0, bw - 2));
        buf.writeString(col + 1, row, hBar, borderFg, bgColor, Attr.None, clip);
        buf.writeString(col + 1, row + bh - 1, hBar, borderFg, bgColor, Attr.None, clip);

        // Vertical edges
        for (let r = row + 1; r < row + bh - 1 && r < buf.rows; r++) {
          buf.writeString(col, r, chars.v, borderFg, bgColor, Attr.None, clip);
          buf.writeString(col + bw - 1, r, chars.v, borderFg, bgColor, Attr.None, clip);
        }
      }
    }

    // Determine child clipping and scroll offset
    const overflow = node.props.overflow;
    if (overflow === "hidden" || overflow === "scroll") {
      const border = hasBorder ? 1 : 0;
      const innerClip = {
        left: Math.max(clip?.left ?? 0, col + border),
        top: Math.max(clip?.top ?? 0, row + border),
        right: Math.min(clip?.right ?? buf.cols, col + bw - border),
        bottom: Math.min(clip?.bottom ?? buf.rows, row + bh - border)
      };
      const scrollY = overflow === "scroll" ? node.scrollTop : 0;
      for (const child of node.children) {
        paint(child, buf, x, y - scrollY, innerClip);
      }
    } else {
      for (const child of node.children) {
        paint(child, buf, x, y, clip);
      }
    }

    // Paint-time color filter: recolor glyph cells in this box's region
    // (set by <Animated> for shimmer/wave/pulse effects across children).
    const colorField = node.props.colorField;
    if (colorField) {
      applyColorField(buf, col, row, bw, bh, colorField, clip);
    }
  } else if (node.type === "text") {
    const content = collectText(node);
    if (!content) return;
    const fg = propColor(node.props, "color");
    const bg = propColor(node.props, "bg");
    const attrs = resolveAttrs(node.props);
    const wrapMode = node.props.wrap ?? "word";
    const colWidth = Math.floor(w);
    const startCol = Math.floor(x);
    const startRow = Math.floor(y);
    const maxRow = Math.min(startRow + Math.floor(h), buf.rows);
    if (wrapMode === "word") {
      // Paint from raw content lines to preserve spaces
      // (Bun.wrapAnsi strips trailing whitespace)
      const sourceLines = content.split("\n");
      let row = 0;
      for (const srcLine of sourceLines) {
        if (startRow + row >= maxRow) break;
        if (colWidth > 0 && Bun.stringWidth(srcLine) > colWidth) {
          // Line needs wrapping — use wrapAnsi for word boundaries
          const wrapped = Bun.wrapAnsi(srcLine, colWidth);
          for (const wLine of wrapped.split("\n")) {
            if (startRow + row >= maxRow) break;
            buf.writeString(startCol, startRow + row, wLine, fg, bg, attrs, clip);
            row++;
          }
        } else {
          buf.writeString(startCol, startRow + row, srcLine, fg, bg, attrs, clip);
          row++;
        }
      }
    } else {
      // Truncation modes — each source line becomes exactly one output line
      const sourceLines = content.split("\n");
      for (let i = 0; i < sourceLines.length && startRow + i < maxRow; i++) {
        const line = sourceLines[i] ?? "";
        const truncated = truncateLine(line, colWidth, wrapMode);
        buf.writeString(startCol, startRow + i, truncated, fg, bg, attrs, clip);
      }
    }
  } else if (node.type === "hyperlink") {
    const content = collectText(node);
    if (!content) return;
    const href = node.props.href ?? "";
    const fg = propColor(node.props, "color");
    const bg = propColor(node.props, "bg");
    const attrs = resolveAttrs(node.props);
    const colWidth = Math.floor(w);
    const startCol = Math.floor(x);
    const startRow = Math.floor(y);
    const maxRow = Math.min(startRow + Math.floor(h), buf.rows);
    const sourceLines = content.split("\n");
    let row = 0;
    for (const srcLine of sourceLines) {
      if (startRow + row >= maxRow) break;
      if (colWidth > 0 && Bun.stringWidth(srcLine) > colWidth) {
        const wrapped = Bun.wrapAnsi(srcLine, colWidth);
        for (const wLine of wrapped.split("\n")) {
          if (startRow + row >= maxRow) break;
          buf.writeString(startCol, startRow + row, wLine, fg, bg, attrs, clip, href);
          row++;
        }
      } else {
        buf.writeString(startCol, startRow + row, srcLine, fg, bg, attrs, clip, href);
        row++;
      }
    }
  } else if (node.type === "input") {
    paintInput(node, buf, x, y, w, h, clip);
  }
  // _text and _slot nodes are handled by their parent text node via collectText
}

// --- Scroll utilities ---

/** Get the total content height of a node's children (after layout). */
export function getScrollHeight(node) {
  let maxBottom = 0;
  for (const child of node.children) {
    if (child.yogaNode) {
      const bottom = child.yogaNode.getComputedTop() + child.yogaNode.getComputedHeight();
      if (bottom > maxBottom) maxBottom = bottom;
    }
  }
  return maxBottom;
}

/** Scroll a node to an absolute offset, clamped to valid range. */
export function scrollTo(node, top) {
  const viewportHeight = node.yogaNode?.getComputedHeight() ?? 0;
  // Account for border inset (1 cell top + 1 cell bottom)
  const borderTop = node.yogaNode?.getComputedBorder(Edge.Top) ?? 0;
  const borderBottom = node.yogaNode?.getComputedBorder(Edge.Bottom) ?? 0;
  const border = borderTop + borderBottom;
  const contentHeight = getScrollHeight(node);
  const maxScroll = Math.max(0, contentHeight - (viewportHeight - border));
  node.scrollTop = Math.max(0, Math.min(top, maxScroll));
}

/** Scroll a node by a relative delta. */
export function scrollBy(node, delta) {
  scrollTo(node, node.scrollTop + delta);
}

/** Scroll a node to the bottom of its content. */
export function scrollToBottom(node) {
  scrollTo(node, Infinity);
}

// --- Hit-testing ---

/**
 * Find the deepest TermNode at screen coordinates (col, row).
 * Walks children in reverse order so later (visually on-top) nodes win.
 * Returns null if no node contains the point.
 */
export function hitTest(node, col, row) {
  const rect = node.screenRect;
  if (!rect) return null;
  if (col < rect.left || col >= rect.right || row < rect.top || row >= rect.bottom) {
    return null;
  }

  // Check children in reverse z-order (last child is on top)
  for (let i = node.children.length - 1; i >= 0; i--) {
    const child = node.children[i];
    if (!child) continue;
    // Skip internal nodes (_text, _slot)
    if (child.type === "_text" || child.type === "_slot") continue;
    const hit = hitTest(child, col, row);
    if (hit) return hit;
  }

  // No child matched — this node is the target
  return node;
}

/**
 * Walk from a hit node up to root, looking for the first ancestor
 * (including itself) that has a specific handler prop.
 */
export function findHandler(node, prop) {
  let current = node;
  while (current) {
    const handler = current.props[prop];
    if (typeof handler === "function") {
      return {
        node: current,
        handler: handler
      };
    }
    current = current.parent;
  }
  return null;
}

// --- Frame render ---

export function createRenderState(cols, rows, syncSupported = false, linksEnabled = false) {
  return {
    activeHeight: 0,
    cols,
    rows,
    buffer: new FrameBuffer(cols, rows),
    prevBuffer: new FrameBuffer(cols, rows),
    selection: createSelection(),
    syncSupported,
    linksEnabled
  };
}

/** Clear dirty flags after layout so unchanged frames skip layout next time. */
export function clearDirty(node) {
  node.dirty = false;
  for (const child of node.children) {
    clearDirty(child);
  }
}

/**
 * Run the full render pipeline: layout → paint → diff → serialize.
 * Returns the ANSI string to write to stdout.
 */
export function renderFrame(root, state) {
  // 1. Layout — skip if no props/children/text changed since last frame
  if (root.dirty) {
    layout(root, state.cols, state.rows);
    clearDirty(root);
  }

  // 2. Clear only previously-painted rows, then paint
  state.buffer.clearDirtyRows();
  paint(root, state.buffer);

  // 2b. Track actual content height
  state.activeHeight = state.buffer.contentHeight();

  // 2c. Apply selection overlay (if active)
  if (state.selection.anchor && state.selection.focus) {
    applySelectionOverlay(state.buffer, state.selection);
  }

  // 3. Diff only rows that were painted this frame or last frame
  const dirtyRows = new Set(state.buffer.dirtyRows);
  for (const row of state.prevBuffer.dirtyRows) dirtyRows.add(row);
  const changes = diffFrames(state.prevBuffer, state.buffer, dirtyRows);

  // 4. Serialize to ANSI
  const ansi = serializeChanges(changes, undefined, state.linksEnabled);

  // 5. Swap buffers
  const tmp = state.prevBuffer;
  state.prevBuffer = state.buffer;
  state.buffer = tmp;

  // 6. Wrap in sync output if supported
  if (!ansi) return "";
  return state.syncSupported ? wrapSynchronized(ansi) : ansi;
}

/**
 * Content-driven render: active region height = content height.
 * Ports CC's Ink primary-screen rendering model (renderer.ts + log-update.ts):
 *
 * - Yoga root height is unconstrained → content determines height
 * - screen.height = yogaHeight (content height, may exceed terminal rows)
 * - cursor.y = screen.height (parked after last content row)
 * - When content < viewport: only renders content rows, input near top
 * - When content >= viewport: log-update's cursor-restore LF naturally
 *   scrolls old content into terminal scrollback
 * - Diff is only computed for rows within the terminal viewport
 *
 * @param terminalRows - the terminal's visible row count (for viewport calc)
 */
export function renderContentDriven(root, state, terminalRows) {
  // 1. Layout with unconstrained height — content determines size
  if (root.dirty) {
    layout(root, state.cols);
    clearDirty(root);
  }

  // 2. Compute content height from yoga layout (CC: renderer.ts line 85)
  let contentHeight = 0;
  for (const child of root.children) {
    if (child.yogaNode) {
      const bottom = child.yogaNode.getComputedTop() + child.yogaNode.getComputedHeight();
      if (bottom > contentHeight) contentHeight = Math.ceil(bottom);
    }
  }
  contentHeight = Math.max(1, contentHeight);

  // Ensure buffers are large enough for content
  if (contentHeight > state.buffer.rows) {
    state.buffer = new FrameBuffer(state.cols, contentHeight);
    state.prevBuffer = new FrameBuffer(state.cols, contentHeight);
    state.rows = contentHeight;
  }

  // 3. Paint full content into buffer
  state.buffer.clearDirtyRows();
  paint(root, state.buffer);
  const prevHeight = state.activeHeight;
  state.activeHeight = contentHeight;
  const heightDelta = Math.max(contentHeight, 1) - Math.max(prevHeight, 1);
  const growing = heightDelta > 0;
  const shrinking = heightDelta < 0;

  // CC pattern (log-update.ts): cursor is at (0, prevHeight) from the
  // previous frame's cursor-restore. Rows 0..viewportY-1 are in scrollback
  // (unreachable). We can only diff rows viewportY..contentHeight-1.
  const cursorAtBottom = prevHeight >= terminalRows;
  const prevHadScrollback = cursorAtBottom;
  const viewportY = growing ? Math.max(0, prevHeight - terminalRows + (prevHadScrollback ? 1 : 0)) : Math.max(Math.max(prevHeight, contentHeight) - terminalRows + (prevHadScrollback ? 1 : 0), 0);

  // 4. Diff only rows within the viewport (skip scrollback)
  const dirtyRows = new Set();
  for (const row of state.buffer.dirtyRows) {
    if (row >= viewportY) dirtyRows.add(row);
  }
  for (const row of state.prevBuffer.dirtyRows) {
    if (row >= viewportY && row < contentHeight) dirtyRows.add(row);
  }
  const changes = diffFrames(state.prevBuffer, state.buffer, dirtyRows);

  // 5. Swap buffers
  const tmp = state.prevBuffer;
  state.prevBuffer = state.buffer;
  state.buffer = tmp;

  // Skip output if nothing changed and height is stable
  if (changes.length === 0 && heightDelta === 0) return "";
  let out = "";

  // Handle shrinking: clear removed rows from bottom
  if (shrinking) {
    const linesToClear = -heightDelta;
    // Move up from cursor position and clear
    out += `\x1b[${linesToClear}A\x1b[0J`;
    // Now cursor is at contentHeight, move up to row 0 relative
    const remaining = contentHeight - (prevHeight - linesToClear);
    if (remaining > 0) {
      // noop — we're at the right position
    }
  }

  // Serialize changed rows. serializeChanges uses absolute row positions
  // in cursor moves, so this works correctly.
  const ansi = serializeChanges(changes, undefined, state.linksEnabled);
  if (ansi) {
    // Move cursor to top of active region before writing diffs
    if (prevHeight > 0) {
      out += `\x1b[${prevHeight}A\r`;
    }
    out += ansi;
  }

  // Handle growth: new rows are rendered by the diff above.
  // Move cursor to after content for cursor-restore LF effect.
  // CC: cursor.y = screen.height (log-update handles the LF at frame end)
  if (growing && !ansi) {
    // Content grew but diff was empty (shouldn't happen, but be safe)
    if (prevHeight > 0) {
      out += `\x1b[${prevHeight}A\r`;
    }
  }

  // Park cursor at (0, contentHeight) — CC's cursor-restore position.
  // Using CR + LFs to reach the target row, which scrolls the terminal
  // when contentHeight >= terminalRows (exactly how CC scrolls).
  out += `\r`;
  const targetRow = contentHeight;
  // We need to get to row=contentHeight from wherever the cursor ended up.
  // After writing ansi, cursor is somewhere in the content. Use absolute.
  out += `\x1b[${targetRow + 1};1H`;

  // If content exceeds terminal and cursor is past viewport bottom,
  // emit a newline to scroll (CC: log-update lines 426-438)
  if (contentHeight >= terminalRows) {
    out += `\n`;
  }
  if (!out) return "";
  return state.syncSupported ? wrapSynchronized(out) : out;
}

/**
 * Render a component to an ANSI string (one-shot).
 * Creates a temporary Solid tree, runs layout + paint, serializes, disposes.
 * Returns { ansi, height } — the ANSI output and the content height in rows.
 */
export function renderToAnsi(code, cols, linksEnabled = false) {
  // Temporary root — unconstrained height so content determines size
  const yogaRoot = new YogaNode();
  yogaRoot.setWidth(cols);
  const root = {
    type: "box",
    props: {},
    children: [],
    parent: null,
    yogaNode: yogaRoot,
    screenRect: null,
    scrollTop: 0,
    dirty: true,
    _cachedText: null
  };

  // Mount Solid tree
  const dispose = render(code, root);

  // Layout with unconstrained height
  layout(root, cols);

  // Measure actual content height
  let contentRows = 0;
  for (const child of root.children) {
    if (child.yogaNode) {
      const bottom = child.yogaNode.getComputedTop() + child.yogaNode.getComputedHeight();
      if (bottom > contentRows) contentRows = Math.ceil(bottom);
    }
  }
  contentRows = Math.max(1, contentRows);

  // Paint into a buffer sized to content
  const buf = new FrameBuffer(cols, contentRows);
  paint(root, buf);

  // Serialize (diff against empty buffer = full output)
  const empty = new FrameBuffer(cols, contentRows);
  const changes = diffFrames(empty, buf);
  const ansi = serializeChanges(changes, undefined, linksEnabled);

  // Cleanup
  dispose();
  yogaRoot.free();
  return {
    ansi,
    height: contentRows
  };
}
//# sourceMappingURL=pipeline.jsx.map
