// Durable oracle harness for the rt zero-flicker byte goldens.
//
// Regenerates the JSON golden bundle that `crates/inkferro-rt/tests/
// zero_flicker_goldens.rs` pins. The byte streams are produced by ink's REAL
// built modules — `createIncremental` (`build/log-update.js`), `bsu`/`esu`
// (`build/write-synchronized.js`), and `ansiEscapes.clearTerminal` — driven
// through a verbatim copy of ink's `renderInteractiveFrame` decision tree
// (`src/ink.tsx` ~1037-1102). It performs a read-only import of those built
// modules; it never mutates ink sources.
//
// INVOCATION (node resolution requires running from inside the ink repo so the
// `ansi-escapes` dependency and the built `./build/*` modules resolve):
//
//   INK_DIR=/home/alpha/rewrite/ink \
//     node --experimental-vm-modules \
//     crates/inkferro-rt/tests/oracle/zero_flicker_oracle.mjs
//
// or, simplest, copy into the ink repo root and run there. The script reads the
// ink repo path from $INK_DIR (default /home/alpha/rewrite/ink) and imports the
// built modules + ansi-escapes by absolute path.
//
// Capture config matches the established M2-E oracle (inkferro-rt/src/tests.rs):
// `logUpdate.create(stream, {incremental:true, showCursor:true})`,
// no `setCursorPosition` — so cli-cursor hide/show terminal IO is suppressed and
// the cursor prefix/suffix collapse to "", leaving exactly the pure
// `&str -> bytes` content the rt crate models.
//
// DIVERGENCES from ink's real issue-450 fixture stream (documented, intentional):
//   - Renderer: ink's fixtures default to incrementalRendering:false
//     (ink/src/render.ts:211) = createStandard, which eraseLines+full-rewrites
//     every frame (real eraseLineCount 7-8). This loop drives createIncremental
//     (incremental:true), the M2-E incremental-only path inkferro-rt models, so
//     it selective-diffs (eraseLineCount 0 for constant-height fullscreen).
//   - Lifecycle: this loop ports renderInteractiveFrame only — no React mount,
//     throttle, or unmount-final-frame + unmount clearTerminal (ink's single
//     real clear). The in-scope clear DECISION is reproduced exactly.
// See zero_flicker_goldens.rs for the cross-checked real-PTY reference numbers
// and the caller-driven unmount-clear test.

import path from 'node:path';
import {pathToFileURL} from 'node:url';
import {createRequire} from 'node:module';

const INK_DIR = process.env.INK_DIR || '/home/alpha/rewrite/ink';
const inkUrl = p => pathToFileURL(path.join(INK_DIR, p)).href;
const require = createRequire(pathToFileURL(path.join(INK_DIR, 'package.json')));

const logUpdate = (await import(inkUrl('build/log-update.js'))).default;
const {bsu, esu} = await import(inkUrl('build/write-synchronized.js'));
const ansiEscapes = require('ansi-escapes');

// ---- verbatim from ink.tsx:118-152 (pure) ----
const shouldClearTerminalForFrame = ({
	isTty,
	viewportRows,
	previousOutputHeight,
	nextOutputHeight,
	isUnmounting,
}) => {
	if (!isTty) return false;
	const hadPreviousFrame = previousOutputHeight > 0;
	const wasFullscreen = previousOutputHeight >= viewportRows;
	const wasOverflowing = previousOutputHeight > viewportRows;
	const isOverflowing = nextOutputHeight > viewportRows;
	const isLeavingFullscreen = wasFullscreen && nextOutputHeight < viewportRows;
	const shouldClearOnUnmount = isUnmounting && wasFullscreen;
	return (
		wasOverflowing ||
		(isOverflowing && hadPreviousFrame) ||
		isLeavingFullscreen ||
		shouldClearOnUnmount
	);
};

class CaptureStream {
	constructor(isTty) {
		this.chunks = [];
		this.isTTY = isTty;
	}
	write(s) {
		this.chunks.push(Buffer.from(s, 'utf8'));
		return true;
	}
	bytes() {
		return Buffer.concat(this.chunks);
	}
}

// Verbatim copy of renderInteractiveFrame's decision tree, plus the
// !interactive short-circuit (ink.tsx:562-571) for the non-TTY path, threading
// explicit per-frame params instead of a React render result.
class FrameLoop {
	constructor(stream, {isTty = true} = {}) {
		this.stream = stream;
		this.isTty = isTty;
		this.log = logUpdate.create(stream, {incremental: true, showCursor: true});
		this.lastOutput = '';
		this.lastOutputHeight = 0;
		this.fullStaticOutput = '';
	}

