import os
import re
from typing import Literal

import click

import frappe
from frappe.utils.data import cint, cstr

EXECUTABLE_PATHS = {
	"linux": ["chrome-linux", "headless_shell"],
	"darwin": ["chrome-mac", "headless_shell"],
	"windows": ["chrome-win", "headless_shell.exe"],
}


def get_print(
	doctype=None,
	name=None,
	print_format=None,
	style=None,
	as_pdf=False,
	doc=None,
	output=None,
	no_letterhead=0,
	password=None,
	pdf_options=None,
	letterhead=None,
	pdf_generator: Literal["wkhtmltopdf", "chrome"] | None = None,
):
	"""Get Print Format for given document.
	:param doctype: DocType of document.
	:param name: Name of document.
	:param print_format: Print Format name. Default 'Standard',
	:param style: Print Format style.
	:param as_pdf: Return as PDF. Default False.
	:param password: Password to encrypt the pdf with. Default None
	:param pdf_generator: PDF generator to use. Default 'wkhtmltopdf'
	"""

	"""
	local.form_dict.pdf_generator is set from before_request hook (print designer app) for download_pdf endpoint
	if it is not set (internal function call) then set it
	"""
	import copy

	from frappe.utils.pdf import get_pdf
	from frappe.website.serve import get_response_without_exception_handling

	local = frappe.local
	if "pdf_generator" not in local.form_dict:
		# if arg is passed, use that, else get setting from print format
		if pdf_generator is None:
			pdf_generator = (
				frappe.get_cached_value("Print Format", print_format, "pdf_generator") or "wkhtmltopdf"
			)
		local.form_dict.pdf_generator = pdf_generator

	original_form_dict = copy.deepcopy(local.form_dict)
	try:
		local.form_dict.doctype = doctype
		local.form_dict.name = name
		local.form_dict.format = print_format
		local.form_dict.style = style
		local.form_dict.doc = doc
		local.form_dict.no_letterhead = no_letterhead
		local.form_dict.letterhead = letterhead

		pdf_options = pdf_options or {}
		if password:
			pdf_options["password"] = password

		response = get_response_without_exception_handling("printview", 200)
		html = str(response.data, "utf-8")
	finally:
		local.form_dict = original_form_dict

	if not as_pdf:
		return html

	if local.form_dict.pdf_generator != "wkhtmltopdf":
		hook_func = frappe.get_hooks("pdf_generator")
		for hook in hook_func:
			"""
			check pdf_generator value in your hook function.
			if it matches run and return pdf else return None
			"""
			pdf = frappe.call(
				hook,
				print_format=print_format,
				html=html,
				options=pdf_options,
				output=output,
				pdf_generator=local.form_dict.pdf_generator,
			)
			# if hook returns a value, assume it was the correct pdf_generator and return it
			if pdf:
				if output and isinstance(pdf, bytes):
					from io import BytesIO

					from pypdf import PdfReader

					reader = PdfReader(BytesIO(pdf))
					for page in reader.pages:
						output.add_page(page)
					return output
				return pdf

	for hook in frappe.get_hooks("on_print_pdf"):
		frappe.call(hook, doctype=doctype, name=name, print_format=print_format)

	return get_pdf(html, options=pdf_options, output=output)


def attach_print(
	doctype,
	name,
	file_name=None,
	print_format=None,
	style=None,
	html=None,
	doc=None,
	lang=None,
	print_letterhead=True,
	password=None,
	letterhead=None,
):
	from frappe.translate import print_language
	from frappe.utils import scrub_urls
	from frappe.utils.pdf import get_pdf

	print_settings = frappe.db.get_singles_dict("Print Settings")
	if print_letterhead and not letterhead:
		letterhead = frappe.get_cached_value("Letter Head", {"is_default": 1}, "name")
	kwargs = dict(
		print_format=print_format,
		style=style,
		doc=doc,
		no_letterhead=not print_letterhead,
		letterhead=letterhead,
		password=password,
	)

	frappe.local.flags.ignore_print_permissions = True

	is_weasyprint_print_format = False
	if print_format and print_format != "Standard":
		print_format_doc = frappe.get_cached_doc("Print Format", print_format)
		is_weasyprint_print_format = not (
			print_format_doc.custom_format
			or print_format_doc.get("print_format_builder")
			or print_format_doc.get("print_designer_print_format")
		)

	with print_language(lang or frappe.local.lang):
		content = ""
		if cint(print_settings.send_print_as_pdf):
			ext = ".pdf"
			if html:
				content = get_pdf(html, options={"password": password} if password else None)
			elif is_weasyprint_print_format:
				from frappe.utils.weasyprint import PrintFormatGenerator

				doc_obj = doc or frappe.get_cached_doc(doctype, name)
				letterhead_name = letterhead if print_letterhead else None
				generator = PrintFormatGenerator(print_format, doc_obj, letterhead_name)
				content = generator.render_pdf()
			else:
				kwargs["as_pdf"] = True
				content = get_print(doctype, name, **kwargs)
		else:
			ext = ".html"
			content = html or scrub_urls(get_print(doctype, name, **kwargs)).encode("utf-8")

	frappe.local.flags.ignore_print_permissions = False

	if not file_name:
		file_name = name
	file_name = cstr(file_name).replace(" ", "").replace("/", "-") + ext

	return {"fname": file_name, "fcontent": content}


