from urllib.parse import unquote

import frappe
from frappe import _

from jey_theme import desk_layout

BLOCKED_MODULES: frozenset[str] = frozenset({
	"Quality Management",
	"Subcontracting",
})

BLOCKED_DOCTYPES_EXTRA: frozenset[str] = frozenset({
	"Quality Inspection",
	"Quality Inspection Template",
	"Quality Inspection Parameter",
	"Quality Inspection Reading",
})


def _slugify(name: str) -> str:
	return name.lower().replace(" ", "-")


def get_blocked_doctypes() -> frozenset[str]:
	"""Doctypes blocked by the static module list (Quality/Subcontracting).

	NOT the config-driven jey_layout block — that is enforced via the permission
	hooks (has_permission + permission_query_conditions) keyed off
	desk_layout.prod_blocked_doctypes(), so it doesn't go through before_request
	(which would break search_link / dynamic links for core doctypes)."""
	cached = getattr(frappe.local, "_jey_blocked_doctypes", None)
	if cached is not None:
		return cached
	doctypes = set(BLOCKED_DOCTYPES_EXTRA)
	if BLOCKED_MODULES:
		try:
			rows = frappe.get_all(
				"DocType",
				filters={"module": ("in", list(BLOCKED_MODULES))},
				pluck="name",
			)
			doctypes.update(rows)
		except Exception:
			pass
	result = frozenset(doctypes)
	frappe.local._jey_blocked_doctypes = result
	return result


def get_blocked_reports() -> frozenset[str]:
	# Config-driven, prod-gated (blocked only where jey_layout is NOT installed).
	return desk_layout.prod_blocked_reports()


def get_blocked_pages() -> frozenset[str]:
	return desk_layout.prod_blocked_pages()


def is_blocked_report(report: str) -> bool:
	return report in get_blocked_reports()


def get_blocked_workspace_names() -> frozenset[str]:
	cached = getattr(frappe.local, "_jey_blocked_workspaces", None)
	if cached is not None:
		return cached
	names: set[str] = set(desk_layout.prod_blocked_workspaces())
	try:
		rows = frappe.get_all(
			"Workspace",
			filters={"module": ("in", list(BLOCKED_MODULES))},
			pluck="name",
		)
		names.update(rows)
	except Exception:
		pass
	result = frozenset(names)
	frappe.local._jey_blocked_workspaces = result
	return result


def get_blocked_slugs() -> frozenset[str]:
	cached = getattr(frappe.local, "_jey_blocked_slugs", None)
	if cached is not None:
		return cached
	slugs = {_slugify(dt) for dt in get_blocked_doctypes()}
	for w in get_blocked_workspace_names():
		slugs.add(_slugify(w))
	for p in get_blocked_pages():
		slugs.add(_slugify(p))
	result = frozenset(slugs)
	frappe.local._jey_blocked_slugs = result
	return result


def is_blocked_doctype(doctype: str) -> bool:
	return doctype in get_blocked_doctypes()


def _deny():
	frappe.throw(_("Not Permitted"), frappe.PermissionError)


def _safe_log(message: str):
	try:
		frappe.log_error(message, "Jey Access Control")
	except Exception:
		pass


def before_request():
	"""Hot-path guard — never raise except the intended PermissionError block."""
	try:
		_before_request_impl()
	except frappe.PermissionError:
		raise
	except Exception:
		_safe_log("before_request failed")


def _before_request_impl():
	"""HTTP-level block. Works even for Administrator (who bypasses has_permission).
	Covers /api/resource/<Doctype>, /app/<slug>, and any whitelisted method that
	passes `doctype` in form_dict."""
	req = getattr(frappe.local, "request", None)
	if req is None:
		return

	path = getattr(req, "path", "") or ""

	if path.startswith("/api/resource/"):
		remainder = path[len("/api/resource/"):]
		slug = remainder.split("/", 1)[0]
		doctype = unquote(slug)
		if is_blocked_doctype(doctype):
			_deny()

	if path.startswith("/app/query-report/"):
		remainder = path[len("/app/query-report/"):]
		report = unquote(remainder.split("/", 1)[0].split("?", 1)[0])
		if report and is_blocked_report(report):
			_deny()

	if path.startswith("/app/"):
		remainder = path[len("/app/"):]
		first_segment = remainder.split("/", 1)[0].split("?", 1)[0].lower()
		if first_segment and first_segment in get_blocked_slugs():
			_deny()

	form_dict = getattr(frappe.local, "form_dict", None)
	if form_dict:
		for key in ("doctype", "dt", "parent_doctype"):
			value = form_dict.get(key)
			if isinstance(value, str) and is_blocked_doctype(value):
				_deny()
		# Report data endpoints (frappe.desk.query_report.run / export_query)
		report_name = form_dict.get("report_name")
		if isinstance(report_name, str) and is_blocked_report(report_name):
			_deny()


