import { promises as fs } from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
import { DebugProtocol } from "@vscode/debugprotocol";
import chai from "chai";
import chaiSubset from "chai-subset";
const chaiAsPromised = import("chai-as-promised");
// const chaiAsPromised = await import("chai-as-promised"); // TODO: use after migration to ECMAScript modules

chaiAsPromised.then((module) => {
	chai.use(module.default);
});

import { promisify } from "node:util";
import { execFile } from "node:child_process";
import { clean_godot_path } from "../../../utils";

const execFileAsync = promisify(execFile);

chai.use(chaiSubset);
const { expect } = chai;

async function sleep(ms) {
	return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Given a path to a script, returns an object where each key is the name of a
 * breakpoint (delimited by `breakpoint::`) and each value is the line number
 * where the breakpoint appears in the script.
 *
 * @param scriptPath The path to the script to scan.
 * @returns An object of breakpoint names to line numbers.
 */
async function getBreakpointLocations(scriptPath: string): Promise<{ [key: string]: vscode.Location }> {
	const script_content = await fs.readFile(scriptPath, "utf-8");
	const breakpoints: { [key: string]: vscode.Location } = {};
	const breakpointRegex = /\b(breakpoint::.*)\b/g;
	let match: RegExpExecArray | null;
	while (true) {
		match = breakpointRegex.exec(script_content);
		if (match === null) {
			break;
		}
		const breakpointName = match[1];
		const line = match.index ? script_content.substring(0, match.index).split("\n").length : 1;
		breakpoints[breakpointName] = new vscode.Location(
			vscode.Uri.file(scriptPath),
			new vscode.Position(line - 1, 0),
		);
	}
	return breakpoints;
}

async function waitForActiveStackItemChange(
	ms = 10000,
): Promise<vscode.DebugThread | vscode.DebugStackFrame | undefined> {
	const res = await new Promise<vscode.DebugThread | vscode.DebugStackFrame | undefined>((resolve, reject) => {
		const debugListener = vscode.debug.onDidChangeActiveStackItem((event) => {
			debugListener.dispose();
			resolve(vscode.debug.activeStackItem);
		});

		// Timeout fallback in case stack item never changes
		setTimeout(() => {
			debugListener.dispose();
			console.warn();
			reject(new Error(`The ActiveStackItem eventwas not changed within the timeout period of '${ms}'`));
		}, ms);
	});

	return res;
}

async function getStackFrames(threadId = 1): Promise<DebugProtocol.StackFrame[]> {
	// Ensure there is an active debug session
	if (!vscode.debug.activeDebugSession) {
		throw new Error("No active debug session found");
	}

	// corresponds to file://./debug_session.ts stackTraceRequest(...)
	const stackTraceResponse = await vscode.debug.activeDebugSession.customRequest("stackTrace", {
		threadId: threadId,
	});

	// Extract and return the stack frames
	return stackTraceResponse.stackFrames || [];
}

async function waitForBreakpoint(
	breakpoint: vscode.SourceBreakpoint,
	timeoutMs: number,
	ctx?: Mocha.Context,
): Promise<void> {
	const t0 = performance.now();
	console.log(
		fmt(
			`Waiting for breakpoint ${breakpoint.location.uri.path}:${breakpoint.location.range.start.line}, enabled: ${breakpoint.enabled}`,
		),
	);
	const res = await waitForActiveStackItemChange(timeoutMs);
	const t1 = performance.now();
	console.log(
		fmt(
			`Waiting for breakpoint completed ${breakpoint.location.uri.path}:${breakpoint.location.range.start.line}, enabled: ${breakpoint.enabled}, took ${t1 - t0}ms`,
		),
	);
	const stackFrames = await getStackFrames();
	if (
		stackFrames[0].source.path !== breakpoint.location.uri.fsPath ||
		stackFrames[0].line !== breakpoint.location.range.start.line + 1
	) {
		throw new Error(
			`Wrong breakpoint was hit. Expected: ${breakpoint.location.uri.fsPath}:${breakpoint.location.range.start.line + 1}, Got: ${stackFrames[0].source.path}:${stackFrames[0].line}`,
		);
	}
}

enum VariableScope {
	Locals = 0,
	Members = 1,
	Globals = 2,
}

async function getVariablesForVSCodeID(vscode_id: number): Promise<DebugProtocol.Variable[]> {
	// corresponds to file://./debug_session.ts protected async variablesRequest
	const variablesResponse = await vscode.debug.activeDebugSession?.customRequest("variables", {
		variablesReference: vscode_id,
	});
	return variablesResponse?.variables || [];
}

async function getVariablesForScope(scope: VariableScope, stack_frame_id = 0): Promise<DebugProtocol.Variable[]> {
	const res_scopes = await vscode.debug.activeDebugSession.customRequest("scopes", { frameId: stack_frame_id });
	const scope_name = VariableScope[scope];
	const scope_res = res_scopes.scopes.find((s) => s.name === scope_name);
	if (scope_res === undefined) {
		throw new Error(`No ${scope_name} scope found in responce from "scopes" request`);
	}
	const vscode_id = scope_res.variablesReference;
	const variables = await getVariablesForVSCodeID(vscode_id);
	return variables;
}

async function evaluateRequest(scope: VariableScope, expression: string, context = "watch", frameId = 0): Promise<any> {
	// corresponds to file://./debug_session.ts protected async evaluateRequest
	const evaluateResponse: DebugProtocol.EvaluateResponse = await vscode.debug.activeDebugSession?.customRequest(
		"evaluate",
		{
			context,
			expression,
			frameId,
		},
	);
	return evaluateResponse.body;
}

function formatMs(ms: number): string {
	const seconds = Math.floor((ms / 1000) % 60);
	const minutes = Math.floor((ms / (1000 * 60)) % 60);
	return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${(Math.round(ms) % 1000).toString().padStart(3, "0")}`;
}

function formatMessage(this: Mocha.Context, msg: string): string {
	return `[${formatMs(performance.now() - this.testStart)}] ${msg}`;
}

let fmt: (msg: string) => string; // formatMessage bound to Mocha.Context

declare global {
	// eslint-disable-next-line @typescript-eslint/no-namespace
	namespace Chai {
		interface Assertion {
			unique: Assertion;
		}
	}
}

chai.Assertion.addProperty("unique", function () {
	const actual = this._obj; // The object being tested
	if (!Array.isArray(actual)) {
		throw new chai.AssertionError("Expected value to be an array");
	}
	const uniqueArray = [...new Set(actual)];
	this.assert(
		actual.length === uniqueArray.length,
		"expected #{this} to contain only unique elements",
		"expected #{this} to not contain only unique elements",
		uniqueArray,
		actual,
	);
});

async function startDebugging(
	scene: "ScopeVars.tscn" | "ExtensiveVars.tscn" | "BuiltInTypes.tscn" = "ScopeVars.tscn",
): Promise<void> {
	const t0 = performance.now();
	const debugConfig: vscode.DebugConfiguration = {
		type: "godot",
		request: "launch",
		name: "Godot Debug",
		scene: scene,
		additional_options: "--headless",
	};
	console.log(fmt(`Starting debugger for scene ${scene}`));
	const res = await vscode.debug.startDebugging(vscode.workspace.workspaceFolders?.[0], debugConfig);
	const t1 = performance.now();
	console.log(fmt(`Starting debugger for scene ${scene} completed, took ${t1 - t0}ms`));
	if (!res) {
		throw new Error(`Failed to start debugging for scene ${scene}`);
	}
}

suite("DAP Integration Tests - Variable Scopes", () => {
	// workspaceFolder should match `.vscode-test.js`::workspaceFolder
	const workspaceFolder = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
	if (!workspaceFolder || !workspaceFolder.endsWith("test-dap-project-godot4")) {
		throw new Error(`workspaceFolder should contain 'test-dap-project-godot4' project, got: ${workspaceFolder}`);
	}

	suiteSetup(async function () {
		this.timeout(20000); // enough time to do `godot --import`
		console.log("Environment Variables:");
		for (const [key, value] of Object.entries(process.env)) {
			console.log(`${key}: ${value}`);
		}

		// init the godot project by importing it in godot engine:
		const config = vscode.workspace.getConfiguration("godotTools");
		// config.update("editorPath.godot4", "godot4", vscode.ConfigurationTarget.Workspace);

		const godot4_path = clean_godot_path(config.get<string>("editorPath.godot4"));

		// get the path for currently opened project in vscode test instance:
		console.log("Executing", [godot4_path, "--headless", "--import", workspaceFolder]);
		const exec_res = await execFileAsync(godot4_path, ["--headless", "--import", workspaceFolder], {
			shell: true,
			cwd: workspaceFolder,
		});
		if (exec_res.stderr !== "") {
			// TODO: was preventing tests from running
			// throw new Error(exec_res.stderr);
			console.log(exec_res.stderr);
		}
		console.log(exec_res.stdout);
	});

	setup(async function () {
		console.log(`➤ Test '${this?.currentTest.title}' starting`);
		await vscode.commands.executeCommand("workbench.action.closeAllEditors");
		if (vscode.debug.breakpoints) {
			await vscode.debug.removeBreakpoints(vscode.debug.breakpoints);
		}
		this.testStart = performance.now();
		fmt = formatMessage.bind(this);
	});

	teardown(async function () {
		this.timeout(3000);
		await sleep(1000);
		if (vscode.debug.activeDebugSession !== undefined) {
			console.log("Closing debug session");
			await vscode.debug.stopDebugging();
			await sleep(1000);
		}
		console.log(
			`⬛ Test '${this.currentTest.title}' result: ${this.currentTest.state}, duration: ${performance.now() - this.testStart}ms`,
		);
	});

	test("should return correct scopes", async () => {
		const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd"));
		const breakpoint = new vscode.SourceBreakpoint(
			breakpointLocations["breakpoint::ScopeVars::ClassFoo::test_function"],
		);
		vscode.debug.addBreakpoints([breakpoint]);

		await startDebugging("ScopeVars.tscn");
		await waitForBreakpoint(breakpoint, 2000);

		// TODO: current DAP needs a delay before it will return variables
		console.log("Sleeping for 2 seconds");
		await sleep(2000);

		// corresponds to file://./debug_session.ts async scopesRequest
		const stack_scopes_map: Map<
			number,
			{
				Locals: number;
				Members: number;
				Globals: number;
			}
		> = new Map();
		for (let stack_frame_id = 0; stack_frame_id < 3; stack_frame_id++) {
			const res_scopes = await vscode.debug.activeDebugSession.customRequest("scopes", {
				frameId: stack_frame_id,
			});
			expect(res_scopes).to.exist;
			expect(res_scopes.scopes).to.exist;
			expect(res_scopes.scopes.length).to.equal(3, "Expected 3 scopes");
			expect(res_scopes.scopes[0].name).to.equal(VariableScope[VariableScope.Locals], "Expected Locals scope");
			expect(res_scopes.scopes[1].name).to.equal(VariableScope[VariableScope.Members], "Expected Members scope");
			expect(res_scopes.scopes[2].name).to.equal(VariableScope[VariableScope.Globals], "Expected Globals scope");
			const vscode_ids = res_scopes.scopes.map((s) => s.variablesReference);
			expect(vscode_ids, "VSCode IDs should be unique for each scope").to.be.unique;
			stack_scopes_map[stack_frame_id] = {
				Locals: vscode_ids[0],
				Members: vscode_ids[1],
				Globals: vscode_ids[2],
			};
		}

		const all_scopes_vscode_ids = Array.from(stack_scopes_map.values()).flatMap((s) => Object.values(s));
		expect(all_scopes_vscode_ids, "All scopes should be unique").to.be.unique;

		const vars_frame0_locals = await getVariablesForVSCodeID(stack_scopes_map[0].Locals);
		expect(vars_frame0_locals).to.containSubset([
			{ name: "str_var", value: "ScopeVars::ClassFoo::test_function::local::str_var" },
		]);

		const vars_frame1_locals = await getVariablesForVSCodeID(stack_scopes_map[1].Locals);
		expect(vars_frame1_locals).to.containSubset([{ name: "str_var", value: "ScopeVars::test::local::str_var" }]);

		const vars_frame2_locals = await getVariablesForVSCodeID(stack_scopes_map[2].Locals);
		expect(vars_frame2_locals).to.containSubset([{ name: "str_var", value: "ScopeVars::_ready::local::str_var" }]);
	})?.timeout(10000);

	test("should return global variables", async () => {
		const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd"));
		const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::_ready"]);
		vscode.debug.addBreakpoints([breakpoint]);

		await startDebugging("ScopeVars.tscn");
		await waitForBreakpoint(breakpoint, 2000);

		// TODO: current DAP needs a delay before it will return variables
		console.log("Sleeping for 2 seconds");
		await sleep(2000);

		const variables = await getVariablesForScope(VariableScope.Globals);
		expect(variables).to.containSubset([{ name: "GlobalScript" }]);
	})?.timeout(10000);

	test("should return all local variables", async () => {
		/** {@link file://./../../../../test_projects/test-dap-project-godot4/ScopeVars.gd"} */
		const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd"));
		const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::_ready"]);
		vscode.debug.addBreakpoints([breakpoint]);

		await startDebugging("ScopeVars.tscn");
		await waitForBreakpoint(breakpoint, 2000);

		// TODO: current DAP needs a delay before it will return variables
		console.log("Sleeping for 2 seconds");
		await sleep(2000);

		const variables = await getVariablesForScope(VariableScope.Locals);
		expect(variables.length).to.equal(2);
		expect(variables).to.containSubset([{ name: "str_var" }]);
		expect(variables).to.containSubset([{ name: "self_var" }]);
	})?.timeout(10000);

	test("should return all member variables", async () => {
		/** {@link file://./../../../../test_projects/test-dap-project-godot4/ScopeVars.gd"} */
		const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd"));
		const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::_ready"]);
		vscode.debug.addBreakpoints([breakpoint]);

		await startDebugging("ScopeVars.tscn");
		await waitForBreakpoint(breakpoint, 2000);

		// TODO: current DAP needs a delay before it will return variables
		console.log("Sleeping for 2 seconds");
		await sleep(2000);

		const variables = await getVariablesForScope(VariableScope.Members);
		expect(variables.length).to.equal(4);
		expect(variables).to.containSubset([{ name: "self" }]);
		expect(variables).to.containSubset([{ name: "member1" }]);
		expect(variables).to.containSubset([{ name: "str_var", value: "ScopeVars::member::str_var" }]);
		expect(variables).to.containSubset([
			{ name: "str_var_member_only", value: "ScopeVars::member::str_var_member_only" },
		]);
	})?.timeout(10000);

	test("should retrieve all built-in types correctly", async () => {
		const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "BuiltInTypes.gd"));
		const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::BuiltInTypes::_ready"]);
		vscode.debug.addBreakpoints([breakpoint]);

		await startDebugging("BuiltInTypes.tscn");
		await waitForBreakpoint(breakpoint, 2000);

		// TODO: current DAP needs a delay before it will return variables
		console.log("Sleeping for 2 seconds");
		await sleep(2000);

		const variables = await getVariablesForScope(VariableScope.Locals);

		expect(variables).to.containSubset([{ name: "int_var", value: "42" }]);
		expect(variables).to.containSubset([{ name: "float_var", value: "3.14" }]);
		expect(variables).to.containSubset([{ name: "bool_var", value: "true" }]);
		expect(variables).to.containSubset([{ name: "string_var", value: "Hello, Godot!" }]);
		expect(variables).to.containSubset([{ name: "nil_var", value: "null" }]);
		expect(variables).to.containSubset([{ name: "vector2", value: "Vector2(10, 20)" }]);
		expect(variables).to.containSubset([{ name: "vector3", value: "Vector3(1, 2, 3)" }]);
		expect(variables).to.containSubset([{ name: "rect2", value: "Rect2((0, 0) - (100, 50))" }]);
		expect(variables).to.containSubset([{ name: "quaternion", value: "Quat(0, 0, 0, 1)" }]);
		expect(variables).to.containSubset([{ name: "simple_array", value: "(3) [1, 2, 3]" }]);
		// expect(variables).to.containSubset([{ name: "nested_dict.nested_key", value: `"Nested Value"` }]);
		// expect(variables).to.containSubset([{ name: "nested_dict.sub_dict.sub_key", value: "99" }]);
		expect(variables).to.containSubset([{ name: "nested_dict", value: "Dictionary(2)" }]);
		expect(variables).to.containSubset([{ name: "byte_array", value: "(4) [0, 1, 2, 255]" }]);
		expect(variables).to.containSubset([{ name: "int32_array", value: "(3) [100, 200, 300]" }]);
		expect(variables).to.containSubset([{ name: "color_var", value: "Color(1, 0, 0, 1)" }]);
		expect(variables).to.containSubset([{ name: "aabb_var", value: "AABB((0, 0, 0), (1, 1, 1))" }]);
		expect(variables).to.containSubset([{ name: "plane_var", value: "Plane(0, 1, 0, -5)" }]);
		expect(variables).to.containSubset([{ name: "callable_var", value: "Callable()" }]);
		expect(variables).to.containSubset([{ name: "signal_var" }]);
		const signal_var = variables.find((v) => v.name === "signal_var");
		expect(signal_var.value).to.match(
			/Signal\(member_signal\, <\d+>\)/,
			"Should be in format of 'Signal(member_signal, <28236055815>)'",
		);
	})?.timeout(10000);

	test("should retrieve all complex variables correctly", async () => {
		const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ExtensiveVars.gd"));
		const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ExtensiveVars::_ready"]);
		vscode.debug.addBreakpoints([breakpoint]);

		await startDebugging("ExtensiveVars.tscn");
		await waitForBreakpoint(breakpoint, 2000);

		// TODO: current DAP needs a delay before it will return variables
		console.log("Sleeping for 2 seconds");
		await sleep(2000);

		const memberVariables = await getVariablesForScope(VariableScope.Members);

		expect(memberVariables.length).to.equal(3, "Incorrect member variables count");
		expect(memberVariables).to.containSubset([{ name: "self" }]);
		expect(memberVariables).to.containSubset([{ name: "self_var" }]);
		expect(memberVariables).to.containSubset([{ name: "label" }]);
		const self = memberVariables.find((v) => v.name === "self");
		const self_var = memberVariables.find((v) => v.name === "self_var");
		expect(self.value).to.deep.equal(self_var.value);

		const localVariables = await getVariablesForScope(VariableScope.Locals);
		const expectedLocalVariables = [
			{ name: "local_label", value: /Label<\d+>/ },
			{ name: "local_self_var_through_label", value: /Node2D<\d+>/ },
			{ name: "local_classA", value: /RefCounted<\d+>/ },
			{ name: "local_classB", value: /RefCounted<\d+>/ },
			{ name: "str_var", value: /^ExtensiveVars::_ready::local::str_var$/ },
		];
		expect(localVariables.length).to.equal(expectedLocalVariables.length, "Incorrect local variables count");
		expect(localVariables).to.containSubset(expectedLocalVariables.map((v) => ({ name: v.name })));
		for (const expectedLocalVariable of expectedLocalVariables) {
			const localVariable = localVariables.find((v) => v.name === expectedLocalVariable.name);
			expect(localVariable).to.exist;
			expect(localVariable.value).to.match(
				expectedLocalVariable.value,
				`Variable '${expectedLocalVariable.name}' has incorrect value'`,
			);
		}
	})?.timeout(15000);
});