def setup_chromium():
	"""Setup Chromium at the bench level."""
	# Load Chromium version from common_site_config.json or use default

	try:
		executable = find_or_download_chromium_executable()
		click.echo(f"Chromium is already set up at {executable}")
	except Exception as e:
		click.echo(f"Failed to setup Chromium: {e}")
		raise RuntimeError(f"Failed to setup Chromium: {e}")
	return executable


def find_or_download_chromium_executable():
	"""Finds the Chromium executable or downloads if not found."""
	import platform
	import shutil
	from pathlib import Path

	if chromium_path := shutil.which(frappe.get_common_site_config().get("chromium_path", "")):
		return chromium_path

	bench_path = frappe.utils.get_bench_path()
	"""Determine the path to the Chromium executable."""
	chromium_dir = os.path.join(bench_path, "chromium")

	platform_name = platform.system().lower()

	if platform_name not in ["linux", "darwin", "windows"]:
		click.echo(f"Unsupported platform: {platform_name}")

	executable_name = EXECUTABLE_PATHS.get(platform_name)

	# Construct the full path to the executable
	exec_path = Path(chromium_dir).joinpath(*executable_name)
	if not exec_path.exists():
		click.echo("Chromium is not available. downloading...")
		download_chromium()

	if not exec_path.exists():
		click.echo("Error while downloading chrome")

	return str(exec_path)


def download_chromium():
	import platform
	import shutil
	import zipfile

	import requests

	bench_path = frappe.utils.get_bench_path()
	"""Download and extract Chromium for the specific version at the bench level."""
	chromium_dir = os.path.join(bench_path, "chromium")

	# Remove old Chromium directory if it exists
	if os.path.exists(chromium_dir):
		click.echo("Removing old Chromium directory...")
		shutil.rmtree(chromium_dir, ignore_errors=True)

	os.makedirs(chromium_dir, exist_ok=True)

	download_url = get_chromium_download_url()
	file_name = os.path.basename(download_url)
	zip_path = os.path.join(chromium_dir, file_name)

	try:
		click.echo(f"Downloading Chromium from {download_url}...")
		# playwright's requires a user agent
		headers = {"User-Agent": "Wget/1.21.1"}
		with requests.get(download_url, stream=True, timeout=(10, 60), headers=headers) as r:
			r.raise_for_status()  # Raise an error for bad status codes
			total_size = int(r.headers.get("content-length", 0))  # Get total file size
			bar = click.progressbar(length=total_size, label="Downloading Chromium")
			with open(zip_path, "wb") as f:
				for chunk in r.iter_content(chunk_size=65536):
					f.write(chunk)
					bar.update(len(chunk))

		click.echo("Extracting Chromium...")
		with zipfile.ZipFile(zip_path, "r") as zip_ref:
			zip_ref.extractall(chromium_dir)

		if os.path.exists(zip_path):
			os.remove(zip_path)

		# There should be only one directory
		# Ensure the correct directory is renamed
		extracted = os.listdir(chromium_dir)[0]
		executable_path = EXECUTABLE_PATHS[platform.system().lower()]
		chrome_folder_name = executable_path[0]

		if extracted != chrome_folder_name:
			extracted_dir = os.path.join(chromium_dir, extracted)
			renamed_dir = os.path.join(chromium_dir, chrome_folder_name)
			if os.path.exists(extracted_dir):
				click.echo(f"Renaming {extracted_dir} to {renamed_dir}")
				os.rename(extracted_dir, renamed_dir)
			else:
				raise RuntimeError(f"Failed to rename extracted directory. Expected {chrome_folder_name}.")
			if os.path.exists(renamed_dir):
				executable_shell = os.path.join(renamed_dir, "chrome-headless-shell")
				if os.path.exists(executable_shell):
					os.rename(executable_shell, os.path.join(renamed_dir, "headless_shell"))
				else:
					raise RuntimeError("Failed to rename executable. Expected chrome-headless-shell.")
			# Make the `headless_shell` executable
			exec_path = os.path.join(renamed_dir, executable_path[1])
			make_chromium_executable(exec_path)

		click.echo(f"Chromium is ready to use at: {chromium_dir}")
	except requests.Timeout:
		click.echo("Download timed out. Check your internet connection.")
		raise RuntimeError("Download timed out.")
	except requests.ConnectionError:
		click.echo("Failed to connect to Chromium download server.")
		raise RuntimeError("Connection error.")
	except requests.RequestException as e:
		click.echo(f"Failed to download Chromium: {e}")
		raise RuntimeError(f"Failed to download Chromium: {e}")
	except zipfile.BadZipFile as e:
		click.echo(f"Failed to extract Chromium: {e}")
		raise RuntimeError(f"Failed to extract Chromium: {e}")


