# (c) cavaliba.com - home - dashboard.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__)

# special schema: value expanding to every schema not already named
# (positively or negated) anywhere in the document. Deliberately not "*" -
# that reads as easily confused with app: all_schemas (a link to the "browse
# all schemas" page, a completely different thing from "insert a widget per
# schema"). Must be quoted in YAML regardless ("@" is a reserved YAML
# indicator, same category of gotcha as "!").
WILDCARD_SCHEMA = "@ALL"


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


def _parse_yaml(raw):
    """Parse DASHBOARD_CONTENT YAML text into a list of sections. 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("dashboard_content must be a YAML list")

    return data


def validate_dashboard_yaml(raw):
    """Validate DASHBOARD_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_dashboard()."""

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

    errors = []
    for idx, section in enumerate(sections):
        errors.extend(_validate_section(section, str(idx)))
    return errors


def _validate_section(section, label):
    """Return a list of error strings for one top-level section."""

    if not isinstance(section, dict):
        return [f"section #{label}: must be a mapping"]
    if "section" not in section:
        return [f"section #{label}: missing 'section' key"]

    entries = section.get("entries") or []
    if not isinstance(entries, list):
        return [f"section #{label}: 'entries' must be a list"]

    errors = []
    for eidx, entry in enumerate(entries):
        errors.extend(_validate_item(entry, f"{label}.{eidx}"))
    return errors


def _validate_item(item, label):
    """Return a list of error strings for one schema/app/link entry."""

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

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

    itemtype = types_present[0]

    if itemtype == "app":
        if item.get("app") not in APP_REGISTRY:
            return [f"item #{label}: unknown app '{item.get('app')}'"]

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

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

    return []


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


def compute_dashboard(aaa):
    """Build the dashboard section list for aaa: a list of
    {"label": ..., "cards": [{"kind", "label", "icon", "url", "count"?}, ...]}
    dicts, ready for a template. Sections with zero visible cards are dropped
    entirely. Falls back to a minimal/empty dashboard on any parse/lookup
    error, so a bad DASHBOARD_CONTENT edit doesn't break the page for every
    user - see _fallback_dashboard()."""

    try:
        raw = get_configuration("home", "DASHBOARD_CONTENT")
        sections = _parse_yaml(raw)
        listed, negated = _collect_schema_names(sections)
        return _build_sections(sections, aaa, listed, negated)
    except Exception as e:
        logger.error(f"compute_dashboard: falling back to default - {e}")
        return _fallback_dashboard(aaa)


def _collect_schema_names(sections):
    """First pass: collect every explicitly-named (positive) and negated (!)
    schema keyname across the WHOLE document, regardless of section. This is
    what makes WILDCARD_SCHEMA expansion (wherever it appears) global - it
    never re-adds a schema already placed, or excluded, anywhere else in the
    document."""

    listed = set()
    negated = set()

    for section in sections:
        if not isinstance(section, dict):
            raise ValueError(f"dashboard section is not a mapping: {section!r}")

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

        for item in entries:
            if not isinstance(item, dict) or "schema" not in item:
                continue
            name = item.get("schema")
            if not name:
                continue
            if name.startswith("!"):
                negated.add(name[1:])
            elif name != WILDCARD_SCHEMA:
                listed.add(name)

    return listed, negated


def _build_sections(sections, aaa, listed, negated):
    """Second pass: resolve every section's entries into card rows, dropping
    sections that end up with zero visible cards."""

    result = []
    for section in sections:
        if not isinstance(section, dict):
            raise ValueError(f"dashboard section is not a mapping: {section!r}")

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

        cards = []
        for item in entries:
            cards.extend(_resolve_item(item, aaa, listed, negated))

        if cards:
            result.append({"label": label, "cards": cards})

    return result


def _resolve_item(item, aaa, listed, negated):
    """Resolve a schema/app/link item to a list of 0+ card dicts (only
    schema: WILDCARD_SCHEMA can return more than one). Raises ValueError if
    the item shape is unrecognized."""

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

    if "schema" in item:
        return _resolve_schema_item(item, aaa, listed, negated)
    if "app" in item:
        card = _resolve_app_item(item, aaa)
        return [card] if card else []
    if "link" in item:
        card = _resolve_link_item(item, aaa)
        return [card] if card else []

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


def _resolve_schema_item(item, aaa, listed, negated):
    """schema: entries are visible only via Schema.has_read_permission() -
    not overridable from the YAML. A '!name' value never renders itself, it
    only marks 'name' as excluded from WILDCARD_SCHEMA expansion (see
    _collect_schema_names). A WILDCARD_SCHEMA ('@ALL') value expands to every
    schema not already named - positively or negated - anywhere in the
    document."""

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

    if name.startswith("!"):
        return []

    if name == WILDCARD_SCHEMA:
        cards = []
        for schema in Schema.listall():
            if schema.classname in listed or schema.classname in negated:
                continue
            if _schema_dashboard_hidden(schema):
                continue
            card = _schema_card(schema, aaa)
            if card:
                cards.append(card)
        return cards

    schema = Schema.from_name(name)
    card = _schema_card(schema, aaa, label=item.get("label"), icon=item.get("icon"))
    return [card] if card else []


def _schema_dashboard_hidden(schema):
    """Placeholder for a future per-schema 'no_dashboard' option (mirroring
    schema.options flags like notify/field_hide_from_edit) - always False
    until that option exists. Only consulted during WILDCARD_SCHEMA expansion;
    an explicitly-named schema: entry always shows (same convention as
    sidebar's schema: entries never being hideable from YAML, only by real
    permissions)."""

    return False


def _schema_card(schema, aaa, label=None, icon=None):
    """Build a schema card dict, or None if the schema doesn't exist or aaa
    lacks read permission on it."""

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

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

    return {
        "kind": "schema",
        "label": card_label,
        "icon": convert_icon(card_icon),
        "count": Schema.count_instances(schema.classname),
        "url": url,
    }


def _resolve_app_item(item, aaa):
    """app: entries resolve against APP_REGISTRY; label/icon/permission
    in the YAML override the registry default when present. No object count
    (unlike schema: entries)."""

    appkey = item.get("app")
    registry_entry = APP_REGISTRY.get(appkey)
    if not registry_entry:
        logger.warning(f"compute_dashboard: 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": "app", "label": label, "icon": convert_icon(icon), "url": url}


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

    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": "link", "label": label, "icon": convert_icon(icon), "url": url}


def _fallback_dashboard(aaa):
    """Rendered instead of a 500/broken page when dashboard_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 [
        {
            "label": _("Dashboard configuration error"),
            "cards": [
                {
                    "kind": "link",
                    "label": _("Fix dashboard configuration"),
                    "icon": "fa-wrench",
                    "url": url,
                }
            ],
        }
    ]


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


def get_dashboard_cached(aaa):
    """Per-user cached wrapper around compute_dashboard() - short TTL
    (app_home.cache.cache2_dashboard) since permissions may vary/change."""

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

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

    rows = compute_dashboard(aaa)
    cache.cache2_dashboard.set(key=cachekey, value=rows)
    return rows


def invalidate_dashboard_cache(username=None):
    """Invalidate one user's cached dashboard - call right after a
    dashboard_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_dashboard.delete(key=f"user:{username}")
