// Rhai source formatter.
//
// Parses the source, walks the AST, emits canonical text. On a parse
// failure it returns the first error and leaves the caller responsible
// for showing it (the dashboard's UX mirrors the JSON "Format" button:
// the doc is untouched and the error is surfaced inline).
//
// Choices:
//   * Indent = one tab. The dashboard CSS is tab-based and the editor
//     keymaps `indentWithTab`, so matching the existing convention.
//   * Print width = 100 cols. If an inline-printed call's argument list
//     would push the current line past 100 cols, the args reflow one
//     per line with a trailing comma.
//   * Comments preserved verbatim. Each comment lands on its own line at
//     the indent of the statement it precedes — same-line inline
//     positioning is intentionally NOT recovered; the goal is "verbatim
//     text", not "byte-exact placement".
//   * Blank lines between statements are preserved when the user wrote
//     them; multiples collapse to one. The formatter never *adds* blank
//     lines the user didn't write (rustfmt's default policy applied
//     strictly — no forced separation between top-level fn decls).
//   * Block bodies always use multi-line braces. `{}` for empty.
//   * If parse errors are reported by the parser, the formatter refuses
//     to emit anything and returns the first error with line / column
//     coordinates (1-based, matching Rhai's own diagnostic format).

import type {
	BlockExpr,
	Comment,
	Expr,
	IfExpr,
	ObjectMapExpr,
	ParseError,
	ParseResult,
	Stmt,
	SwitchExpr
} from './ast';
import { parse } from './parser';

const PRINT_WIDTH = 100;

export type FormatResult =
	| { ok: true; text: string }
	| { ok: false; error: FormatError };

export interface FormatError {
	message: string;
	// 1-based line and column, matching Rhai's own diagnostic format.
	line: number;
	column: number;
	// Byte offset retained for callers that want to jump the editor
	// cursor (CodeMirror works in offsets, not line/col).
	offset: number;
}

export function format(source: string): FormatResult {
	const result = parse(source);
	if (result.errors.length > 0) {
		return { ok: false, error: errorPayload(source, result.errors[0]) };
	}
	const p = new Printer(result);
	p.printProgram();
	return { ok: true, text: p.finish() };
}

function errorPayload(source: string, e: ParseError): FormatError {
	const { line, column } = lineColAt(source, e.start);
	return { message: e.message, line, column, offset: e.start };
}

// Convert a byte offset into 1-based (line, column). Used for rendering
// parser errors in a way that matches Rhai's own diagnostic format
// (e.g. "Expecting name of a variable (line 2, position 4)").
function lineColAt(source: string, offset: number): { line: number; column: number } {
	let line = 1;
	let lineStart = 0;
	const limit = Math.min(offset, source.length);
	for (let i = 0; i < limit; i++) {
		if (source.charCodeAt(i) === 10) {
			line++;
			lineStart = i + 1;
		}
	}
	return { line, column: limit - lineStart + 1 };
}

class Printer {
	private buf = '';
	private indent = 0;
	private commentPtr = 0;

	constructor(private result: ParseResult) {}

	finish(): string {
		this.drainCommentsBefore(this.result.source.length + 1);
		// Strip trailing whitespace from every line; ensure a single
		// terminating newline.
		const text = this.buf.replace(/[ \t]+$/gm, '').replace(/\n*$/, '\n');
		return text;
	}

	// ---------------------------------------------------------------- emit

	private emit(s: string): void {
		this.buf += s;
	}

	private newline(): void {
		this.buf += '\n' + '\t'.repeat(this.indent);
	}

	private blankLine(): void {
		// Two newlines, but never more than that.
		if (this.buf.endsWith('\n\n' + '\t'.repeat(this.indent))) return;
		// Remove any trailing indent we already wrote so the blank line is
		// truly blank (no stray tabs).
		this.buf = this.buf.replace(/[ \t]*$/, '');
		if (!this.buf.endsWith('\n')) this.buf += '\n';
		this.buf += '\n' + '\t'.repeat(this.indent);
	}

	private column(): number {
		const last = this.buf.lastIndexOf('\n');
		return this.buf.length - (last + 1);
	}

	// Run `body` against a scratch buffer, return the text it would have
	// appended. Useful for measuring before deciding whether to reflow.
	private measure(body: () => void): string {
		const prev = this.buf;
		body();
		const text = this.buf.slice(prev.length);
		this.buf = prev;
		return text;
	}

	// ------------------------------------------------------------ comments

	private drainCommentsBefore(pos: number): void {
		const comments = this.result.comments;
		while (this.commentPtr < comments.length && comments[this.commentPtr].start < pos) {
			const c = comments[this.commentPtr++];
			this.emitComment(c);
		}
	}

