# test_runner.py

import pytest
import json
import os
import sys
from typing import Optional, Any, Dict, List
from _pytest.reports import TestReport  # For type hinting


def _unload_modules_for_path(path):
	"""
	Remove from sys.modules any entry whose __file__ is exactly `path`,
	or lives in the same directory tree as `path`. This ensures that
	pytest re-imports from disk, not from any cached module.
	"""
	abspath = os.path.abspath(path)
	dirpath = os.path.dirname(abspath)

	to_delete = []
	for mod_name, module in list(sys.modules.items()):
		# Some modules lack __file__ (like builtins); skip those.
		mod_file = getattr(module, "__file__", None)
		if not mod_file:
			continue

		try:
			mod_abspath = os.path.abspath(mod_file)
		except Exception:
			continue

		# If the module file exactly matches the test file, unload it.
		# Or if it's inside the same directory (e.g. a local helper), unload that too.
		if mod_abspath == abspath or mod_abspath.startswith(dirpath + os.sep):
			to_delete.append(mod_name)

	for name in to_delete:
		del sys.modules[name]


class TestRunResultPlugin:
	"""A pytest plugin to capture the results of a single test run."""
	def __init__(self):
		self.nodeid: Optional[str] = None
		self.status: Optional[str] = None # "passed", "failed", "skipped"
		self.reason: Optional[str] = None   # Failure message or skip reason
		self.traceback_str: Optional[str] = None
		self.duration: float = 0.0
		self.sections: Dict[str, str] = {} # Captured stdout, stderr, log
		self._current_reports: List[TestReport] = []

	def pytest_runtest_protocol(self, item: pytest.Item, nextitem: Optional[pytest.Item]):
		self.nodeid = item.nodeid # Capture the nodeid of the test being run
		return None # continue with the default protocol

	def pytest_report_teststatus(self, report: TestReport, config):
		# This hook is called to determine the status character (., F, s, etc.)
		# We can also inspect the report here if needed, but logreport is more common for details.
		pass

	def pytest_runtest_logreport(self, report: TestReport):
		"""
		Process test reports (setup, call, teardown).
		We are primarily interested in the 'call' phase for the test status.
		"""
		self._current_reports.append(report) # Store all reports for the item

		if report.nodeid == self.nodeid and report.when == "call": # Ensure it's the 'call' phase of the current test
			self.status = report.outcome
			self.duration = report.duration
			self.sections = dict(report.sections) # (stdout, stderr, log)

			if report.failed:
				self.reason = "Unknown failure" # Default
				if report.longrepr:
					if hasattr(report.longrepr, 'reprcrash') and report.longrepr.reprcrash:  # type: ignore
						self.reason = str(report.longrepr.reprcrash.message)  # type: ignore
					elif isinstance(report.longrepr, str): # Fallback for simpler string repr
						self.reason = report.longrepr.strip().split('\n')[-1]
					self.traceback_str = str(report.longrepr) # Full traceback string
				else:
					self.traceback_str = "No traceback available."
			elif report.skipped:
				self.reason = "Skipped" # Default
				if isinstance(report.longrepr, tuple) and len(report.longrepr) == 3:
					# For skips, longrepr is often (filepath, lineno, skip_reason_string)
					self.reason = report.longrepr[2]
				elif isinstance(report.longrepr, str):
					self.reason = report.longrepr
				self.traceback_str = f"Skipped: {self.reason}"
			elif report.passed:
				self.reason = None
				self.traceback_str = None

		# You might also want to capture setup/teardown failures if they are important
		# for your UI (e.g., if setup fails, the test itself won't run).
		elif report.nodeid == self.nodeid and report.failed and (report.when == "setup" or report.when == "teardown"):
			if self.status not in ["failed", "skipped"]: # Only override if not already failed/skipped by 'call'
				self.status = "error" # or "failed_setup", "failed_teardown"
				self.duration += report.duration # Add setup/teardown duration
				self.reason = f"Failure during {report.when}: {getattr(report.longrepr, 'reprcrash.message', str(report.longrepr))}"
				self.traceback_str = str(report.longrepr)


	def get_results(self) -> Dict[str, Any]:
		return {
			"nodeid": self.nodeid,
			"status": self.status,
			"reason": self.reason,
			"traceback": self.traceback_str,
			"duration": self.duration,
			"stdout": self.sections.get("stdout", ""),
			"stderr": self.sections.get("stderr", ""),
			"log": self.sections.get("log", ""),
			"all_reports": self._current_reports # For more detailed debugging if needed
		}

	def reset(self):
		"""Reset fields for the next individual test run."""
		self.nodeid = None
		self.status = None
		self.reason = None
		self.traceback_str = None
		self.duration = 0.0
		self.sections = {}
		self._current_reports = []


def run_test(node_id: str, verbosity_args: Optional[List[str]] = None) -> Dict[str, Any]:
	"""
	Runs a single test identified by its nodeid and returns detailed results.

	Args:
		node_id: The unique nodeid of the test to run (e.g., "test_file.py::test_function").
		verbosity_args: Optional list of pytest args for verbosity like ["--tb=long"].

	Returns:
		A dictionary containing the test results.
	"""

	if not hasattr(sys.stdout, "isatty"):
		sys.stdout.isatty = lambda: False

	result_plugin = TestRunResultPlugin()
	result_plugin.reset() # Ensure it's clean

	parts = node_id.split("::", 1)
	if len(parts) == 2:
		file_path, _ = parts
	else:
		# If no "::" present, treat the entire string as file_path
		file_path = node_id

	# Compute absolute path to the test file
	abspath = os.path.abspath(file_path)

	pytest_args = [
		node_id, # Specify the exact test to run
		"-q"		 # Quiet mode for less pytest console noise
		# "-s"  # Add if you want to see print statements from the test itself (captured output)
		# "--capture=no"  # Add for live print statements (though plugin captures sections)
	]
	if verbosity_args:
		pytest_args.extend(verbosity_args)

	# Running pytest with the CWD as the test file's directory can help resolve the nodeid
	original_cwd = os.getcwd()

	dirpath = os.path.dirname(abspath)
	print("*******", __file__)
	if dirpath not in sys.path:
		sys.path.insert(0, dirpath)
	try:
		os.chdir(dirpath)
	except Exception:
		pass

	_unload_modules_for_path(abspath)

	try:
		# Add "-s" if you want to see print statements from the test itself (captured output)
		# Add "--capture=no" for live print statements (though plugin captures sections)
		pytest.main(pytest_args, plugins=[result_plugin])
	finally:
		os.chdir(original_cwd)

	result = result_plugin.get_results()

	if result['status'] == 'failed':
		full_traceback = result['traceback']
		# Now, parse the `full_traceback` string. It's much simpler than parsing
		# the entire pytest console output.
		# For example, to get the info you want from the traceback:

		import re
		fileName = os.path.basename(file_path)
		# Find the line number in the test file (the first file:line in the trace)
		# This pattern looks for a file path ending in .py, a colon, and a number.
		match = re.search(fileName + r":(\d+)", full_traceback)
		if match:
			line_number = int(match.group(1))
			result["lineNumber"] = line_number
			# print(f"Failure Location: {file_path} at line {line_number}")

	# print("__result", result)
	return result


# If someone calls this file as a script, you can still do basic tests
if __name__ == "__main__":
	import sys
	if len(sys.argv) < 2:
		node_id = "/Users/georg/Library/Application Support/Glyphs 4/Scripts/UnitTest/UnitTestKopie.py::test_GSFont_filepath"
	else:
		node_id = sys.argv[1]
	res = run_test(node_id)
	print("____******______")
	print(json.dumps(res))
