from typing import ClassVar

from bs4 import BeautifulSoup

import frappe
from frappe.utils.data import cint
from frappe.utils.pdf import get_host_url
from frappe.utils.print_utils import convert_uom, parse_float_and_unit


class Browser:
	def __init__(self, generator, print_format, html, options):
		self.is_print_designer = frappe.get_cached_value("Print Format", print_format, "print_designer")
		self.debug_mode = frappe.conf.developer_mode and bool(frappe.form_dict.get("pdf_debug"))
		self.browserID = frappe.utils.random_string(10)
		generator.add_browser(self.browserID)
		# sets soup from html
		self.set_html(html)
		# sets wkhtmltopdf options
		self.set_options(options)
		# start cdp connection and create browser context ( kind of like new window / incognito mode)
		self.open(generator)
		# opens header and footer pages and sets content ( not waiting for it to load)
		self.prepare_header_footer()
		# opens body page and sets content and waits for it to finshing load
		self.setup_body_page()
		# prepare options as per chrome for pdf
		self.prepare_options_for_pdf()
		# generate header and footer pages if they are not dynamic ( first, odd, even, last)
		self.update_header_footer_page_pd()
		# if header and footer are not dynamic start generating pdf for them (non-blocking)
		self.try_async_header_footer_pdf()
		# now wait for page to load as we need DOM to generate pdf
		self.body_page.wait_for_set_content()
		self.body_pdf = self.body_page.generate_pdf(raw=not self.header_page and not self.footer_page)
		if not self.debug_mode:
			self.body_page.close()
		self.update_header_footer_page()

		if self.header_page:
			if not self.is_header_dynamic:
				self.header_pdf = self.header_page.get_pdf_from_stream(self.header_page.get_pdf_stream_id())
			else:
				self.header_pdf = self.header_page.generate_pdf()
			if not self.debug_mode:
				self.header_page.close()

		if self.footer_page:
			if not self.is_footer_dynamic:
				self.footer_pdf = self.footer_page.get_pdf_from_stream(self.footer_page.get_pdf_stream_id())
			else:
				self.footer_pdf = self.footer_page.generate_pdf()
			if not self.debug_mode:
				self.footer_page.close()

		if not self.debug_mode:
			self.close()

		generator.remove_browser(self.browserID)
		if self.debug_mode:
			generator.detach_debug_browser()

	def open(self, generator):
		from frappe.utils.pdf_generator.cdp_connection import CDPSocketClient

		# checking because if we share browser accross request _devtools_url will already be set for subsequent requests.
		if not generator._devtools_url:
			generator._set_devtools_url()
		# start the CDP websocket connection to browser
		self.session = CDPSocketClient(generator._devtools_url)

		self.session.connect()
		self.create_browser_context()

	def create_browser_context(self):
		# create browser context
		result, error = self.session.send("Target.createBrowserContext", {"disposeOnDetach": True})
		if error:
			frappe.log_error(title="Error creating browser context:", message=f"{error}")
		self.browser_context_id = result["browserContextId"]

	def set_html(self, html):
		self.soup = BeautifulSoup(html, "html5lib")

	def set_options(self, options):
		self.options = options

	def new_page(self, page_type):
		"""
		# create a new page in the browser inside browser context
		----
		TODO: Implement Deterministic rendering for headless-chrome via DevTools Protocol ( waiting for macos support )
		https://docs.google.com/document/d/1PppegrpXhOzKKAuNlP6XOEnviXFGUiX2hop00Cxcv4o/edit?tab=t.0#bookmark=id.dukbomwxpb3j

		NOTE: In theory this will make it faster but more importantly use less cpu, ram etc.
		"""

		from frappe.utils.pdf_generator.page import Page

		page = Page(self.session, self.browser_context_id, page_type)
		page.is_print_designer = self.is_print_designer

		return page

	def setup_body_page(self):
		self.body_page = self.new_page("body")
		self.body_page.set_tab_url(get_host_url())
		self.body_page.wait_for_navigate()
		self.body_page.set_content(str(self.soup))

	def close_page(self, type):
		page = getattr(self, f"{type}_page")
		page.close()

	def is_page_no_used(self, soup):
		# Check if any of the classes exist
		classes_to_check = [
			"page",
			"frompage",
			"topage",
			"page_info_page",
			"page_info_frompage",
			"page_info_topage",
		]

		# Loop through the classes to check
		for class_name in classes_to_check:
			if soup.find(class_=class_name):  # Check if any element with the class is found
				return True  # Return True if class is found

		return False

	def prepare_header_footer(self):
		# code is structured like this to improve performance by running commands in chrome as soon as possible.
		soup = self.soup
		options = self.options
		# open header and footer pages
		self._open_header_footer_pages()

		# get tags to pass to header template.
		head = soup.find("head").contents
		styles = soup.find_all("style")

		# set header and footer content ( not waiting for it to load yet).
		if self.header_page:
			self.header_page.wait_for_navigate()
			self.header_page.set_content(
				self.get_rendered_header_footer(self.header_content, "header", head, styles, css=[])
			)

		if self.footer_page:
			self.footer_page.wait_for_navigate()
			self.footer_page.set_content(
				self.get_rendered_header_footer(self.footer_content, "footer", head, styles, css=[])
			)
		if self.header_page:
			self.header_page.wait_for_set_content()
			self.header_height = self.header_page.get_element_height()
			self.is_header_dynamic = self.is_page_no_used(self.header_content)
			del self.header_content
		else:
			# bad implicit setting of margin #backwards-compatibility
			options["margin-top"] = "15mm"

		if self.footer_page:
			self.footer_page.wait_for_set_content()
			self.footer_height = self.footer_page.get_element_height()
			self.is_footer_dynamic = self.is_page_no_used(self.footer_content)
			del self.footer_content
		else:
			# bad implicit setting of margin #backwards-compatibility
			options["margin-bottom"] = "15mm"

		# Remove instances of them from main content for render_template
		for html_id in ["header-html", "footer-html"]:
			for tag in soup.find_all(id=html_id):
				tag.extract()

	def try_async_header_footer_pdf(self):
		if self.header_page and not self.is_header_dynamic:
			self.header_page.generate_pdf(wait_for_pdf=False)
		if self.footer_page and not self.is_footer_dynamic:
			self.footer_page.generate_pdf(wait_for_pdf=False)

	def _get_converted_num(self, num_str, unit="px"):
		parsed = parse_float_and_unit(num_str)
		if parsed:
			return convert_uom(parsed["value"], parsed["unit"], unit, only_number=True)

	def _parse_pdf_options_from_html(self):
		from frappe.utils.pdf import get_print_format_styles

		soup: BeautifulSoup = self.soup
		options = {}
		print_format_css = get_print_format_styles(soup)
		attrs = (
			"margin-top",
			"margin-bottom",
			"margin-left",
			"margin-right",
			"page-size",
			"header-spacing",
			"orientation",
			"page-width",
			"page-height",
		)
		options |= {style.name: style.value for style in print_format_css if style.name in attrs}
		self.options.update(options)

	def _set_default_page_size(self):
		options = self.options
		pdf_page_size = (
			options.get("page-size") or frappe.db.get_single_value("Print Settings", "pdf_page_size") or "A4"
		)

		if pdf_page_size == "Custom":
			options["page-height"] = options.get("page-height") or frappe.db.get_single_value(
				"Print Settings", "pdf_page_height"
			)
			options["page-width"] = options.get("page-width") or frappe.db.get_single_value(
				"Print Settings", "pdf_page_width"
			)
		else:
			options["page-size"] = pdf_page_size

	def prepare_options_for_pdf(self):
		self._parse_pdf_options_from_html()
		self._set_default_page_size()

		options = self.options

		updated_options = {
			"scale": 1,
			"printBackground": True,
			"transferMode": "ReturnAsStream",
			"marginTop": 0,
			"marginBottom": 0,
			"marginLeft": 0,
			"marginRight": 0,
			"landscape": options.get("orientation", "Portrait") == "Landscape",
			"preferCSSPageSize": False,
			"pageRanges": options.get("page-ranges", ""),
			# Experimental
			"generateTaggedPDF": options.get("generate-tagged-pdf", False),
			"generateOutline": options.get("generate-outline", False),
		}

		# bad implicit setting of margin #backwards-compatibility
		if not self.is_print_designer:
			if not options.get("margin-right"):
				options["margin-right"] = "15mm"

			if not options.get("margin-left"):
				options["margin-left"] = "15mm"

		if not options.get("page-height") or not options.get("page-width"):
			if not (page_size := self.options.get("page-size")):
				raise frappe.ValidationError("Page size is required")
			if page_size == "CUSTOM":
				raise frappe.ValidationError("Custom page size requires page-height and page-width")
			size = PageSize.get(page_size)
			if not size:
				raise frappe.ValidationError("Invalid page size")

			options["page-height"] = convert_uom(size["height"], "mm", "px", only_number=True)
			options["page-width"] = convert_uom(size["width"], "mm", "px", only_number=True)

		if isinstance(options["page-height"], str):
			options["page-height"] = self._get_converted_num(options["page-height"])

		if isinstance(options["page-width"], str):
			options["page-width"] = self._get_converted_num(options["page-width"])

		updated_options["paperWidth"] = convert_uom(options["page-width"], "px", "in", only_number=True)

		if options.get("margin-left"):
			updated_options["marginLeft"] = convert_uom(
				self._get_converted_num(options["margin-left"]), "px", "in", only_number=True
			)

		if options.get("margin-right"):
			updated_options["marginRight"] = convert_uom(
				self._get_converted_num(options["margin-right"]), "px", "in", only_number=True
			)

		# make copy of options to update them in header, body, footer.
		self.body_page.options = updated_options.copy()
		if self.header_page:
			self.header_page.options = updated_options.copy()
		if self.footer_page:
			self.footer_page.options = updated_options.copy()

		margin_top = self._get_converted_num(options.get("margin-top", 0))
		margin_bottom = self._get_converted_num(options.get("margin-bottom", 0))

		header_with_top_margin = 0
		header_with_spacing_top_margin = 0
		footer_with_bottom_margin = 0
		footer_height = 0

		if self.header_page:
			header_with_top_margin = self.header_height + margin_top
			header_spacing = options.get("header-spacing", 0)
			header_with_spacing_top_margin = header_with_top_margin + header_spacing
			self.header_page.options["paperHeight"] = (
				convert_uom(header_with_spacing_top_margin, "px", "in", only_number=True)
				if header_with_spacing_top_margin
				else 0
			)

		margin_top = convert_uom(margin_top, "px", "in", only_number=True)

		if self.header_page:
			self.header_page.options["marginTop"] = margin_top
		else:
			self.body_page.options["marginTop"] = margin_top

		if self.footer_page:
			footer_height = self.footer_height
			self.footer_page.options["paperHeight"] = (
				convert_uom(footer_height, "px", "in", only_number=True) if footer_height else 0
			)
			footer_with_bottom_margin = self.footer_height + margin_bottom

		margin_bottom = convert_uom(margin_bottom, "px", "in", only_number=True)

		if self.footer_page:
			self.footer_page.options["marginBottom"] = margin_bottom
		else:
			self.body_page.options["marginBottom"] = margin_bottom

		body_height = options.get("page-height") - (
			header_with_spacing_top_margin + footer_with_bottom_margin
		)

		"""
		matching scale for some old formats is 1.46 #backwards-compatibility ( scale 1 is better in my opinion)
		If we face issues in custom formats then only we should enable this.
		"""

		self.body_page.options["paperHeight"] = convert_uom(body_height, "px", "in", only_number=True)

	def get_rendered_header_footer(self, content, type, head, styles, css):
		from frappe.utils.pdf import toggle_visible_pdf

		html_id = f"{type}-html"
		content = content.extract()
		toggle_visible_pdf(content)
		id_map = {"header": "pdf_header_html", "footer": "pdf_footer_html"}
		hook_func = frappe.get_hooks(id_map.get(type))
		return frappe.call(
			hook_func[-1],
			soup=self.soup,
			head=head,
			content=content,
			styles=styles,
			html_id=html_id,
			css=css,
			path="templates/print_formats/chrome_pdf_header_footer.html",
		)

	def update_header_footer_page(self):
		if not self.header_page and not self.footer_page:
			return
		total_pages = len(self.body_pdf.pages)
		# function is added to html from update_page_no.js
		if self.header_page:
			if self.is_header_dynamic:
				self.header_page.evaluate(
					f"clone_and_update('{'#header-render-container' if self.is_print_designer else '.wrapper'}', {total_pages}, {1 if self.is_print_designer else 0}, 'Header', 1);",
					await_promise=True,
				)

		if self.footer_page:
			if self.is_footer_dynamic:
				self.footer_page.evaluate(
					f"clone_and_update('{'#footer-render-container' if self.is_print_designer else '.wrapper'}', {total_pages}, {1 if self.is_print_designer else 0}, 'Footer', 1);",
					await_promise=True,
				)

	def update_header_footer_page_pd(self):
		if not self.is_print_designer:
			return
		if not self.header_page and not self.footer_page:
			return
		# function is added to html from update_page_no.js
		if self.header_page and not self.is_header_dynamic:
			self.header_page.evaluate(
				"clone_and_update('#header-render-container', 0, 1, 'Header', 0);",
				await_promise=True,
			)

		if self.footer_page and not self.is_footer_dynamic:
			self.footer_page.evaluate(
				"clone_and_update('#footer-render-container', 0, 1, 'Footer', 0);",
				await_promise=True,
			)

	def _open_header_footer_pages(self):
		self.header_page = None
		self.footer_page = None
		# open new page for header/footer if they exist.
		# It sends CDP command to the browser to open a new tab.
		if header_content := self.soup.find(id="header-html"):
			self.header_page = self.new_page("header")
			self.header_page.set_tab_url(get_host_url())

		if footer_content := self.soup.find(id="footer-html"):
			self.footer_page = self.new_page("footer")
			self.footer_page.set_tab_url(get_host_url())

		self.header_content = header_content
		self.footer_content = footer_content

	def close(self):
		self.session.disconnect()


