import {
	getPathFromDirRecursive,
	getProgram,
	loadFromYamlFileSilent,
	types as rtypes,
} from "@algo-builder/runtime";
import { types as wtypes } from "@algo-builder/web";
import {
	createDryrun,
	decodeSignedTransaction,
	encodeObj,
	modelsv2,
	SignedTransaction,
} from "algosdk";
import { spawn } from "child_process";
import * as fs from "fs";
import { ensureDirSync } from "fs-extra";
import * as path from "path";

import { writeToFile } from "../builtin-tasks/gen-accounts";
import { ASSETS_DIR, CACHE_DIR } from "../internal/core/project-structure";
import { timestampNow } from "../lib/time";
import type { ASCCache, DebuggerContext, Deployer } from "../types";
import { makeAndSignTx } from "./tx";

export const tealExt = ".teal";
export const pyExt = ".py";
export const lsigExt = ".lsig";

export class Tealdbg {
	deployer: Deployer;
	execParams: wtypes.ExecParams | wtypes.ExecParams[];

	constructor(deployer: Deployer, execParams: wtypes.ExecParams | wtypes.ExecParams[]) {
		this.deployer = deployer;
		this.execParams = execParams;
	}

	/**
	 * Create dry run request object using SDK transaction(s) from wtypes.ExecParams
	 * User can dump the response (using this.dryRunResponse) or start debugger session
	 * @returns SDK dryrun request object
	 */
	private async createDryRunReq(): Promise<modelsv2.DryrunRequest> {
		let signedTxn = (await makeAndSignTx(this.deployer, this.execParams, new Map()))[1];
		if (!Array.isArray(signedTxn)) {
			signedTxn = [signedTxn];
		}

		const signedTxns: SignedTransaction[] = [];
		for (const s of signedTxn) {
			const decodedTx = decodeSignedTransaction(s);
			signedTxns.push(decodedTx);
		}

		return await createDryrun({
			client: this.deployer.algodClient,
			txns: signedTxns,
		});
	}

	/**
	 * Gets dryrun response in json from the request object
	 * Returns a response with disassembly, logic-sig-messages with PASS/REJECT and sig-trace
	 * @param outFile name of file to dump the response. Dumped in `assets/<file>`
	 * @param force if true, overwrites an existing dryrun response dump
	 */
	async dryRunResponse(outFile?: string, force?: boolean): Promise<unknown> {
		const dryRunRequest = await this.createDryRunReq();
		const dryRunResponse = await this.deployer.algodClient.dryrun(dryRunRequest).do();
		if (outFile) {
			const outPath = path.join(ASSETS_DIR, outFile);
			await writeToFile(JSON.stringify(dryRunResponse, null, 2), force ?? false, outPath);
		}
		return dryRunResponse;
	}

	/**
	 * Start a debugger session using child_process.spawn() using the given args.
	 * Kills an existing session first (using killall -9 <process>)
	 * @param tealdbgArgs arguments to `tealdbg debug`. Eg. `--mode signature`, `--group-index 0`
	 */
	protected async runDebugger(tealdbgArgs: string[]): Promise<boolean> {
		spawn(`killall`, ["-9", "tealdbg"]); // kill existing tealdbg process first

		console.log("--> ", tealdbgArgs);

		const childProcess = spawn(`tealdbg`, ["debug", ...tealdbgArgs], {
			stdio: "inherit",
			cwd: process.cwd(),
		});
		return await new Promise<boolean>((resolve, reject) => {
			childProcess.once("close", (status) => {
				childProcess.removeAllListeners("error");

				if (status === 0) {
					resolve(true);
					return;
				}
				reject(new Error("script process returned non 0 status"));
			});

			childProcess.once("error", (status) => {
				childProcess.removeAllListeners("close");
				reject(new Error("script process returned non 0 status"));
			});
		});
	}