def get_chromium_download_url():
	# Avoid this unless it is going to run on a single type of platform and you have the correct binary hosted.
	common_config = frappe.get_common_site_config()

	chrome_download_url = common_config.get("chromium_download_url", None)

	if chrome_download_url:
		return chrome_download_url

	"""
	We are going to use chrome-for-testing builds but unfortunately it doesn't have linux arm64 https://github.com/GoogleChromeLabs/chrome-for-testing/issues/1
	so we will use playwright's fallback builds for linux arm64
	TODO: we will also use the fallback builds for windows arm
	https://community.arm.com/arm-community-blogs/b/tools-software-ides-blog/posts/native-chromium-builds-windows-on-arm
	"""
	"""
	To find the CHROME_VERSION AND CHROME_FALLBACK_VERSION, follow these steps:
	1. Visit the GitHub Actions page for Playwright: https://github.com/microsoft/playwright/actions/workflows/roll_browser_into_playwright.yml
	2. Open the latest job run.
	3. Navigate to the "Roll to New Browser Version" step.
	4. In the logs, look for a line similar to:
		Downloading Chromium 133.0.6943.16 (playwright build v1155)
		Here, the first number (e.g., 133.0.6943.16) is the CHROME_VERSION, and the second number (e.g., 1155) is the CHROME_FALLBACK_VERSION.
	"""
	# Using Google's chrome-for-testing-public builds for most platforms. (close to end user experience)
	# For Linux ARM64, we use Playwright's Chromium builds due to the lack of official support.

	download_path = {
		"linux64": "%s/linux64/chrome-headless-shell-linux64.zip",
		"mac-arm64": "%s/mac-arm64/chrome-headless-shell-mac-arm64.zip",
		"mac-x64": "%s/mac-x64/chrome-headless-shell-mac-x64.zip",
		"win32": "%s/win32/chrome-headless-shell-win32.zip",
		"win64": "%s/win64/chrome-headless-shell-win64.zip",
	}
	linux_arm_download_path = {
		"ubuntu20.04-arm64": "%s/chromium-headless-shell-linux-arm64.zip",
		"ubuntu22.04-arm64": "%s/chromium-headless-shell-linux-arm64.zip",
		"ubuntu24.04-arm64": "%s/chromium-headless-shell-linux-arm64.zip",
		"debian11-arm64": "%s/chromium-headless-shell-linux-arm64.zip",
		"debian12-arm64": "%s/chromium-headless-shell-linux-arm64.zip",
	}

	platform_key = calculate_platform()

	version = "133.0.6943.35"
	playwright_build_version = "1157"

	base_url = "https://storage.googleapis.com/chrome-for-testing-public/"
	playwright_base_url = "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/"

	# Overwrite with values from common_site_config.json ( escape hatch )
	version = common_config.get("chromium_version", version)
	playwright_build_version = common_config.get("playwright_chromium_version", playwright_build_version)
	# make sure that you have all required flavours at correct urls
	base_url = common_config.get("chromium_download_base_url", base_url)
	playwright_base_url = common_config.get("playwright_chromium_download_base_url", playwright_base_url)

	if platform_key in download_path:
		relative_path = download_path[platform_key]
	elif platform_key in linux_arm_download_path:
		version = playwright_build_version
		base_url = playwright_base_url
		relative_path = linux_arm_download_path[platform_key]
	else:
		frappe.throw(
			f"No download path configured or Chromium download not available for platform: {platform_key}"
		)

	return f"{base_url}{relative_path % version}"


