# (c) cavaliba.com - home - sidebar.py

import logging

import yaml
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

import app_home.cache as cache
from app_data.schema import Schema
from app_home.configuration import get_configuration
from app_home.home import APP_REGISTRY, convert_icon

logger = logging.getLogger(__name__)


# --------------------------------------------
# YAML parsing / validation
# --------------------------------------------


def _parse_yaml(raw):
    """Parse SIDEBAR_CONTENT YAML text into a list of items. Raises
    ValueError on malformed/non-list content; an empty/unset value parses to
    an empty list."""

    if not raw or not str(raw).strip():
        return []

    try:
        data = yaml.safe_load(raw)
    except yaml.YAMLError as e:
        raise ValueError(f"invalid YAML: {e}") from e

    if data is None:
        return []
    if not isinstance(data, list):
        raise ValueError("sidebar_content must be a YAML list")

    return data


def validate_sidebar_yaml(raw):
    """Validate SIDEBAR_CONTENT shape/types without touching aaa/permissions
    or resolving any URL. Returns a list of error strings (empty if valid) -
    used by the configuration form to reject a bad save before it can ever
    reach compute_sidebar()."""

    try:
        items = _parse_yaml(raw)
    except ValueError as e:
        return [str(e)]

    errors = []
    for idx, item in enumerate(items):
        errors.extend(_validate_item(item, str(idx), allow_section=True))
    return errors


def _validate_item(item, label, allow_section):
    """Return a list of error strings for one sidebar item (recursing into
    section entries)."""

    if not isinstance(item, dict):
        return [f"item #{label}: must be a mapping"]

    types_present = [t for t in ("section", "schema", "app", "link") if t in item]
    if len(types_present) != 1:
        return [f"item #{label}: must have exactly one of section/schema/app/link"]

    itemtype = types_present[0]
    errors = []

    if itemtype == "section":
        if not allow_section:
            errors.append(f"item #{label}: section cannot be nested inside another section")
        entries = item.get("entries") or []
        if not isinstance(entries, list):
            errors.append(f"item #{label}: section 'entries' must be a list")
        else:
            for eidx, entry in enumerate(entries):
                errors.extend(_validate_item(entry, f"{label}.{eidx}", allow_section=False))

    elif itemtype == "app":
        if item.get("app") not in APP_REGISTRY:
            errors.append(f"item #{label}: unknown app '{item.get('app')}'")

    elif itemtype == "link":
        if not item.get("link"):
            errors.append(f"item #{label}: 'link' value required")

    elif itemtype == "schema":
        if not item.get("schema"):
            errors.append(f"item #{label}: 'schema' value required")

    return errors


# --------------------------------------------
# compute
# --------------------------------------------


def compute_sidebar(aaa):
    """Build the sidebar row list for aaa: a flat list of
    {"kind": "header", "label": ...} / {"kind": "entry", "label", "icon",
    "url"} dicts, ready for _sidebar.html. Sections with zero visible
    entries are dropped entirely. Falls back to a minimal/empty sidebar on
    any parse/lookup error, so a bad SIDEBAR_CONTENT edit doesn't break
    every user's page - see _fallback_sidebar()."""

    try:
        raw = get_configuration("home", "SIDEBAR_CONTENT")
        items = _parse_yaml(raw)
        rows = _build_rows(items, aaa)
        _ensure_configuration_entry(rows, aaa)
        return rows
    except Exception as e:
        logger.error(f"compute_sidebar: falling back to default - {e}")
        return _fallback_sidebar(aaa)


def _ensure_configuration_entry(rows, aaa):
    """Guarantee a link to the Configuration page is always present in the
    computed sidebar, even if SIDEBAR_CONTENT forgot/dropped its 'app:
    configuration' entry - so an admin always has a way back in to fix a
    broken sidebar. Mutates rows in place; no-op if already present or if
    aaa lacks p_conf_view."""

    registry_entry = APP_REGISTRY["configuration"]
    permission = registry_entry.get("permission")
    if permission and permission not in aaa["perms"]:
        return

    url = reverse(registry_entry["url_name"])
    if any(row.get("kind") == "entry" and row.get("url") == url for row in rows):
        return

    rows.append(
        {
            "kind": "entry",
            "label": registry_entry["label"],
            "icon": convert_icon(registry_entry["icon"]),
            "url": url,
        }
    )


def _build_rows(items, aaa):
    """Resolve top-level items into rows, dropping empty sections."""

    rows = []
    for item in items:
        if not isinstance(item, dict):
            raise ValueError(f"sidebar item is not a mapping: {item!r}")

        if "section" in item:
            label = item.get("label") or item.get("section")
            entries = item.get("entries") or []
            if not isinstance(entries, list):
                raise ValueError(f"section {label!r} 'entries' is not a list")

            child_rows = [row for row in (_resolve_entry(e, aaa) for e in entries) if row]
            if child_rows:
                rows.append({"kind": "header", "label": label})
                rows.extend(child_rows)
        else:
            resolved = _resolve_entry(item, aaa)
            if resolved:
                rows.append(resolved)

    return rows