class PageSize:
	page_sizes: ClassVar[dict[str, tuple[int, int]]] = {
		"A10": (26, 37),
		"A1": (594, 841),
		"A0": (841, 1189),
		"A3": (297, 420),
		"A2": (420, 594),
		"A5": (148, 210),
		"A4": (210, 297),
		"A7": (74, 105),
		"A6": (105, 148),
		"A9": (37, 52),
		"A8": (52, 74),
		"B10": (44, 31),
		"B1+": (1020, 720),
		"B4": (353, 250),
		"B5": (250, 176),
		"B6": (176, 125),
		"B7": (125, 88),
		"B0": (1414, 1000),
		"B1": (1000, 707),
		"B2": (707, 500),
		"B3": (500, 353),
		"B2+": (720, 520),
		"B8": (88, 62),
		"B9": (62, 44),
		"C10": (40, 28),
		"C9": (57, 40),
		"C8": (81, 57),
		"C3": (458, 324),
		"C2": (648, 458),
		"C1": (917, 648),
		"C0": (1297, 917),
		"C7": (114, 81),
		"C6": (162, 114),
		"C5": (229, 162),
		"C4": (324, 229),
		"Legal": (216, 356),
		"Junior Legal": (127, 203),
		"Letter": (216, 279),
		"Tabloid": (279, 432),
		"Ledger": (432, 279),
		"ANSI C": (432, 559),
		"ANSI A (letter)": (216, 279),
		"ANSI B (ledger & tabloid)": (279, 432),
		"ANSI E": (864, 1118),
		"ANSI D": (559, 864),
	}

	@classmethod
	def get(cls, name):
		if name in cls.page_sizes:
			width, height = cls.page_sizes[name]
			return {"width": width, "height": height}
		else:
			return None  # Return None if the page size is not found