	/**
	 * Sets args to pass to `tealdbg debug` command. Currently supported args are
	 * tealFile, mode, groupIndex.
	 * @param debugCtxParams args passed by user for debugger session
	 * @param pathToCache path to --dryrun-dump (msgpack encoded) present in `/cache/dryrun`
	 */
	/* eslint-disable sonarjs/cognitive-complexity */
	private getTealDbgParams(debugCtxParams: DebuggerContext, pathToCache: string): string[] {
		const tealdbgArgs = [];

		/* Push path of tealfile to debug. If not passed then debugger will use assembled code by default
		 * Supplying the program will allow debugging the "original source" and not the decompiled version. */
		const file = debugCtxParams.tealFile;
		if (file) {
			let pathToFile;
			if (file.endsWith(pyExt)) {
				let tealFromPyTEAL: string | undefined;
				// note: currently tealdbg only accepts "teal" code, so we need to compile pyTEAL to
				// TEAL first. issue: https://github.com/algorand/go-algorand/issues/2538
				pathToFile = path.join(
					CACHE_DIR,
					"dryrun",
					path.parse(file).name + "." + timestampNow().toString() + tealExt
				);

				// load pyCache from "artifacts/cache"
				if (fs.existsSync(path.join(CACHE_DIR, file + ".yaml"))) {
					const pathToPyCache = getPathFromDirRecursive(CACHE_DIR, file + ".yaml");
					if (pathToPyCache) {
						const pyCache = loadFromYamlFileSilent(pathToPyCache) as ASCCache;
						tealFromPyTEAL = pyCache.tealCode;
					}
				}

				/* Use cached TEAL code if:
				 *  + We already have compiled pyteal code in artifacts/cache
				 *  + template paramteres (scInitParam) are not passed by user
				 * NOTE: if template parameters are passed, recompilation is forced to compile
				 * pyTEAL with the passed params (as the generated TEAL code could differ from cache)
				 */
				if (tealFromPyTEAL !== undefined && debugCtxParams.scInitParam === undefined) {
					console.info("\x1b[33m%s\x1b[0m", `Using cached TEAL code for ${file}`);
				} else {
					tealFromPyTEAL = getProgram(file, debugCtxParams.scInitParam);
				}
				this.writeFile(pathToFile, tealFromPyTEAL);
			} else {
				pathToFile = getPathFromDirRecursive(ASSETS_DIR, file);
			}

			if (pathToFile) {
				tealdbgArgs.push(pathToFile);
			}
		}

		// push path to --dryrun-dump (msgpack encoded) present in `/cache/dryrun`
		tealdbgArgs.push("-d", pathToCache);

		/* Set mode(application/signature) if passed. By default,
		 * the debugger scans the program to determine the type of contract. */
		if (debugCtxParams.mode !== undefined) {
			const mode =
				debugCtxParams.mode === rtypes.ExecutionMode.APPLICATION ? "application" : "signature";
			tealdbgArgs.push("--mode", mode);
		}

		// set groupIndex flag if a transaction group is passed in this.wtypes.ExecParams
		const grpIdx = debugCtxParams.groupIndex;
		if (grpIdx !== undefined) {
			const execParamsArr = Array.isArray(this.execParams)
				? this.execParams
				: [this.execParams];
			if (grpIdx >= execParamsArr.length) {
				throw new Error(
					`groupIndex(= ${grpIdx}) exceeds transaction group length(= ${execParamsArr.length})`
				);
			}
			tealdbgArgs.push("--group-index", grpIdx.toString());
		}

		return tealdbgArgs;
	}

	/**
	 * Runs a debugging session:
	 *  + Construct dryrun request using wtypes.ExecParams passed by user
	 *  + Set arguments for tealdbg debug
	 *  + Run debugger session using child_process.spawn()
	 * @param debugCtxParams args passed by user for debugger session
	 */
	async run(debugCtxParams?: DebuggerContext): Promise<void> {
		// construct encoded dryrun request using SDK
		const dryRunRequest = await this.createDryRunReq();

		/*
			Encoding fails on taking empty arrays ([]), so we need to convert
			to undefined first (hence the type hack). Ideally, the js-sdk type
			for dryrunreq.accounts should be "modelsv2.accounts[] | undefined"
		*/
		if (dryRunRequest.accounts.length === 0) {
			(dryRunRequest as any).accounts = undefined;
		}
		if (dryRunRequest.apps.length === 0) {
			(dryRunRequest as any).apps = undefined;
		}

		const encodedReq = encodeObj(dryRunRequest.get_obj_for_encoding(true));

		// output the dump in cache/dryrun directory (.msgp file is used as input to teal debugger)
		// similar to 'goal <txns> --dryrun-dump'
		const msgpDumpFileName = "dump-" + timestampNow().toString() + ".msgp";
		const pathToCache = path.join(CACHE_DIR, "dryrun", msgpDumpFileName);
		this.writeFile(pathToCache, encodedReq);

		// run tealdbg debug on dryrun-dump using args
		const tealdbgArgs = this.getTealDbgParams(debugCtxParams ?? {}, pathToCache);
		await this.runDebugger(tealdbgArgs);
	}

	// write (dryrun dump) to file in `cache/dryrun`
	protected writeFile(filename: string, content: Uint8Array | string): void {
		ensureDirSync(path.dirname(filename));
		fs.writeFileSync(filename, content);
	}
}