def has_permission(doc=None, ptype=None, user=None, debug=False):
	"""Hot-path hook — never raise; on unexpected error fail open (allow)."""
	try:
		return _has_permission_impl(doc, ptype, user, debug)
	except Exception:
		_safe_log("has_permission failed")
		return True


def _has_permission_impl(doc=None, ptype=None, user=None, debug=False):
	"""Per-doc hook. Applies to non-Administrator users that might otherwise have role access.

	IMPORTANT: Frappe treats a falsy return (None/False/0) as a denial — see
	`has_controller_permissions` in frappe/permissions.py. Controllers can only
	*deny*, never grant — so for the pass-through case we MUST return True."""
	doctype = None
	if doc is not None:
		doctype = getattr(doc, "doctype", None)
		if doctype is None and isinstance(doc, str):
			doctype = doc
	if not doctype:
		return True
	if is_blocked_doctype(doctype):
		return False
	# Config-driven block (prod only, i.e. where jey_layout is NOT installed).
	# Per-doc deny; the empty list is handled by permission_query_conditions.
	if doctype in desk_layout.prod_blocked_doctypes():
		return False

	# Name of the doc being checked (for Report/Page/Workspace identity blocks).
	docname = None
	if doc is not None and not isinstance(doc, str):
		docname = getattr(doc, "name", None)

	if doctype == "Report" and docname and is_blocked_report(docname):
		return False
	if doctype == "Page" and docname and docname in get_blocked_pages():
		return False
	if doctype == "Workspace":
		module = getattr(doc, "module", None)
		if module and module in BLOCKED_MODULES:
			return False
		if docname and docname in get_blocked_workspace_names():
			return False
	return True


def permission_query_conditions(user=None, doctype=None):
	"""List-query filter (registered for "*"). Returns "1=0" for config-blocked
	doctypes on production → their list views come back EMPTY, for everyone incl.
	System Manager, and it cannot be undone from the UI (lives in code).

	search_link and the framework's internal reads use ignore_permissions and
	bypass this, so dynamic links / dropdowns keep working. Never raises."""
	try:
		if user == "Administrator":
			return ""  # break-glass superuser keeps full list access
		if doctype and doctype in desk_layout.prod_blocked_doctypes():
			return "1=0"
	except Exception:
		_safe_log("permission_query_conditions failed")
	return ""


def _filter_list(container, key, predicate):
	if isinstance(container, dict):
		value = container.get(key)
	else:
		value = getattr(container, key, None)
	if not isinstance(value, list):
		return
	filtered = [item for item in value if predicate(item)]
	if isinstance(container, dict):
		container[key] = filtered
	else:
		setattr(container, key, filtered)


def _module_of(item):
	if isinstance(item, dict):
		return item.get("module") or item.get("module_name")
	return getattr(item, "module", None) or getattr(item, "module_name", None)


def extend_bootinfo(bootinfo):
	"""Hot-path hook — never raise; on unexpected error skip filtering."""
	try:
		_extend_bootinfo_impl(bootinfo)
	except Exception:
		_safe_log("extend_bootinfo failed")