	private emitComment(c: Comment): void {
		// Emit each comment on its own line at the current indent. For
		// block comments we still keep the original text (which may span
		// multiple lines) verbatim.
		this.emit(c.text);
		this.newline();
	}

	// ------------------------------------------------------------- program

	printProgram(): void {
		const stmts = this.result.program.stmts;
		for (let i = 0; i < stmts.length; i++) {
			const stmt = stmts[i];
			if (i > 0) {
				if (this.hadBlankBetween(stmts[i - 1].end, stmt.start)) this.blankLine();
				else this.newline();
			}
			this.drainCommentsBefore(stmt.start);
			this.printStmt(stmt);
		}
	}

	// "Did the user leave a blank line in this gap?" Consulted between
	// every pair of emitted statements to decide whether to keep the
	// vertical separator the source originally had.
	private hadBlankBetween(prevEnd: number, currStart: number): boolean {
		for (const offset of this.result.blankLines) {
			if (offset >= prevEnd && offset < currStart) return true;
		}
		return false;
	}

	// ---------------------------------------------------------- statements

	private printStmt(stmt: Stmt): void {
		switch (stmt.kind) {
			case 'Let':
			case 'Const': {
				this.emit(stmt.kind === 'Let' ? 'let ' : 'const ');
				this.emit(stmt.name);
				if (stmt.init) {
					this.emit(' = ');
					this.printExpr(stmt.init);
				}
				this.emit(';');
				return;
			}
			case 'FnDecl': {
				this.emit('fn ');
				this.emit(stmt.name);
				this.emit('(');
				this.emit(stmt.params.map((p) => p.name).join(', '));
				this.emit(') ');
				this.printBlock(stmt.body);
				return;
			}
			case 'ExprStmt': {
				this.printExpr(stmt.expr);
				// Preserve whether the user terminated with `;`. Block-form
				// expressions never take one, so suppress regardless.
				if (stmt.semi && !isBlockForm(stmt.expr)) this.emit(';');
				return;
			}
			case 'Return': {
				this.emit('return');
				if (stmt.value) {
					this.emit(' ');
					this.printExpr(stmt.value);
				}
				this.emit(';');
				return;
			}
			case 'While': {
				this.emit('while ');
				this.printExpr(stmt.cond);
				this.emit(' ');
				this.printBlock(stmt.body);
				return;
			}
			case 'Loop': {
				this.emit('loop ');
				this.printBlock(stmt.body);
				return;
			}
			case 'For': {
				this.emit('for ');
				this.emit(stmt.varName);
				this.emit(' in ');
				this.printExpr(stmt.iter);
				this.emit(' ');
				this.printBlock(stmt.body);
				return;
			}
			case 'Break':
				this.emit('break;');
				return;
			case 'Continue':
				this.emit('continue;');
				return;
			case 'Try': {
				this.emit('try ');
				this.printBlock(stmt.body);
				this.emit(' catch');
				if (stmt.catchVar) {
					this.emit(' (');
					this.emit(stmt.catchVar);
					this.emit(') ');
				} else {
					this.emit(' ');
				}
				this.printBlock(stmt.handler);
				return;
			}
		}
	}

	private printBlock(block: BlockExpr): void {
		if (block.stmts.length === 0) {
			this.drainCommentsBefore(block.end);
			this.emit('{}');
			return;
		}
		this.emit('{');
		this.indent++;
		for (let i = 0; i < block.stmts.length; i++) {
			if (i > 0 && this.hadBlankBetween(block.stmts[i - 1].end, block.stmts[i].start)) {
				this.blankLine();
			} else {
				this.newline();
			}
			this.drainCommentsBefore(block.stmts[i].start);
			this.printStmt(block.stmts[i]);
		}
		this.drainCommentsBefore(block.end);
		this.indent--;
		this.newline();
		this.emit('}');
	}

	// --------------------------------------------------------- expressions

