"""Desk layout engine (variant A — non-destructive, server-side filtering).

Reads a JSON snapshot describing which workspaces / cards / links are visible,
visually hidden, or fully blocked, and exposes derived sets that the rest of
jey_theme (access_control.py, desk_overrides.py, extend_bootinfo) uses to filter
desk data *before* it reaches the browser. The Workspace records in the DB are
never modified, so everything is fully reversible and survives ERPNext migrations.

Config file format (config/desk_layout.json):

    {
      "version": 1,
      "workspaces": {
        "Selling": {
          "state": "visible",          # visible | hidden | blocked
          "cards": {
            "Sales Order": {"state": "hidden"}        # visible | hidden
          },
          "links": {
            "DocType::Quotation":      {"state": "hidden"},   # visible | hidden | blocked
            "Report::Sales Analytics": {"state": "blocked"}
          }
        }
      }
    }

Sources & modes
---------------
- Production (default): read the snapshot shipped inside jey_theme. Always enforced.
- Design mode (`jey_design_mode: 1` in site_config): read the *working* file written
  by the jey_layout editor app (if present), and only enforce when the editor's
  Preview switch is on — so the operator can arrange the desk against the raw,
  unfiltered layout and toggle Preview to verify the result.
"""

import json
import os

import frappe

VISIBLE = "visible"
HIDDEN = "hidden"
BLOCKED = "blocked"

CONFIG_FILENAME = "desk_layout.json"
EDITING_CACHE_KEY = "jey_layout_editing"

# Per-request cache slot on frappe.local
_CACHE_ATTR = "_jey_layout"


def link_key(link_type: str, link_to: str) -> str:
	"""Stable identity for a workspace link across migrations."""
	return f"{link_type}::{link_to}"


# ---------------------------------------------------------------------------
# Mode helpers
# ---------------------------------------------------------------------------
def is_design_mode() -> bool:
	"""Design machines run the editor; production just enforces the snapshot.

	Default: ON exactly when the `jey_layout` editor app is installed (it's only
	installed on test/design machines). An explicit `jey_design_mode` in
	site_config overrides this either way (set 0 to force off, 1 to force on)."""
	override = frappe.conf.get("jey_design_mode")
	if override is not None:
		return bool(override)
	cached = getattr(frappe.local, "_jey_design_mode", None)
	if cached is None:
		try:
			cached = "jey_layout" in frappe.get_installed_apps()
		except Exception:
			cached = False
		frappe.local._jey_design_mode = cached
	return cached


def is_editing() -> bool:
	"""Whether the operator is in an active edit session (design machine only)."""
	try:
		return bool(frappe.cache().get_value(EDITING_CACHE_KEY))
	except Exception:
		return False


def set_editing(on: bool) -> None:
	frappe.cache().set_value(EDITING_CACHE_KEY, 1 if on else 0)


def is_enforcing() -> bool:
	"""Whether filtering should be applied for the current request.

	- Production (no design mode): always enforce — the snapshot is the default.
	- Design machine: enforce by DEFAULT too, so leaving the editor shows the real
	  applied layout. Enforcement is suspended only while actively editing, so the
	  operator can see every item (including hidden ones) and toggle it back."""
	if is_design_mode():
		return not is_editing()
	return True


# ---------------------------------------------------------------------------
# File locations
# ---------------------------------------------------------------------------
def snapshot_path() -> str:
	"""Canonical, shipped snapshot inside jey_theme (the production source)."""
	return os.path.join(frappe.get_app_path("jey_theme"), "config", CONFIG_FILENAME)


def working_path() -> str | None:
	"""Editable working file written by the jey_layout editor app, if installed."""
	try:
		base = frappe.get_app_path("jey_layout")
	except Exception:
		return None
	return os.path.join(base, "config", CONFIG_FILENAME)


def _read_file(path: str | None) -> dict:
	if not path or not os.path.exists(path):
		return {}
	try:
		with open(path, encoding="utf-8") as f:
			data = json.load(f)
		return data if isinstance(data, dict) else {}
	except Exception:
		frappe.log_error("jey_theme: failed to read desk layout config", "Desk Layout")
		return {}


def read_raw_layout() -> dict:
	"""Raw config dict from the active source, ignoring enforce/preview state.

	In design mode prefers the working file (falls back to the snapshot)."""
	if is_design_mode():
		working = _read_file(working_path())
		if working:
			return working
	return _read_file(snapshot_path())


# ---------------------------------------------------------------------------
# Per-request cache of the parsed, *effective* layout + derived sets
# ---------------------------------------------------------------------------
_EMPTY = {
	"workspaces": {},
	"hidden_ws": frozenset(),
	"blocked_ws": frozenset(),
	"blocked_doctypes": frozenset(),
	"blocked_reports": frozenset(),
	"blocked_pages": frozenset(),
}


def _build() -> dict:
	try:
		return _build_inner()
	except Exception:
		# Never let the layout engine break the desk: fail open (no filtering).
		try:
			frappe.log_error("jey_theme: desk layout build failed", "Desk Layout")
		except Exception:
			pass
		return _EMPTY


