import datetime
import json
import logging
import os
import unittest
from collections.abc import Sequence
from pathlib import Path
from typing import Any

import frappe
from frappe.model.base_document import BaseDocument
from frappe.utils import cint

logger = logging.Logger(__file__)

datetime_like_types = (datetime.datetime, datetime.date, datetime.time, datetime.timedelta)


class BaseTestCase:
	@classmethod
	def registerAs(cls, _as):
		def decorator(cm_func):
			setattr(cls, cm_func.__name__, _as(cm_func))
			return cm_func

		return decorator


class UnitTestCase(unittest.TestCase, BaseTestCase):
	"""Unit test class for Frappe tests.

	This class extends unittest.TestCase and provides additional utilities
	specific to Frappe framework. It's designed for testing individual
	components or functions in isolation.

	Key features:
	- Custom assertions for Frappe-specific comparisons
	- Utilities for HTML and SQL normalization
	- Context managers for user switching and time freezing

	Note: If you override `setUpClass`, make sure to call `super().setUpClass()`
	to maintain the functionality of this base class.
	"""

	@classmethod
	def setUpClass(cls) -> None:
		if getattr(cls, "_unit_test_case_class_setup_done", None):
			return
		super().setUpClass()
		cls.doctype = _get_doctype_from_module(cls)
		cls.module = frappe.get_module(cls.__module__)
		# Test Environment
		frappe.set_user("Administrator")
		# Test Environment (cleanup)
		cls.addClassCleanup(frappe.set_user, "Administrator")
		cls._unit_test_case_class_setup_done = True

	def assertQueryEqual(self, first: str, second: str) -> None:
		self.assertEqual(self.normalize_sql(first), self.normalize_sql(second))

	def assertSequenceSubset(self, larger: Sequence, smaller: Sequence, msg: str | None = None) -> None:
		"""Assert that `expected` is a subset of `actual`."""
		self.assertTrue(set(smaller).issubset(set(larger)), msg=msg)

	# --- Frappe Framework specific assertions
	def assertDocumentEqual(self, expected: dict | BaseDocument, actual: BaseDocument) -> None:
		"""Compare a (partial) expected document with actual Document."""

		if isinstance(expected, BaseDocument):
			expected = expected.as_dict()

		for field, value in expected.items():
			if isinstance(value, list):
				actual_child_docs = actual.get(field)
				self.assertEqual(len(value), len(actual_child_docs), msg=f"{field} length should be same")
				for exp_child, actual_child in zip(value, actual_child_docs, strict=False):
					self.assertDocumentEqual(exp_child, actual_child)
			else:
				self._compare_field(value, actual.get(field), actual, field)

	def _compare_field(self, expected: Any, actual: Any, doc: BaseDocument, field: str) -> None:
		msg = f"{field} should be same."

		if isinstance(expected, float):
			precision = doc.precision(field)
			self.assertAlmostEqual(
				expected, actual, places=precision, msg=f"{field} should be same to {precision} digits"
			)
		elif isinstance(expected, bool | int):
			self.assertEqual(expected, cint(actual), msg=msg)
		elif isinstance(expected, datetime_like_types) or isinstance(actual, datetime_like_types):
			self.assertEqual(str(expected), str(actual), msg=msg)
		else:
			self.assertEqual(expected, actual, msg=msg)

	@staticmethod
	def normalize_html(code: str) -> str:
		"""Formats HTML consistently so simple string comparisons can work on them."""
		from bs4 import BeautifulSoup

		return BeautifulSoup(code, "html.parser").prettify(formatter=None)

	@staticmethod
	def normalize_sql(query: str) -> str:
		"""Formats SQL consistently so simple string comparisons can work on them."""
		import sqlparse

		if frappe.db.db_type == "postgres":
			query = query.replace("`", '"')
		return sqlparse.format(query.strip(), keyword_case="upper", reindent=True, strip_comments=True)


def _get_doctype_from_module(cls):
	namespace = cls.__module__.split(".")
	path = frappe.get_pymodule_path(cls.__module__)
	try:
		doctype_index = namespace.index("doctype")
		doctype_snake_case = namespace[doctype_index + 1]
		# need to check json spec: todo -> ToDo (not Todo); not inferable
		json_file_path = Path(path).joinpath(f"{doctype_snake_case}.json")
		if json_file_path.is_file():
			doctype_data = json.loads(json_file_path.read_text())
			return doctype_data.get("name")
	except (ValueError, IndexError):
		# 'doctype' not found in module_path
		pass
	return None