def _extend_bootinfo_impl(bootinfo):
	"""Strip blocked modules/doctypes from bootinfo so client router
	never creates routes for them and the sidebar/switcher hide them."""
	# Flag for jey_theme.js to decide whether to dynamically load the jey_layout
	# editor (kept out of app_include to avoid bloating the preload Link header).
	bootinfo.jey_design_mode = desk_layout.is_design_mode()
	# Strip from boot discovery lists (awesomebar/new): static module block +
	# config-driven prod block (so prod-blocked doctypes don't show in search).
	blocked_doctypes = get_blocked_doctypes() | desk_layout.prod_blocked_doctypes()
	blocked_modules = BLOCKED_MODULES

	allowed_modules = getattr(bootinfo, "allowed_modules", None)
	if isinstance(allowed_modules, list):
		bootinfo.allowed_modules = [
			m for m in allowed_modules if _module_of(m) not in blocked_modules
		]

	modules = getattr(bootinfo, "modules", None)
	if isinstance(modules, dict):
		for mod_name in list(modules.keys()):
			if mod_name in blocked_modules:
				del modules[mod_name]

	user = getattr(bootinfo, "user", None)
	if user is not None:
		not_blocked = lambda dt: dt not in blocked_doctypes
		for key in (
			"can_read", "can_write", "can_create", "can_cancel",
			"can_delete", "can_get_report", "can_search", "can_export",
			"can_print", "can_email", "all_read", "all_reports",
			"can_select", "can_submit",
		):
			_filter_list(user, key, not_blocked)
		_filter_list(user, "allow_modules", lambda m: m not in blocked_modules)

	# Config-driven hidden/blocked workspaces (by name), in addition to module-based.
	hidden_ws = desk_layout.hidden_or_blocked_workspaces()

	def _ws_name(p):
		if isinstance(p, dict):
			return p.get("name") or p.get("title")
		return getattr(p, "name", None) or getattr(p, "title", None)

	def _ws_kept(p):
		return _module_of(p) not in blocked_modules and _ws_name(p) not in hidden_ws

	# bootinfo.workspaces = {"pages": [...], "blocks": [...], ...}
	workspaces = getattr(bootinfo, "workspaces", None)
	if isinstance(workspaces, dict):
		for key in ("pages", "private_pages"):
			pages = workspaces.get(key)
			if isinstance(pages, list):
				workspaces[key] = [p for p in pages if _ws_kept(p)]

	# Sidebar items keyed by section title
	sidebar_items = getattr(bootinfo, "workspace_sidebar_item", None)
	if isinstance(sidebar_items, dict):
		for section_key in list(sidebar_items.keys()):
			section = sidebar_items[section_key]
			if isinstance(section, dict) and isinstance(section.get("items"), list):
				section["items"] = [
					it for it in section["items"] if _module_of(it) not in blocked_modules
				]

	# Module-keyed workspace dict
	mww = getattr(bootinfo, "module_wise_workspaces", None)
	if isinstance(mww, dict):
		for mod_name in list(mww.keys()):
			if mod_name in blocked_modules:
				del mww[mod_name]

	# Per-app data (workspaces & modules lists)
	app_data = getattr(bootinfo, "app_data", None)
	if isinstance(app_data, list):
		for app in app_data:
			if not isinstance(app, dict):
				continue
			ws = app.get("workspaces")
			if isinstance(ws, list):
				# Remove workspace names that belong to blocked modules, are
				# config-blocked, or are config-hidden. app_data workspaces are
				# just names, so compare by name and slug.
				app["workspaces"] = [
					w
					for w in ws
					if _slugify(w) not in get_blocked_slugs() and w not in hidden_ws
				]
			mods = app.get("modules")
			if isinstance(mods, list):
				app["modules"] = [m for m in mods if m not in blocked_modules]

	for key in ("allowed_pages", "allowed_workspaces", "sidebar_pages"):
		items = getattr(bootinfo, key, None)
		if isinstance(items, list):
			setattr(bootinfo, key, [
				it for it in items if _module_of(it) not in blocked_modules
			])

	# Desktop Icon records (used by legacy /desk page). Filter by label/link_to/name.
	icons = getattr(bootinfo, "desktop_icons", None)
	if isinstance(icons, list):
		blocked_ws = get_blocked_workspace_names()
		def _icon_blocked(i):
			if _module_of(i) in blocked_modules:
				return True
			for key in ("label", "link_to", "name", "module_name"):
				val = i.get(key) if isinstance(i, dict) else getattr(i, key, None)
				if val and val in blocked_ws:
					return True
			return False
		bootinfo.desktop_icons = [i for i in icons if not _icon_blocked(i)]
