import { logForDebugging } from './_internal/debug.js';
import { type DOMElement, markDirty } from './dom.js';
import type { Frame } from './frame.js';
import { consumeAbsoluteRemovedFlag } from './node-cache.js';
import Output from './output.js';
import renderNodeToOutput, {
  getScrollDrainNode,
  getScrollHint,
  resetLayoutShifted,
  resetScrollDrainNode,
  resetScrollHint,
} from './render-node-to-output.js';
import { createScreen, type StylePool } from './screen.js';

export type RenderOptions = {
  frontFrame: Frame;
  backFrame: Frame;
  isTTY: boolean;
  terminalWidth: number;
  terminalRows: number;
  altScreen: boolean;
  prevFrameContaminated: boolean;
};

export type Renderer = (options: RenderOptions) => Frame;

export default function createRenderer(
  node: DOMElement,
  stylePool: StylePool,
): Renderer {
  // The Output instance lives across frames so its char cache (tokenize +
  // grapheme clustering) accumulates — the same lines tend to come back
  // unchanged frame after frame, and the cache turns those into pointer
  // comparisons.
  let output: Output | undefined;
  return options => {
    const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } =
      options;
    const prevScreen = frontFrame.screen;
    const backScreen = backFrame.screen;
    // Pools are read off the back buffer rather than captured in the
    // closure because a generational reset may have swapped them out
    // between frames.
    const charPool = backScreen.charPool;
    const hyperlinkPool = backScreen.hyperlinkPool;

    // Bail out cleanly when the yoga node is missing or hasn't laid out.
    // getComputedHeight() returns NaN before calculateLayout() runs, and
    // we also defend against the negative/Infinity cases that would
    // throw RangeError when allocating cell arrays.
    const computedHeight = node.yogaNode?.getComputedHeight();
    const computedWidth = node.yogaNode?.getComputedWidth();
    const hasInvalidHeight =
      computedHeight === undefined ||
      !Number.isFinite(computedHeight) ||
      computedHeight < 0;
    const hasInvalidWidth =
      computedWidth === undefined ||
      !Number.isFinite(computedWidth) ||
      computedWidth < 0;

    if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) {
      // Log when there *is* a yoga node but its dimensions are nonsense —
      // that case shouldn't occur and the diagnostic helps trace it.
      if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) {
        logForDebugging(
          `Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` +
            `childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`,
        );
      }
      return {
        screen: createScreen(
          terminalWidth,
          0,
          stylePool,
          charPool,
          hyperlinkPool,
        ),
        viewport: { width: terminalWidth, height: terminalRows },
        cursor: { x: 0, y: 0, visible: true },
      };
    }

    const width = Math.floor(node.yogaNode.getComputedWidth());
    const yogaHeight = Math.floor(node.yogaNode.getComputedHeight());
    // Alt-screen invariant: the screen buffer IS the alt buffer and must
    // be exactly terminalRows tall. The expected shape is a top-level
    // <Box height={rows} flexShrink={0}>, in which case yogaHeight equals
    // terminalRows naturally. When a sibling escapes that box (a layout
    // bug we've actually shipped — a stray component rendered next to the
    // fullscreen layout instead of inside it), yogaHeight exceeds
    // terminalRows and several downstream invariants collapse: the
    // viewport +1 trick, the cursor.y clamp, log-update's heightDelta===0
    // fast path. Clamping here makes the offending sibling invisible
    // (overflow cells land past screen.height and setCellAt drops them)
    // instead of corrupting the whole terminal — and we log so the
    // sibling is easy to find.
    const height = options.altScreen ? terminalRows : yogaHeight;
    if (options.altScreen && yogaHeight > terminalRows) {
      logForDebugging(
        `alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` +
          `something is rendering outside <AlternateScreen>. Overflow clipped.`,
        { level: 'warn' },
      );
    }
    const screen =
      backScreen ??
      createScreen(width, height, stylePool, charPool, hyperlinkPool);
    if (output) {
      output.reset(width, height, screen);
    } else {
      output = new Output({ width, height, stylePool, screen });
    }

    resetLayoutShifted();
    resetScrollHint();
    resetScrollDrainNode();

    // Decide whether the blit fast path can use prevScreen. Two
    // independent contamination sources to consider:
    //   - prevFrameContaminated (passed in): selection overlay, alt-screen
    //     reset, or forceRedraw mutated the screen after we returned it.
    //   - absoluteRemoved (consumed here): an absolute-positioned node
    //     was removed since last frame. Its painted cells may have
    //     covered non-siblings (e.g. an overlay over an unrelated
    //     ScrollBox earlier in tree order), so blitting from prevScreen
    //     would restore those pixels. hasRemovedChild only protects
    //     direct siblings; absolute removals reach across subtrees.
    // Normal-flow removals don't paint outside their subtree and are safe.
    const absoluteRemoved = consumeAbsoluteRemovedFlag(node);
    renderNodeToOutput(node, output, {
      prevScreen:
        absoluteRemoved || options.prevFrameContaminated
          ? undefined
          : prevScreen,
    });

    const renderedScreen = output.get();

    // Drain continuation: when a ScrollBox is in the middle of a multi-
    // frame scroll drain, the render that just finished cleared its
    // dirty flag, which would make the root blit skip the subtree next
    // frame. Re-mark it so the next frame walks back in. This has to run
    // AFTER renderNodeToOutput; otherwise the end-of-render dirty clear
    // would overwrite the mark.
    const drainNode = getScrollDrainNode();
    if (drainNode) markDirty(drainNode);

    return {
      scrollHint: options.altScreen ? getScrollHint() : null,
      scrollDrainPending: drainNode !== null,
      screen: renderedScreen,
      viewport: {
        width: terminalWidth,
        // Alt-screen viewport height is reported as `rows + 1` so the
        // diff layer's `screen.height >= viewport.height` predicate
        // (which would otherwise treat exactly-filling content as
        // overflowing into scrollback) never fires. Alt-screen content
        // is always exactly `rows` tall via the wrapping <Box>, and the
        // cursor never actually moves because we clamp it below — the
        // fake +1 just keeps log-update from issuing a full-reset.
        height: options.altScreen ? terminalRows + 1 : terminalRows,
      },
      cursor: {
        x: 0,
        // Keep the cursor inside the visible viewport in alt-screen mode.
        // If we let cursor.y = screen.height when screen.height ===
        // terminalRows exactly (the steady state when content fills the
        // alt buffer), log-update's cursor-restore emits an LF on the
        // last row, scrolls one row off the top of the alt buffer, and
        // desyncs our diff model. The cursor itself is hidden in this
        // mode so the y value only matters for the diff coords.
        y: options.altScreen
          ? Math.max(0, Math.min(screen.height, terminalRows) - 1)
          : screen.height,
        // Hide the cursor while there's dynamic content to repaint;
        // leaving it visible would smear it across the diff path. In
        // non-TTY mode we keep it visible since nothing animates anyway.
        visible: !isTTY || screen.height === 0,
      },
    };
  };
}