	frame({output, viewportRows, staticOutput = '', isUnmounting = false, sync, debug = false}) {
		const outputHeight = output.split('\n').length; // derived; never an independent param
		const hasStaticOutput = staticOutput !== '' && staticOutput !== '\n';
		const isTty = this.isTty;

		if (debug) {
			if (hasStaticOutput) this.fullStaticOutput += staticOutput;
			this.lastOutput = output;
			this.lastOutputHeight = outputHeight;
			this.stream.write(this.fullStaticOutput + output);
			return;
		}

		if (!isTty) {
			if (hasStaticOutput) this.stream.write(staticOutput);
			this.lastOutput = output;
			this.lastOutputHeight = outputHeight;
			return;
		}

		const isFullscreen = outputHeight >= viewportRows;
		const outputToRender = isFullscreen ? output : output + '\n';

		if (hasStaticOutput) this.fullStaticOutput += staticOutput;

		const shouldClear = shouldClearTerminalForFrame({
			isTty,
			viewportRows,
			previousOutputHeight: this.lastOutputHeight,
			nextOutputHeight: outputHeight,
			isUnmounting,
		});

		if (shouldClear) {
			if (sync) this.stream.write(bsu);
			this.stream.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
			this.lastOutput = output;
			this.lastOutputHeight = outputHeight;
			this.log.sync(outputToRender);
			if (sync) this.stream.write(esu);
			return;
		}

		if (hasStaticOutput) {
			if (sync) this.stream.write(bsu);
			this.log.clear();
			this.stream.write(staticOutput);
			this.log(outputToRender);
			if (sync) this.stream.write(esu);
		} else if (output !== this.lastOutput) {
			// throttledLog: wrap in bsu/esu only when the diff actually renders
			// (willRender) — a no-op diff emits nothing, no empty BSU/ESU pair.
			if (sync && this.log.willRender(outputToRender)) {
				this.stream.write(bsu);
				this.log(outputToRender);
				this.stream.write(esu);
			} else {
				this.log(outputToRender);
			}
		}

		this.lastOutput = output;
		this.lastOutputHeight = outputHeight;
	}
}

// issue-450 rerender output model: Box height={targetHeight} + 3 labelled rows +
// flexGrow spacer => exactly `height` lines for height>=3. Must stay byte-equal
// to the Rust `rerender_output` helper.
function rerenderOutput(frame, height) {
	const lines = ['#450 top', `frame ${frame}`];
	while (lines.length < height - 1) lines.push('');
	lines.push('#450 bottom');
	return lines.join('\n');
}

function initialOutput(prefix, lineCount) {
	const lines = [];
	for (let i = 1; i <= lineCount; i++) lines.push(`${prefix} line ${i}`);
	return lines.join('\n');
}

function countOcc(buf, needle) {
	const n = Buffer.from(needle, 'utf8');
	if (n.length === 0) return 0;
	let hits = 0;
	let i = 0;
	while (i + n.length <= buf.length) {
		if (buf.subarray(i, i + n.length).equals(n)) {
			hits++;
			i += n.length;
		} else {
			i++;
		}
	}
	return hits;
}

function runRerender(viewport, heights, {staticFirst = false} = {}) {
	const s = new CaptureStream(true);
	const loop = new FrameLoop(s, {isTty: true});
	heights.forEach((h, frame) => {
		loop.frame({
			output: rerenderOutput(frame, h),
			viewportRows: viewport,
			staticOutput: staticFirst && frame === 0 ? '#450 static line\n' : '',
			sync: true,
		});
	});
	return s.bytes();
}

const ROWS = 6;
const frames9 = fn => Array.from({length: 9}, (_, f) => fn(ROWS, f)); // frameLimit 8 -> 9 frames

const scenarios = {};