	private printExpr(expr: Expr): void {
		switch (expr.kind) {
			case 'Ident':
				this.emit(expr.name);
				return;
			case 'Number':
			case 'String':
				this.emit(expr.raw);
				return;
			case 'Bool':
				this.emit(expr.value ? 'true' : 'false');
				return;
			case 'Null':
				this.emit('null');
				return;
			case 'Member': {
				this.printExpr(expr.object);
				// `log::info` was parsed as Member(Ident(log), 'info'); restore
				// the namespace separator for known namespaces. `ctx.request`
				// always uses `.` because we parsed it that way.
				const sep = isNamespacePath(expr.object) ? '::' : '.';
				this.emit(sep);
				this.emit(expr.property);
				return;
			}
			case 'Index':
				this.printExpr(expr.object);
				this.emit('[');
				this.printExpr(expr.index);
				this.emit(']');
				return;
			case 'Call':
				this.printExpr(expr.callee);
				this.printArgList('(', ')', expr.args);
				return;
			case 'Unary':
				this.emit(expr.op);
				this.printExpr(expr.operand);
				return;
			case 'Binary':
				this.printExpr(expr.left);
				this.emit(` ${expr.op} `);
				this.printExpr(expr.right);
				return;
			case 'Assign':
				this.printExpr(expr.target);
				this.emit(` ${expr.op} `);
				this.printExpr(expr.value);
				return;
			case 'Paren':
				this.emit('(');
				this.printExpr(expr.expr);
				this.emit(')');
				return;
			case 'Array':
				this.printArgList('[', ']', expr.elements);
				return;
			case 'ObjectMap':
				this.printObjectMap(expr);
				return;
			case 'FnExpr':
				this.emit('fn (');
				this.emit(expr.params.map((p) => p.name).join(', '));
				this.emit(') ');
				this.printBlock(expr.body);
				return;
			case 'IfExpr':
				this.printIf(expr);
				return;
			case 'SwitchExpr':
				this.printSwitch(expr);
				return;
			case 'BlockExpr':
				this.printBlock(expr);
				return;
		}
	}

	private printArgList(open: string, close: string, items: Expr[]): void {
		if (items.length === 0) {
			this.emit(open);
			this.emit(close);
			return;
		}
		const inline = this.measure(() => {
			this.emit(open);
			for (let i = 0; i < items.length; i++) {
				if (i > 0) this.emit(', ');
				this.printExpr(items[i]);
			}
			this.emit(close);
		});
		if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
			this.emit(inline);
			return;
		}
		this.emit(open);
		this.indent++;
		for (const item of items) {
			this.newline();
			this.printExpr(item);
			this.emit(',');
		}
		this.indent--;
		this.newline();
		this.emit(close);
	}

	private printObjectMap(expr: ObjectMapExpr): void {
		if (expr.entries.length === 0) {
			this.emit('#{}');
			return;
		}
		const inline = this.measure(() => {
			this.emit('#{ ');
			for (let i = 0; i < expr.entries.length; i++) {
				const e = expr.entries[i];
				if (i > 0) this.emit(', ');
				this.emit(e.key);
				this.emit(': ');
				this.printExpr(e.value);
			}
			this.emit(' }');
		});
		if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
			this.emit(inline);
			return;
		}
		this.emit('#{');
		this.indent++;
		for (const e of expr.entries) {
			this.newline();
			this.emit(e.key);
			this.emit(': ');
			this.printExpr(e.value);
			this.emit(',');
		}
		this.indent--;
		this.newline();
		this.emit('}');
	}

	private printIf(expr: IfExpr): void {
		this.emit('if ');
		this.printExpr(expr.cond);
		this.emit(' ');
		this.printBlock(expr.then);
		if (expr.else_) {
			this.emit(' else ');
			if (expr.else_.kind === 'IfExpr') this.printIf(expr.else_);
			else this.printBlock(expr.else_);
		}
	}

	private printSwitch(expr: SwitchExpr): void {
		this.emit('switch ');
		this.printExpr(expr.subject);
		this.emit(' {');
		this.indent++;
		for (const arm of expr.arms) {
			this.newline();
			if (arm.pattern === null) this.emit('_');
			else this.printExpr(arm.pattern);
			if (arm.guard) {
				this.emit(' if ');
				this.printExpr(arm.guard);
			}
			this.emit(' => ');
			this.printExpr(arm.value);
			this.emit(',');
		}
		this.indent--;
		this.newline();
		this.emit('}');
	}
}

function isBlockForm(expr: Expr): boolean {
	return expr.kind === 'IfExpr' || expr.kind === 'SwitchExpr' || expr.kind === 'BlockExpr' || expr.kind === 'FnExpr';
}

// Namespace path detection — used by `Member` printing to decide between
// `.` and `::`. Currently the only well-known namespace in scripts is
// `log`, but we generalize to any bare identifier whose name happens to
// be the namespace token. False positives are harmless (we'd render
// `something::field` for a local named `log`); the parser-side fix would
// be a dedicated `Path` node — not worth it for one keyword.
function isNamespacePath(expr: Expr): boolean {
	if (expr.kind === 'Ident') return expr.name === 'log';
	return false;
}