def _resolve_entry(item, aaa):
    """Resolve a schema/app/link item to a row dict, or None if not visible
    to aaa. Raises ValueError if the item shape is unrecognized."""

    if not isinstance(item, dict):
        raise ValueError(f"sidebar entry is not a mapping: {item!r}")

    if "schema" in item:
        return _resolve_schema_entry(item, aaa)
    if "app" in item:
        return _resolve_app_entry(item, aaa)
    if "link" in item:
        return _resolve_link_entry(item, aaa)

    raise ValueError(f"sidebar entry has no schema/app/link key: {item!r}")


def _resolve_schema_entry(item, aaa):
    """schema: entries are visible only via Schema.has_read_permission() -
    not overridable from the YAML, so a sidebar entry can never grant more
    access than the schema's own permission settings allow."""

    classname = item.get("schema")
    if not classname:
        raise ValueError("schema entry missing 'schema' value")

    schema = Schema.from_name(classname)
    if not schema or not schema.has_read_permission(aaa=aaa):
        return None

    label = item.get("label") or schema.displayname or classname
    icon = item.get("icon") or schema.icon or "fa-question"
    url = reverse("app_data:instance_list", kwargs={"classname": classname})

    return {"kind": "entry", "label": label, "icon": convert_icon(icon), "url": url}


def _resolve_app_entry(item, aaa):
    """app: entries resolve against APP_REGISTRY; label/icon/permission in
    the YAML override the registry default when present."""

    appkey = item.get("app")
    registry_entry = APP_REGISTRY.get(appkey)
    if not registry_entry:
        logger.warning(f"compute_sidebar: unknown app '{appkey}' - skipped")
        return None

    permission = item["permission"] if "permission" in item else registry_entry.get("permission")
    if permission and permission not in aaa["perms"]:
        return None

    label = item.get("label") or registry_entry.get("label") or appkey
    icon = item.get("icon") or registry_entry.get("icon") or "fa-question"

    if "url" in registry_entry:
        url = registry_entry["url"]
    else:
        url = reverse(registry_entry["url_name"], kwargs=registry_entry.get("url_kwargs"))

    return {"kind": "entry", "label": label, "icon": convert_icon(icon), "url": url}


def _resolve_link_entry(item, aaa):
    """link: entries use their url as-is (absolute or relative, never
    reverse()d) with an optional permission gate."""

    url = item.get("link")
    if not url:
        raise ValueError("link entry missing 'link' value")

    permission = item.get("permission")
    if permission and permission not in aaa["perms"]:
        return None

    label = item.get("label") or url
    icon = item.get("icon") or "fa-question"

    return {"kind": "entry", "label": label, "icon": convert_icon(icon), "url": url}


def _fallback_sidebar(aaa):
    """Rendered instead of a 500/broken page when sidebar_content fails to
    parse: empty for a regular user, a single link to the Configuration page
    for an admin so they can fix the YAML."""

    if not aaa.get("is_admin"):
        return []

    try:
        url = reverse("app_home:configuration")
    except Exception:
        url = "/home/private/conf/"

    return [
        {"kind": "header", "label": _("Sidebar configuration error")},
        {"kind": "entry", "label": _("Fix sidebar configuration"), "icon": "fa-wrench", "url": url},
    ]


# --------------------------------------------
# per-user cache
# --------------------------------------------


def get_sidebar_cached(aaa):
    """Per-user cached wrapper around compute_sidebar() - short TTL
    (app_home.cache.cache2_sidebar) since permissions may vary/change."""

    username = aaa.get("username") or "anonymous"
    cachekey = f"user:{username}"

    cached = cache.cache2_sidebar.get(key=cachekey)
    if cached is not None:
        return cached

    rows = compute_sidebar(aaa)
    cache.cache2_sidebar.set(key=cachekey, value=rows)
    return rows


def invalidate_sidebar_cache(username=None):
    """Invalidate one user's cached sidebar - call right after a
    sidebar_content config save so the saving admin sees their own edit
    immediately instead of waiting out the cache TTL."""

    if not username:
        return
    cache.cache2_sidebar.delete(key=f"user:{username}")


def list_active_users():
    """Return the sorted list of usernames with a currently cached sidebar -
    a proxy for "active in the last CAVALIBA_CACHE_SIDEBAR_TIMEOUT seconds",
    since get_sidebar_cached() populates one cache entry per user on every
    page view. Returns None (not []) if the cache backend doesn't support
    key enumeration (only django-redis does - the sqlite DatabaseCache/
    LocMemCache fallbacks do not)."""

    keys = cache.cache2_sidebar.list_keys("user:*")
    if keys is None:
        return None

    key_prefix = f"{cache.cache2_sidebar.prefix}::user:"
    return sorted({k[len(key_prefix) :] for k in keys if k.startswith(key_prefix)})
