import process from 'node:process';
import {stripVTControlCharacters} from 'node:util';
import chalk from 'chalk';
import cliCursor from 'cli-cursor';
import cliSpinners from 'cli-spinners';
import logSymbols from 'log-symbols';
import stringWidth from 'string-width';
import isInteractive from 'is-interactive';
import isUnicodeSupported from 'is-unicode-supported';
import stdinDiscarder from 'stdin-discarder';

// Constants
const RENDER_DEFERRAL_TIMEOUT = 200; // Milliseconds to wait before re-rendering after partial chunk write
const SYNCHRONIZED_OUTPUT_ENABLE = '\u001B[?2026h';
const SYNCHRONIZED_OUTPUT_DISABLE = '\u001B[?2026l';

// Global state for concurrent spinner detection
const activeHooksPerStream = new Map(); // Stream → ora instance

const validColors = new Set(['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray']);

class Ora {
	#linesToClear = 0;
	#frameIndex = -1;
	#lastFrameTime = 0;
	#options;
	#spinner;
	#stream;
	#id;
	#hookedStreams = new Map();
	#isInternalWrite = false;
	#drainHandler;
	#deferRenderTimer;
	#isDiscardingStdin = false;
	#color;

	// Helper to execute writes while preventing hook recursion
	#internalWrite(fn) {
		this.#isInternalWrite = true;
		try {
			return fn();
		} finally {
			this.#isInternalWrite = false;
		}
	}

	// Helper to render if still spinning
	#tryRender() {
		if (this.isSpinning) {
			this.render();
		}
	}

	#stringifyChunk(chunk, encoding) {
		if (chunk === undefined || chunk === null) {
			return '';
		}

		if (typeof chunk === 'string') {
			return chunk;
		}

		/* eslint-disable n/prefer-global/buffer */
		if (Buffer.isBuffer(chunk) || ArrayBuffer.isView(chunk)) {
			const normalizedEncoding = (typeof encoding === 'string' && encoding && encoding !== 'buffer') ? encoding : 'utf8';
			return Buffer.from(chunk).toString(normalizedEncoding);
		}
		/* eslint-enable n/prefer-global/buffer */

		return String(chunk);
	}

	#chunkTerminatesLine(chunkString) {
		if (!chunkString) {
			return false;
		}

		const lastCharacter = chunkString.at(-1);
		return lastCharacter === '\n' || lastCharacter === '\r';
	}

	#scheduleRenderDeferral() {
		// If already deferred, don't reset timer - let it complete
		if (this.#deferRenderTimer) {
			return;
		}

		this.#deferRenderTimer = setTimeout(() => {
			this.#deferRenderTimer = undefined;

			if (this.isSpinning) {
				this.#tryRender();
			}
		}, RENDER_DEFERRAL_TIMEOUT);

		if (typeof this.#deferRenderTimer?.unref === 'function') {
			this.#deferRenderTimer.unref();
		}
	}

	#clearRenderDeferral() {
		if (this.#deferRenderTimer) {
			clearTimeout(this.#deferRenderTimer);
			this.#deferRenderTimer = undefined;
		}
	}

	// Helper to build complete line with symbol, text, prefix, and suffix
	#buildOutputLine(symbol, text, prefixText, suffixText) {
		const fullPrefixText = this.#getFullPrefixText(prefixText, ' ');
		const separatorText = symbol ? ' ' : '';
		const fullText = (typeof text === 'string') ? separatorText + text : '';
		const fullSuffixText = this.#getFullSuffixText(suffixText, ' ');
		return fullPrefixText + symbol + fullText + fullSuffixText;
	}

	constructor(options) {
		if (typeof options === 'string') {
			options = {
				text: options,
			};
		}

		this.#options = {
			color: 'cyan',
			stream: process.stderr,
			discardStdin: true,
			hideCursor: true,
			...options,
		};

		// Public
		this.color = this.#options.color;

		this.#stream = this.#options.stream;

		// Normalize isEnabled and isSilent into options
		if (typeof this.#options.isEnabled !== 'boolean') {
			this.#options.isEnabled = isInteractive({stream: this.#stream});
		}

		if (typeof this.#options.isSilent !== 'boolean') {
			this.#options.isSilent = false;
		}

		if (this.#options.interval !== undefined && !(Number.isInteger(this.#options.interval) && this.#options.interval > 0)) {
			throw new Error('The `interval` option must be a positive integer');
		}

		// Set *after* `this.#stream`.
		// Store original interval before spinner setter clears it
		const userInterval = this.#options.interval;
		// It's important that these use the public setters.
		this.spinner = this.#options.spinner;
		this.#options.interval = userInterval;
		this.text = this.#options.text;
		this.prefixText = this.#options.prefixText;
		this.suffixText = this.#options.suffixText;
		this.indent = this.#options.indent;

		if (process.env.NODE_ENV === 'test') {
			this._stream = this.#stream;
			this._isEnabled = this.#options.isEnabled;

			Object.defineProperty(this, '_linesToClear', {
				get() {
					return this.#linesToClear;
				},
				set(newValue) {
					this.#linesToClear = newValue;
				},
			});

			Object.defineProperty(this, '_frameIndex', {
				get() {
					return this.#frameIndex;
				},
			});

			Object.defineProperty(this, '_lineCount', {
				get() {
					const columns = this.#stream.columns ?? 80;
					const prefixText = typeof this.#options.prefixText === 'function' ? '' : this.#options.prefixText;
					const suffixText = typeof this.#options.suffixText === 'function' ? '' : this.#options.suffixText;
					const fullPrefixText = (typeof prefixText === 'string' && prefixText !== '') ? prefixText + ' ' : '';
					const fullSuffixText = (typeof suffixText === 'string' && suffixText !== '') ? ' ' + suffixText : '';
					const spinnerChar = '-';
					const fullText = ' '.repeat(this.#options.indent) + fullPrefixText + spinnerChar + (typeof this.#options.text === 'string' ? ' ' + this.#options.text : '') + fullSuffixText;
					return this.#computeLineCountFrom(fullText, columns);
				},
			});
		}
	}

	get indent() {
		return this.#options.indent;
	}

	set indent(indent = 0) {
		if (!(indent >= 0 && Number.isInteger(indent))) {
			throw new Error('The `indent` option must be an integer from 0 and up');
		}

		this.#options.indent = indent;
	}

	get interval() {
		return this.#options.interval ?? this.#spinner.interval ?? 100;
	}

	get spinner() {
		return this.#spinner;
	}

	set spinner(spinner) {
		this.#frameIndex = -1;
		this.#options.interval = undefined;

		if (typeof spinner === 'object') {
			if (!Array.isArray(spinner.frames) || spinner.frames.length === 0 || spinner.frames.some(frame => typeof frame !== 'string')) {
				throw new Error('The given spinner must have a non-empty `frames` array of strings');
			}

			if (spinner.interval !== undefined && !(Number.isInteger(spinner.interval) && spinner.interval > 0)) {
				throw new Error('`spinner.interval` must be a positive integer if provided');
			}

			this.#spinner = spinner;
		} else if (!isUnicodeSupported()) {
			this.#spinner = cliSpinners.line;
		} else if (spinner === undefined) {
			// Set default spinner
			this.#spinner = cliSpinners.dots;
		} else if (spinner !== 'default' && cliSpinners[spinner]) {
			this.#spinner = cliSpinners[spinner];
		} else {
			throw new Error(`There is no built-in spinner named '${spinner}'. See https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json for a full list.`);
		}
	}

	get text() {
		return this.#options.text;
	}

	set text(value = '') {
		this.#options.text = value;
	}

	get prefixText() {
		return this.#options.prefixText;
	}

	set prefixText(value = '') {
		this.#options.prefixText = value;
	}

	get suffixText() {
		return this.#options.suffixText;
	}

	set suffixText(value = '') {
		this.#options.suffixText = value;
	}

	get isSpinning() {
		return this.#id !== undefined;
	}

	#formatAffix(value, separator, placeBefore = false) {
		const resolved = typeof value === 'function' ? value() : value;
		if (typeof resolved === 'string' && resolved !== '') {
			return placeBefore ? (separator + resolved) : (resolved + separator);
		}

		return '';
	}

	#getFullPrefixText(prefixText = this.#options.prefixText, postfix = ' ') {
		return this.#formatAffix(prefixText, postfix, false);
	}

	#getFullSuffixText(suffixText = this.#options.suffixText, prefix = ' ') {
		return this.#formatAffix(suffixText, prefix, true);
	}

	#computeLineCountFrom(text, columns) {
		let count = 0;
		for (const line of stripVTControlCharacters(text).split('\n')) {
			count += Math.max(1, Math.ceil(stringWidth(line) / columns));
		}

		return count;
	}

	get color() {
		return this.#color;
	}

	set color(value) {
		if (value !== undefined && value !== false && !validColors.has(value)) {
			throw new Error('The `color` option must be a valid color or `false` to disable');
		}

		this.#color = value;
	}

	get isEnabled() {
		return this.#options.isEnabled && !this.#options.isSilent;
	}

	set isEnabled(value) {
		if (typeof value !== 'boolean') {
			throw new TypeError('The `isEnabled` option must be a boolean');
		}

		this.#options.isEnabled = value;
	}

	get isSilent() {
		return this.#options.isSilent;
	}

	set isSilent(value) {
		if (typeof value !== 'boolean') {
			throw new TypeError('The `isSilent` option must be a boolean');
		}

		this.#options.isSilent = value;
	}

	frame() {
		// Only advance frame if enough time has passed (throttle to interval)
		const now = Date.now();
		if (this.#frameIndex === -1 || now - this.#lastFrameTime >= this.interval) {
			this.#frameIndex = (this.#frameIndex + 1) % this.#spinner.frames.length;
			this.#lastFrameTime = now;
		}

		const {frames} = this.#spinner;
		let frame = frames[this.#frameIndex];

		if (this.#color) {
			frame = chalk[this.#color](frame);
		}

		const fullPrefixText = this.#getFullPrefixText(this.#options.prefixText, ' ');
		const fullText = typeof this.text === 'string' ? ' ' + this.text : '';
		const fullSuffixText = this.#getFullSuffixText(this.#options.suffixText, ' ');

		return fullPrefixText + frame + fullText + fullSuffixText;
	}

	clear() {
		if (!this.isEnabled || !this.#stream.isTTY) {
			return this;
		}

		// Protect cursor control methods (cursorTo, moveCursor, clearLine) which internally call stream.write
		this.#internalWrite(() => {
			this.#stream.cursorTo(0);

			for (let index = 0; index < this.#linesToClear; index++) {
				if (index > 0) {
					this.#stream.moveCursor(0, -1);
				}

				this.#stream.clearLine(1);
			}

			if (this.#options.indent) {
				this.#stream.cursorTo(this.#options.indent);
			}
		});

		this.#linesToClear = 0;

		return this;
	}

	// Helper to hook a single stream
	#hookStream(stream) {
		if (!stream || this.#hookedStreams.has(stream) || !stream.isTTY || typeof stream.write !== 'function') {
			return;
		}

		// Detect concurrent spinners
		if (activeHooksPerStream.has(stream)) {
			console.warn('[ora] Multiple concurrent spinners detected. This may cause visual corruption. Use one spinner at a time.');
		}

		const originalWrite = stream.write;
		this.#hookedStreams.set(stream, originalWrite);
		activeHooksPerStream.set(stream, this);
		stream.write = (chunk, encoding, callback) => this.#hookedWrite(stream, originalWrite, chunk, encoding, callback);
	}

	/**
	Intercept stream writes while spinner is active to handle external writes cleanly without visual corruption.
	Hooks process stdio streams and the active spinner stream so console.log(), console.error(), and direct writes stay tidy.
	*/
	#installHook() {
		if (!this.isEnabled || this.#hookedStreams.size > 0) {
			return;
		}

		const streamsToHook = new Set([this.#stream, process.stdout, process.stderr]);

		for (const stream of streamsToHook) {
			this.#hookStream(stream);
		}
	}

	#uninstallHook() {
		for (const [stream, originalWrite] of this.#hookedStreams) {
			stream.write = originalWrite;
			if (activeHooksPerStream.get(stream) === this) {
				activeHooksPerStream.delete(stream);
			}
		}

		this.#hookedStreams.clear();
	}

	// eslint-disable-next-line max-params -- Need stream and originalWrite for multi-stream support
	#hookedWrite(stream, originalWrite, chunk, encoding, callback) {
		// Handle both write(chunk, encoding, callback) and write(chunk, callback) signatures
		if (typeof encoding === 'function') {
			callback = encoding;
			encoding = undefined;
		}

		// Pass through our own internal writes (spinner rendering, cursor control)
		if (this.#isInternalWrite) {
			return originalWrite.call(stream, chunk, encoding, callback);
		}

		// External write detected - clear spinner, write content, re-render if appropriate
		this.clear();

		const chunkString = this.#stringifyChunk(chunk, encoding);
		const chunkTerminatesLine = this.#chunkTerminatesLine(chunkString);

		const writeResult = originalWrite.call(stream, chunk, encoding, callback);

		// Schedule or clear render deferral based on chunk content
		if (chunkTerminatesLine) {
			this.#clearRenderDeferral();
		} else if (chunkString.length > 0) {
			this.#scheduleRenderDeferral();
		}

		// Re-render spinner below the new output if still spinning and not deferred
		if (this.isSpinning && !this.#deferRenderTimer) {
			this.render();
		}

		return writeResult;
	}

	render() {
		if (!this.isEnabled || this.#drainHandler || this.#deferRenderTimer) {
			return this;
		}

		const useSynchronizedOutput = this.#stream.isTTY;
		let shouldDisableSynchronizedOutput = false;

		try {
			if (useSynchronizedOutput) {
				this.#internalWrite(() => this.#stream.write(SYNCHRONIZED_OUTPUT_ENABLE));
				shouldDisableSynchronizedOutput = true;
			}

			this.clear();

			let frameContent = this.frame();
			const columns = this.#stream.columns ?? 80;
			const actualLineCount = this.#computeLineCountFrom(frameContent, columns);

			// If content would exceed viewport height, truncate it to prevent garbage
			const consoleHeight = this.#stream.rows;
			if (consoleHeight && consoleHeight > 1 && actualLineCount > consoleHeight) {
				const lines = frameContent.split('\n');
				const maxLines = consoleHeight - 1; // Reserve one line for truncation message
				frameContent = [...lines.slice(0, maxLines), '... (content truncated to fit terminal)'].join('\n');
			}

			const canContinue = this.#internalWrite(() => this.#stream.write(frameContent));

			// Handle backpressure - pause rendering if stream buffer is full
			if (canContinue === false && this.#stream.isTTY) {
				this.#drainHandler = () => {
					this.#drainHandler = undefined;
					this.#tryRender();
				};

				this.#stream.once('drain', this.#drainHandler);
			}

			this.#linesToClear = this.#computeLineCountFrom(frameContent, columns);
		} finally {
			if (shouldDisableSynchronizedOutput) {
				this.#internalWrite(() => this.#stream.write(SYNCHRONIZED_OUTPUT_DISABLE));
			}
		}

		return this;
	}

	start(text) {
		if (text !== undefined) {
			this.text = text;
		}

		if (this.isSilent) {
			return this;
		}

		if (!this.isEnabled) {
			const symbol = this.text ? '-' : '';
			const line = ' '.repeat(this.#options.indent) + this.#buildOutputLine(symbol, this.text, this.#options.prefixText, this.#options.suffixText);

			if (line.trim() !== '') {
				this.#internalWrite(() => this.#stream.write(line + '\n'));
			}

			return this;
		}

		if (this.isSpinning) {
			return this;
		}

		if (this.#options.hideCursor) {
			cliCursor.hide(this.#stream);
		}

		if (this.#options.discardStdin && process.stdin.isTTY) {
			stdinDiscarder.start();
			this.#isDiscardingStdin = true;
		}

		this.#installHook();
		this.render();
		this.#id = setInterval(this.render.bind(this), this.interval);

		return this;
	}

	stop() {
		clearInterval(this.#id);
		this.#id = undefined;
		this.#frameIndex = -1;
		this.#lastFrameTime = 0;

		this.#clearRenderDeferral();
		this.#uninstallHook();

		// Clean up drain handler if it exists
		if (this.#drainHandler) {
			this.#stream.removeListener('drain', this.#drainHandler);
			this.#drainHandler = undefined;
		}

		if (this.isEnabled) {
			this.clear();
			if (this.#options.hideCursor) {
				cliCursor.show(this.#stream);
			}
		}

		if (this.#isDiscardingStdin) {
			this.#isDiscardingStdin = false;
			stdinDiscarder.stop();
		}

		return this;
	}

	succeed(text) {
		return this.stopAndPersist({symbol: logSymbols.success, text});
	}

	fail(text) {
		return this.stopAndPersist({symbol: logSymbols.error, text});
	}

	warn(text) {
		return this.stopAndPersist({symbol: logSymbols.warning, text});
	}

	info(text) {
		return this.stopAndPersist({symbol: logSymbols.info, text});
	}

	stopAndPersist(options = {}) {
		if (this.isSilent) {
			return this;
		}

		const symbol = options.symbol ?? ' ';
		const text = options.text ?? this.text;
		const prefixText = options.prefixText ?? this.#options.prefixText;
		const suffixText = options.suffixText ?? this.#options.suffixText;

		const textToWrite = this.#buildOutputLine(symbol, text, prefixText, suffixText) + '\n';

		this.stop();
		this.#internalWrite(() => this.#stream.write(textToWrite));

		return this;
	}
}

export default function ora(options) {
	return new Ora(options);
}

export async function oraPromise(action, options) {
	const actionIsFunction = typeof action === 'function';
	const actionIsPromise = typeof action?.then === 'function';

	if (!actionIsFunction && !actionIsPromise) {
		throw new TypeError('Parameter `action` must be a Function or a Promise');
	}

	const {
		successText,
		failText,
		successSymbol,
		failSymbol,
	} = (typeof options === 'object' && options !== null)
		? options
		: {};

	const spinner = ora(options).start();

	try {
		const promise = actionIsFunction ? action(spinner) : action;
		const result = await promise;

		const text = successText === undefined
			? undefined
			: (typeof successText === 'string' ? successText : successText(result));

		if (successSymbol === undefined) {
			spinner.succeed(text);
		} else {
			spinner.stopAndPersist({symbol: successSymbol, text});
		}

		return result;
	} catch (error) {
		const text = failText === undefined
			? undefined
			: (typeof failText === 'string' ? failText : failText(error));

		if (failSymbol === undefined) {
			spinner.fail(text);
		} else {
			spinner.stopAndPersist({symbol: failSymbol, text});
		}

		throw error;
	}
}

export {default as spinners} from 'cli-spinners';
