/**
 * Sealed Sins, 2023.
 */
import { pick, get, set, merge } from 'lodash';
import { compile as handlebarsCompile } from 'handlebars';
import { parseNumber, parseObject } from './utils';

// Inspired by:
// https://www.jmeiners.com/lc3-vm/

/**
 * VM register type.
 */
export enum VmReg {
	PC = 'PC',
	R0 = 'R0',
	R1 = 'R1',
	R2 = 'R2',
	R3 = 'R3',
	R4 = 'R4',
	R5 = 'R5',
	R6 = 'R6',
	R7 = 'R7',
	R8 = 'R8',
	R9 = 'R9',
}

/**
 * VM command type.
 */
// prettier-ignore
export type VmCommand = (
	| [ 'let', VmReg, VmVariable ]
	| [ 'mov', VmReg, VmReg ]
	| [ 'get', VmReg, VmReg, string ]
	| [ 'set', VmReg, VmReg, string ]
	| [ 'mrg', VmReg, VmReg ]
	| [ 'ldi', VmReg, string ]
	| [ 'sti', VmReg, string ]
	| [ 'tag', string ]
	| [ 'jmp', string ]
	| [ 'yld' ]
	| [ 'jeq', VmReg, VmReg, string ]	
	| [ 'jgt', VmReg, VmReg, string ]	
	| [ 'jlt', VmReg, VmReg, string ]	
	| [ 'dbg', string ]
	| [ 'fmt', VmReg, string ]
	| [ 'not', VmReg ]
	| [ 'inc', VmReg ]
	| [ 'dec', VmReg ]
	| [ 'add', VmReg, VmReg ]
	| [ 'sub', VmReg, VmReg ]
	| [ 'mul', VmReg, VmReg ]
	| [ 'div', VmReg, VmReg ]
);

/**
 * VM variable type.
 */
// prettier-ignore
export type VmVariable = (
	number | string | boolean | null | Array<VmVariable> | { [key: string]: VmVariable }
);

/**
 * VM error with additional metadata.
 */
export class VmError extends Error {
	// prettier-ignore
	constructor(public messageOriginal: string, public lineNumber?: number) {
		super(`VmError (at ${lineNumber ?? '?'}): ${messageOriginal}`);
	}
}

/**
 * Generic Virtual Machine.
 */
export class VM {
	private mem: Record<string, VmVariable>;
	private reg: Record<VmReg, VmVariable>;

	constructor(private readonly code: Array<VmCommand> = []) {
		this.mem = {};
		this.reg = {
			[VmReg.R0]: 0,
			[VmReg.R1]: 0,
			[VmReg.R2]: 0,
			[VmReg.R3]: 0,
			[VmReg.R4]: 0,
			[VmReg.R5]: 0,
			[VmReg.R6]: 0,
			[VmReg.R7]: 0,
			[VmReg.R8]: 0,
			[VmReg.R9]: 0,
			[VmReg.PC]: 0,
		};
	}

	/**
	 * Serialize VM state.
	 */
	public save() {
		const data = pick(this, ['reg', 'mem', 'code']);
		return JSON.stringify(data);
	}

	/**
	 * Deserialize VM state.
	 */
	public load(state: string) {
		const data = parseObject(JSON.parse(state));
		Object.assign(this, data);
	}

	/**
	 * Get register scope.
	 */
	public regs() {
		return this.reg;
	}

	/**
	 * Get register.
	 */
	public getReg<T extends VmVariable>(reg: VmReg) {
		return this.reg[reg] as T;
	}

	/**
	 * Set register.
	 */
	public setReg<T extends VmVariable>(reg: VmReg, value: T) {
		this.reg[reg] = value;
	}

	/**
	 * Get variable scope.
	 */
	public vars() {
		return this.mem;
	}

	/**
	 * Get variable.
	 */
	public getVar<T extends VmVariable>(key: string) {
		return (this.mem[key] ?? null) as T;
	}

	/**
	 * Set variable.
	 */
	public setVar<T extends VmVariable>(key: string, value: T) {
		this.mem[key] = value;
	}

	/**
	 * Format string using data from VM.
	 * Use `reg` to access registers and `mem` to access variable memory.
	 */
	public format(fmt: string) {
		const fscope = { reg: this.regs(), mem: this.vars() };
		const render = handlebarsCompile(fmt);
		return render(fscope);
	}

	/**
	 * Jump to the given tag (label).
	 */
	public jump(tag: string) {
		for (const [index, cmd] of this.code.entries()) {
			if (cmd[0] === 'tag' && cmd[1] === tag) {
				this.setReg(VmReg.PC, index + 1);
				return;
			}
		}
		// prettier-ignore
		throw new VmError(
			`Invalid jump target: ${tag}`
		);
	}