def _build_inner() -> dict:
	# Always parse the config; gating is applied per-accessor (visual overlay is
	# gated on is_enforcing(); access block is gated on not is_design_mode()).
	layout = read_raw_layout()

	workspaces = layout.get("workspaces") or {}

	hidden_ws: set[str] = set()
	blocked_ws: set[str] = set()
	blocked_doctypes: set[str] = set()
	blocked_reports: set[str] = set()
	blocked_pages: set[str] = set()

	for ws_name, ws in workspaces.items():
		if not isinstance(ws, dict):
			continue
		state = ws.get("state", VISIBLE)
		if state == HIDDEN:
			hidden_ws.add(ws_name)
		elif state == BLOCKED:
			blocked_ws.add(ws_name)

		for key, link in (ws.get("links") or {}).items():
			if not isinstance(link, dict) or link.get("state") != BLOCKED:
				continue
			ltype, _, lto = key.partition("::")
			if not lto:
				continue
			if ltype == "DocType":
				blocked_doctypes.add(lto)
			elif ltype == "Report":
				blocked_reports.add(lto)
			elif ltype == "Page":
				blocked_pages.add(lto)

	# Global block list — doctypes blocked anywhere, including ones not on /desk.
	for dt in layout.get("blocked_doctypes") or []:
		if isinstance(dt, str) and dt:
			blocked_doctypes.add(dt)

	return {
		"workspaces": workspaces,
		"hidden_ws": frozenset(hidden_ws),
		"blocked_ws": frozenset(blocked_ws),
		"blocked_doctypes": frozenset(blocked_doctypes),
		"blocked_reports": frozenset(blocked_reports),
		"blocked_pages": frozenset(blocked_pages),
	}


def _cache() -> dict:
	cached = getattr(frappe.local, _CACHE_ATTR, None)
	if cached is None:
		cached = _build()
		setattr(frappe.local, _CACHE_ATTR, cached)
	return cached


def _ws_entry(ws_name: str) -> dict:
	return _cache()["workspaces"].get(ws_name) or {}


# ---------------------------------------------------------------------------
# VISUAL overlay accessors — gated on is_enforcing() (edit/applied model).
# In edit mode nothing is hidden (operator sees everything); when applied, the
# layout is shown filtered.
# ---------------------------------------------------------------------------
def hidden_or_blocked_workspaces() -> frozenset[str]:
	"""Workspaces to remove from the sidebar (hidden + blocked) — visual only."""
	if not is_enforcing():
		return frozenset()
	c = _cache()
	return c["hidden_ws"] | c["blocked_ws"]


def should_hide_card(ws_name: str, card_label: str) -> bool:
	if not is_enforcing():
		return False
	cards = _ws_entry(ws_name).get("cards") or {}
	return (cards.get(card_label) or {}).get("state", VISIBLE) != VISIBLE


def hidden_card_labels(ws_name: str) -> frozenset[str]:
	"""Untranslated Card Break labels marked hidden for this workspace.

	Output card labels are translated (`_(label)`), so callers forward-translate."""
	if not is_enforcing():
		return frozenset()
	cards = _ws_entry(ws_name).get("cards") or {}
	return frozenset(label for label, entry in cards.items() if (entry or {}).get("state", VISIBLE) != VISIBLE)


def should_hide_link(ws_name: str, link_type: str, link_to: str) -> bool:
	"""True for 'hidden' and 'blocked' links (both removed from the desk display)."""
	if not is_enforcing():
		return False
	links = _ws_entry(ws_name).get("links") or {}
	return (links.get(link_key(link_type, link_to)) or {}).get("state", VISIBLE) != VISIBLE


# ---------------------------------------------------------------------------
# ACCESS-BLOCK accessors — gated on NOT is_design_mode() (enforced on machines
# WITHOUT jey_layout, i.e. production; the design machine keeps full access so
# admins can manage). Enforced via permission hooks (has_permission +
# permission_query_conditions) so a System Manager cannot undo it from the UI.
# ---------------------------------------------------------------------------
def prod_blocked_doctypes() -> frozenset[str]:
	if is_design_mode():
		return frozenset()
	return _cache()["blocked_doctypes"]


def prod_blocked_reports() -> frozenset[str]:
	if is_design_mode():
		return frozenset()
	return _cache()["blocked_reports"]


def prod_blocked_pages() -> frozenset[str]:
	if is_design_mode():
		return frozenset()
	return _cache()["blocked_pages"]


def prod_blocked_workspaces() -> frozenset[str]:
	if is_design_mode():
		return frozenset()
	return _cache()["blocked_ws"]


# ---------------------------------------------------------------------------
# Structural overlay: added links + per-card ordering.
# These are NOT gated by is_enforcing() — adding a link and reordering are
# structural and must apply in edit mode too (so the operator can see/manage
# them). Hide/block (above) stays gated, so edit mode shows everything.
# (Read straight from the parsed config, which _cache() holds ungated.)
# ---------------------------------------------------------------------------
def added_links(ws_name: str) -> list[dict]:
	"""List of injected links for a workspace: {link_type, link_to, label, card}."""
	entry = _ws_entry(ws_name)
	out = []
	for a in (entry.get("added") or {}).values():
		if isinstance(a, dict) and a.get("link_to") and a.get("link_type") and a.get("card"):
			out.append(a)
	return out


def order_map(ws_name: str) -> dict:
	"""{<untranslated card label>: [<key>, ...]} desired link order per card."""
	order = _ws_entry(ws_name).get("order")
	return order if isinstance(order, dict) else {}


def apply_order(items: list, keys: list, key_of) -> list:
	"""Stable reorder: items whose key is listed in `keys` come first in that
	order; everything else keeps its original relative order, appended after."""
	if not keys:
		return items
	rank = {k: i for i, k in enumerate(keys)}
	big = len(keys)
	return sorted(items, key=lambda it: rank.get(key_of(it), big))