scenarios['full-height-rerender'] = {rows: ROWS, bytes: runRerender(ROWS, frames9(r => r))};
scenarios['full-height-rerender-with-marker'] = {rows: ROWS, bytes: runRerender(ROWS, frames9(r => r))};
scenarios['height-minus-one-rerender'] = {rows: ROWS, bytes: runRerender(ROWS, frames9(r => r - 1))};
scenarios['grow-to-fullscreen-rerender'] = {
	rows: ROWS,
	bytes: runRerender(ROWS, frames9((r, f) => (f < 2 ? r - 1 : r))),
};
scenarios['shrink-from-fullscreen-rerender'] = {
	rows: ROWS,
	bytes: runRerender(ROWS, frames9((r, f) => (f < 2 ? r : r - 1))),
};
scenarios['shrink-from-overflow-rerender'] = {
	rows: ROWS,
	bytes: runRerender(ROWS, frames9((r, f) => (f === 0 ? r + 1 : r - 1))),
};
scenarios['static-shrink-from-fullscreen-rerender'] = {
	rows: ROWS,
	bytes: runRerender(ROWS, frames9((r, f) => (f < 2 ? r : r - 1)), {staticFirst: true}),
};
scenarios['full-height-with-static-rerender'] = {
	rows: ROWS,
	bytes: runRerender(ROWS, frames9(r => r), {staticFirst: true}),
};

{
	const s = new CaptureStream(true);
	new FrameLoop(s, {isTty: true}).frame({output: initialOutput('#450 initial overflow', 4), viewportRows: 3, sync: true});
	scenarios['initial-overflow'] = {rows: 3, bytes: s.bytes()};
}
{
	const s = new CaptureStream(true);
	new FrameLoop(s, {isTty: true}).frame({output: initialOutput('#450 initial fullscreen', 3), viewportRows: 3, sync: true});
	scenarios['initial-fullscreen'] = {rows: 3, bytes: s.bytes()};
}

// grow-to-overflow: render.tsx only tests this NON-TTY (frameLimit 1 -> 2 frames,
// rows 3). The !interactive branch never drives createIncremental, so the stream
// is empty and eraseLineCount is honestly 0.
{
	const s = new CaptureStream(false);
	const loop = new FrameLoop(s, {isTty: false});
	[2, 4].forEach((h, f) => loop.frame({output: rerenderOutput(f, h), viewportRows: 3, sync: false}));
	scenarios['grow-to-overflow-rerender'] = {rows: 3, bytes: s.bytes()};
}

// #2 multi-frame diff script (all WITHIN viewport 100 -> no clear ever):
// bootstrap, content change, grow, shrink, steady(no-op), change+shrink.
{
	const s = new CaptureStream(true);
	const loop = new FrameLoop(s, {isTty: true});
	for (const output of [
		'alpha\nbeta\ngamma',
		'alpha\nBETA\ngamma',
		'alpha\nBETA\ngamma\ndelta\nepsilon',
		'alpha\nBETA\ngamma',
		'alpha\nBETA\ngamma',
		'one\ntwo',
	]) {
		loop.frame({output, viewportRows: 100, sync: true});
	}
	scenarios['multiframe-diff-script'] = {rows: 100, bytes: s.bytes()};
}

// #1 steady-state: a second identical frame emits 0 bytes.
{
	const s = new CaptureStream(true);
	const loop = new FrameLoop(s, {isTty: true});
	const p = {output: 'steady\nstate', viewportRows: 100, sync: true};
	loop.frame(p);
	const before = s.bytes().length;
	loop.frame(p);
	scenarios['steady-unchanged'] = {rows: 100, bytes: s.bytes(), secondFrameBytes: s.bytes().length - before};
}

// DECSET presence/absence variants of one diff sequence.
function runDecset({sync, debug, isTty = true}) {
	const s = new CaptureStream(isTty);
	const loop = new FrameLoop(s, {isTty});
	for (const output of ['aa\nbb', 'aa\ncc', 'aa\ncc\ndd']) loop.frame({output, viewportRows: 100, sync, debug});
	return s.bytes();
}
scenarios['decset-sync-on'] = {rows: 100, bytes: runDecset({sync: true, debug: false})};
scenarios['decset-sync-off'] = {rows: 100, bytes: runDecset({sync: false, debug: false})};
scenarios['decset-debug'] = {rows: 100, bytes: runDecset({sync: false, debug: true, isTty: false})};

const out = {};
for (const [name, v] of Object.entries(scenarios)) {
	out[name] = {
		rows: v.rows,
		clearTerminalCount: countOcc(v.bytes, ansiEscapes.clearTerminal),
		eraseLineCount: countOcc(v.bytes, ansiEscapes.eraseLines(1)),
		bsuCount: countOcc(v.bytes, bsu),
		esuCount: countOcc(v.bytes, esu),
		len: v.bytes.length,
		base64: v.bytes.toString('base64'),
	};
	if (v.secondFrameBytes !== undefined) out[name].secondFrameBytes = v.secondFrameBytes;
}
process.stdout.write(JSON.stringify(out, null, 1));