	/**
	 * Execute VM commands until the next `yld` command.
	 */
	public next() {
		const pc = this.getReg<number>(VmReg.PC);
		const cmd = this.code[pc];
		if (cmd) {
			try {
				this.setReg(VmReg.PC, pc + 1);
				if (cmd[0] !== 'yld') {
					this.exec(cmd);
					this.next();
				}
			} catch (err: any) {
				const text = err.messageOriginal || err.message || err.toString();
				const lineNumber = err.lineNumber ?? pc + 1;
				throw new VmError(text, lineNumber);
			}
		}
	}

	/**
	 * Execute given comand within current VM context.
	 */
	public exec(cmd: VmCommand) {
		switch (cmd[0]) {
			case 'let': {
				const [_, reg, value] = cmd;
				this.setReg(reg, value);
				break;
			}
			case 'mov': {
				const [_, regAcc, regVal] = cmd;
				const val = this.getReg(regVal);
				this.setReg(regAcc, val);
				break;
			}
			case 'get': {
				const [_, regAcc, regObj, path] = cmd;
				const obj = parseObject(this.getReg(regObj));
				const val = get(obj, path, null) as VmVariable;
				this.setReg(regAcc, val);
				break;
			}
			case 'set': {
				const [_, regAcc, regObj, path] = cmd;
				const obj = parseObject(this.getReg(regObj));
				const val = this.getReg(regAcc);
				set(obj, path, val);
				break;
			}
			case 'mrg': {
				const [_, regAcc, regUpd] = cmd;
				const acc = parseObject(this.getReg(regAcc));
				const upd = parseObject(this.getReg(regUpd));
				merge(acc, upd);
				break;
			}
			case 'ldi': {
				const [_, reg, key] = cmd;
				this.setReg(reg, this.getVar(key));
				break;
			}
			case 'sti': {
				const [_, reg, key] = cmd;
				this.setVar(key, this.getReg(reg));
				break;
			}
			case 'tag': {
				break; // handled by jump() method
			}
			case 'jmp': {
				const [_, tag] = cmd;
				this.jump(tag);
				break;
			}
			case 'yld': {
				break; // handled by next() method
			}
			case 'jeq': {
				const [_, regA, regB, tag] = cmd;
				const a = this.getReg(regA);
				const b = this.getReg(regB);
				if (a === b) {
					this.jump(tag);
				}
				break;
			}
			case 'jgt': {
				const [_, regA, regB, tag] = cmd;
				const a = parseNumber(this.getReg(regA));
				const b = parseNumber(this.getReg(regB));
				if (a > b) {
					this.jump(tag);
				}
				break;
			}
			case 'jlt': {
				const [_, regA, regB, tag] = cmd;
				const a = parseNumber(this.getReg(regA));
				const b = parseNumber(this.getReg(regB));
				if (a < b) {
					this.jump(tag);
				}
				break;
			}
			case 'dbg': {
				const [_, fmt] = cmd;
				const str = this.format(fmt);
				console.log(str);
				break;
			}
			case 'fmt': {
				const [_, regAcc, fmt] = cmd;
				const str = this.format(fmt);
				this.setReg(regAcc, str);
				break;
			}
			case 'not': {
				const [_, regAcc] = cmd;
				const acc = this.getReg(regAcc);
				this.setReg(regAcc, !acc);
				break;
			}
			case 'inc': {
				const [_, regAcc] = cmd;
				const acc = parseNumber(this.getReg(regAcc));
				this.setReg(regAcc, acc + 1);
				break;
			}
			case 'dec': {
				const [_, regAcc] = cmd;
				const acc = parseNumber(this.getReg(regAcc));
				this.setReg(regAcc, acc - 1);
				break;
			}
			case 'add': {
				const [_, regAcc, regVal] = cmd;
				const acc = parseNumber(this.getReg(regAcc));
				const val = parseNumber(this.getReg(regVal));
				this.setReg(regAcc, acc + val);
				break;
			}
			case 'sub': {
				const [_, regAcc, regVal] = cmd;
				const acc = parseNumber(this.getReg(regAcc));
				const val = parseNumber(this.getReg(regVal));
				this.setReg(regAcc, acc - val);
				break;
			}
			case 'mul': {
				const [_, regAcc, regVal] = cmd;
				const acc = parseNumber(this.getReg(regAcc));
				const val = parseNumber(this.getReg(regVal));
				this.setReg(regAcc, acc * val);
				break;
			}
			case 'div': {
				const [_, regAcc, regVal] = cmd;
				const acc = parseNumber(this.getReg(regAcc));
				const val = parseNumber(this.getReg(regVal));
				this.setReg(regAcc, acc / val);
				break;
			}
			default: {
				const exhaustiveSwitchTest: never = cmd;
				throw new VmError(`Unexpected: ${cmd}`);
			}
		}
	}
}