def make_chromium_executable(executable):
	"""Make the Chromium executable."""
	if os.path.exists(executable):
		# check if the file is executable
		if os.access(executable, os.X_OK):
			click.echo(f"Chromium executable is already executable: {executable}")
			return
		click.echo(f"Making Chromium executable: {executable}")
		os.chmod(executable, 0o755)  # Set executable permissions
		click.echo(f"Chromium executable permissions set: {executable}")
	else:
		raise RuntimeError(f"Chromium executable not found: {executable}.")


def calculate_platform():
	"""
	Determines the host platform and returns it as a string.
	Includes logic for Linux ARM, Linux x64, macOS (Intel and ARM), and Windows (32-bit and 64-bit).

	Returns:
	        str: The detected platform string (e.g., 'linux64', 'mac-arm64', etc.).
	"""
	import platform

	system = platform.system().lower()
	arch = platform.machine().lower()

	# Handle Linux ARM-specific logic
	if system == "linux" and arch == "aarch64":
		distro_info = get_linux_distribution_info()
		distro_id = distro_info.get("id", "")
		version = distro_info.get("version", "")
		major_version = int(version.split(".")[0]) if version else 0

		if distro_id == "ubuntu":
			if major_version < 20:
				return "ubuntu18.04-arm64"
			if major_version < 22:
				return "ubuntu20.04-arm64"
			if major_version < 24:
				return "ubuntu22.04-arm64"
			if major_version < 26:
				return "ubuntu24.04-arm64"
			return "<unknown>"

		if distro_id in ["debian", "raspbian"]:
			if major_version < 11:
				return "debian10-arm64"
			if major_version < 12:
				return "debian11-arm64"
			return "debian12-arm64"
		return "<unknown>"

	# Handle other platforms
	elif system == "linux" and arch == "x86_64":
		return "linux64"
	elif system == "darwin" and arch == "arm64":
		return "mac-arm64"
	elif system == "darwin" and arch == "x86_64":
		return "mac-x64"
	elif system == "windows" and arch == "x86":
		return "win32"
	elif system == "windows" and arch == "x86_64":
		return "win64"

	return "<unknown>"


def get_linux_distribution_info():
	# not tested
	"""Retrieve Linux distribution information using the `distro` library."""
	import distro

	if not distro:
		return {"id": "", "version": ""}

	return {"id": distro.id().lower(), "version": distro.version()}


def parse_float_and_unit(input_text, default_unit="px"):
	if isinstance(input_text, int | float):
		return {"value": input_text, "unit": default_unit}
	if not isinstance(input_text, str):
		return

	number = float(re.search(r"[+-]?([0-9]*[.])?[0-9]+", input_text).group())
	valid_units = [r"px", r"mm", r"cm", r"in"]
	unit = [match.group() for rx in valid_units if (match := re.search(rx, input_text))]

	return {"value": number, "unit": unit[0] if len(unit) == 1 else default_unit}


def convert_uom(
	number: float,
	from_uom: Literal["px", "mm", "cm", "in"] = "px",
	to_uom: Literal["px", "mm", "cm", "in"] = "px",
	only_number: bool = False,
) -> float:
	unit_values = {
		"px": 1,
		"mm": 3.7795275591,
		"cm": 37.795275591,
		"in": 96,
	}
	from_px = (
		{
			"to_px": 1,
			"to_mm": unit_values["px"] / unit_values["mm"],
			"to_cm": unit_values["px"] / unit_values["cm"],
			"to_in": unit_values["px"] / unit_values["in"],
		},
	)
	from_mm = (
		{
			"to_mm": 1,
			"to_px": unit_values["mm"] / unit_values["px"],
			"to_cm": unit_values["mm"] / unit_values["cm"],
			"to_in": unit_values["mm"] / unit_values["in"],
		},
	)
	from_cm = (
		{
			"to_cm": 1,
			"to_px": unit_values["cm"] / unit_values["px"],
			"to_mm": unit_values["cm"] / unit_values["mm"],
			"to_in": unit_values["cm"] / unit_values["in"],
		},
	)
	from_in = {
		"to_in": 1,
		"to_px": unit_values["in"] / unit_values["px"],
		"to_mm": unit_values["in"] / unit_values["mm"],
		"to_cm": unit_values["in"] / unit_values["cm"],
	}
	converstion_factor = ({"from_px": from_px, "from_mm": from_mm, "from_cm": from_cm, "from_in": from_in},)
	if only_number:
		return round(number * converstion_factor[0][f"from_{from_uom}"][0][f"to_{to_uom}"], 3)
	return f"{round(number * converstion_factor[0][f'from_{from_uom}'][0][f'to_{to_uom}'], 3)}{to_uom}"
