"""Render a design system's resolved tokens as its master CSS sheet.

Pure, like `design_cascade` and for the same reasons: no database import, so the
rendering rule is testable without one and the preview surface can reuse it.

WHAT THIS SHEET IS, AND DELIBERATELY IS NOT
-------------------------------------------
It declares **purpose tokens only** — custom properties, grouped by what they
mean. It contains no rules for elements or classes: no `.btn-primary`, no
`table`, no `input`.

That is the point rather than a limitation. A sheet that styled every element
would restate the same handful of values once per element and grow with the UI;
a sheet of purpose-named values states each once and is reused. Components —
buttons, tables, input schemes — live as SNIPPETS that reference these names and
carry prose about the idea, which is a surface that already exists and already
has recall, locations, drift checks and merge.

So the division is: this sheet says what the values MEAN; snippets say what
things LOOK LIKE, in terms of those values. A token named after an element
(`--button-bg`) is the smell that the two have been mixed — it multiplies
with every new element, where a purpose name (`--action-primary`) is reused.

SAFETY
------
Values are interpolated into a stylesheet, and design systems are shareable
records (rule #47). A value of `red; } body { display: none` in a system someone
shared with you would otherwise inject CSS into your page. Everything rendered
here is validated or dropped — never escaped-and-hoped.
"""
from __future__ import annotations

import re
from collections.abc import Sequence

BASE_MODE = "base"

# A custom property name, strictly. Anything else is dropped rather than
# sanitised: a name is an identifier, and a "cleaned up" identifier is a
# different token than the one the operator recorded.
_VALID_NAME = re.compile(r"^--[A-Za-z0-9_-]+$")

# Characters that would end the declaration, open a block, start an at-rule, or
# begin a tag. A value containing any of them is not a value.
_UNSAFE_VALUE = re.compile(r"[{};@<>]|\*/|/\*|\n|\r")


def is_valid_token_name(name: str) -> bool:
    return bool(_VALID_NAME.match((name or "").strip()))


def safe_value(value: str) -> str | None:
    """A CSS value that cannot escape its declaration, or None.

    Rejects rather than strips. A partially-sanitised value is a value the
    operator did not write, and silently rendering a different colour than the
    record holds is worse than rendering none — the sheet's whole claim is that
    it IS the record.
    """
    candidate = (value or "").strip()
    if not candidate or _UNSAFE_VALUE.search(candidate):
        return None
    return candidate


def safe_comment(text: str) -> str:
    """Comment text that cannot close its comment or break the line."""
    return re.sub(r"\*/|/\*|[\n\r]", " ", (text or "")).strip()


def selector_for_mode(mode: str, root_selector: str = ":root") -> str:
    """Which selector a mode's declarations belong under.

    `base` gets the caller's root selector; every other mode gets a bare
    attribute selector, matching the convention already in the codebase.

    The root selector is a PARAMETER because a container-scoped preview cannot
    use `:root` — #251 recorded that mode scoping is one-way (light lives on
    `:root`, dark layers over it), so a generator that hardcoded `:root` could
    not serve a preview at all.
    """
    if mode == BASE_MODE:
        return root_selector
    safe_mode = re.sub(r"[^A-Za-z0-9_-]", "", mode)
    return f'[data-theme="{safe_mode}"]' if safe_mode else root_selector


def _grouped(tokens: Sequence) -> list[tuple[str | None, list]]:
    """Tokens by group, preserving the order they arrive in (already sorted)."""
    groups: dict[str | None, list] = {}
    for token in tokens:
        groups.setdefault(getattr(token, "group_name", None), []).append(token)
    return list(groups.items())


def _modes_present(tokens: Sequence) -> list[str]:
    """Every mode any token declares, base first then the rest alphabetically."""
    modes = {
        mode
        for token in tokens
        for mode in (getattr(token, "value_by_mode", None) or {})
    }
    rest = sorted(modes - {BASE_MODE})
    return ([BASE_MODE] if BASE_MODE in modes else []) + rest


def render_stylesheet(
    tokens: Sequence,
    *,
    root_selector: str = ":root",
    title: str = "",
    design_system_id: int | None = None,
) -> str:
    """The master sheet for a resolved token set.

    One block per mode. Within a block, only the tokens that declare a value for
    that mode — so a mode block is an override layer, exactly as the storage
    model has it.

    Tokens with no value at all are emitted as COMMENTED-OUT declarations in
    their group, not dropped. The rulebook named them, so their absence is a
    finding, and a commented line puts that finding where the reader is already
    looking.
    """
    header = [
        "/*",
        f" * {safe_comment(title) or 'Design system'} — generated stylesheet",
    ]
    if design_system_id is not None:
        header.append(f" * Source: design system {int(design_system_id)}.")
    header += [
        " *",
        " * Purpose tokens only. This sheet declares what values MEAN; it styles",
        " * no elements. Buttons, tables and input schemes live as snippets that",
        " * reference these names, so each value is stated once and reused rather",
        " * than restated per element.",
        " *",
        " * Generated — edit the design system, not this file.",
        " */",
        "",
    ]

    lines = list(header)
    valueless = [
        t for t in tokens
        if is_valid_token_name(getattr(t, "name", ""))
        and not (getattr(t, "value_by_mode", None) or {})
    ]

    for mode in _modes_present(tokens):
        block: list[str] = []
        for group, group_tokens in _grouped(tokens):
            entries: list[str] = []
            for token in group_tokens:
                name = (getattr(token, "name", "") or "").strip()
                if not is_valid_token_name(name):
                    continue
                raw = (getattr(token, "value_by_mode", None) or {}).get(mode)
                if raw is None:
                    continue
                value = safe_value(str(raw))
                if value is None:
                    entries.append(
                        f"  /* {name}: value rejected — not a safe CSS value */"
                    )
                    continue
                # Purpose first — what the token is FOR is what a reader of the
                # stylesheet needs. Rationale is the fallback so a token that
                # only carries the why still says something.
                purpose = safe_comment(
                    getattr(token, "purpose", "")
                    or getattr(token, "rationale", "")
                    or ""
                )
                comment = f"  /* {purpose} */" if purpose and mode == BASE_MODE else ""
                entries.append(f"  {name}: {value};{comment}")
            if entries:
                if group:
                    block.append(f"  /* {safe_comment(group)} */")
                block.extend(entries)
                block.append("")

        # Declared-but-valueless tokens belong to the base layer: they have no
        # mode to sit under, and repeating them per mode would triple the noise.
        if mode == BASE_MODE and valueless:
            block.append("  /* Declared by the design system, no value set yet: */")
            block.extend(f"  /* {t.name}: ; */" for t in valueless)
            block.append("")

        if not block:
            continue
        lines.append(f"{selector_for_mode(mode, root_selector)} {{")
        lines.extend(block[:-1] if block[-1] == "" else block)
        lines.append("}")
        lines.append("")

    # A trailing, machine-readable record of what this system says to write
    # INSTEAD of a given literal.
    #
    # The sheet carries its own supersedes declarations so that any consumer has
    # them — notably a CI check, which has the component sources but no database.
    # Hardcoding the mapping in a checker would bake one install's palette into
    # the tool; reading it from the sheet keeps the checker instance-agnostic and
    # keeps this file the single source.
    replacements = [
        (literal, getattr(token, "name", ""))
        for token in tokens
        for literal in (getattr(token, "supersedes", None) or ())
        if is_valid_token_name(getattr(token, "name", ""))
    ]
    if replacements:
        lines.append("/* SUPERSEDES — write the token, not the literal.")
        for literal, name in replacements:
            lines.append(f" * {safe_comment(str(literal))} -> {name}")
        lines.append(" */")
        lines.append("")

    return "\n".join(lines).rstrip() + "\n"


def duplicate_values(tokens: Sequence) -> dict[str, list[str]]:
    """Values declared by more than one token, mapped to the names declaring them.

    Two names for one value are either a deliberate alias or the same idea
    recorded twice — the token-level form of the duplicated-definition shape.
    Reported rather than refused: a design system legitimately aligns colours on
    purpose (one palette entry defined as equal to another), and a generator
    that rejected that
    would be wrong about the operator's intent.

    Compares the BASE value only. Two tokens agreeing in one mode and diverging
    in another are not duplicates of each other — they are a near-miss, which is
    a different and less interesting finding.
    """
    by_value: dict[str, list[str]] = {}
    for token in tokens:
        name = getattr(token, "name", "")
        base = (getattr(token, "value_by_mode", None) or {}).get(BASE_MODE)
        if not name or not base:
            continue
        by_value.setdefault(str(base).strip().lower(), []).append(name)
    return {value: names for value, names in by_value.items() if len(names) > 1}


# ---------------------------------------------------------------------------
# Reading a sheet from the other side: does this code use it correctly?
# ---------------------------------------------------------------------------
#
# "The snippets use the tags from the sheet" is a verifiable relation, and
# nothing checked it before. Three questions, each a different failure:
#
#   var(--x) where no --x exists   -> renders as NOTHING. No error, no test
#                                     failure, no visual clue beyond the thing
#                                     silently not being styled.
#   a superseded literal in code   -> the value the sheet said to stop writing,
#                                     and the sheet knows what to write instead.
#   --x: declared inside a snippet -> a component minting its own token is the
#                                     bloat a shared sheet exists to prevent.
#
# The first is not hypothetical: `--color-accent` was used throughout a new view
# in this codebase and does not exist.

_VAR_REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
_LOCAL_DEFINITION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")


def referenced_tokens(code: str) -> set[str]:
    """Every custom property the code reads through `var()`."""
    return set(_VAR_REFERENCE.findall(code or ""))


def defined_tokens(code: str) -> set[str]:
    """Every custom property the code declares itself.

    Excludes names it also reads: `--x: var(--x, fallback)` is a redeclaration
    of something the sheet owns, which the unknown-reference check already
    covers more precisely.
    """
    return set(_LOCAL_DEFINITION.findall(code or "")) - referenced_tokens(code or "")


def _literal_pattern(literal: str) -> re.Pattern:
    """Match a literal value without matching a longer one that contains it.

    `#fff` must not match inside `#ffffff` — they are different colours, and a
    finding that fired on the wrong one would send someone to change code that
    was already correct.
    """
    escaped = re.escape(literal)
    lead = r"(?<![0-9A-Za-z_#-])"
    trail = r"(?![0-9A-Za-z_-])" if literal.startswith("#") else r"(?![0-9A-Za-z_-])"
    return re.compile(lead + escaped + trail, re.IGNORECASE)


def check_code_against_tokens(code: str, tokens) -> dict:
    """What this code gets wrong about that token set.

    `tokens` is any sequence with `.name`, `.value_by_mode` and `.supersedes` —
    resolved tokens, or stored ones.

    Reports rather than scores. Every finding here has a legitimate exception:
    a snippet may target a system it isn't being checked against, and a literal
    may be deliberate in a context the token doesn't cover. What it removes is
    the SILENCE — all three currently fail with no signal at all.
    """
    known = {getattr(t, "name", "") for t in tokens}
    referenced = referenced_tokens(code)

    superseded: list[dict] = []
    for token in tokens:
        for literal in getattr(token, "supersedes", None) or ():
            if _literal_pattern(str(literal)).search(code or ""):
                superseded.append({
                    "literal": literal,
                    "use_instead": getattr(token, "name", ""),
                })

    return {
        "used": sorted(referenced & known),
        "unknown": sorted(referenced - known),
        "superseded_literals": superseded,
        "local_definitions": sorted(defined_tokens(code)),
    }


# ---------------------------------------------------------------------------
# Derivation — tokens whose value is a formula over other tokens
# ---------------------------------------------------------------------------
#
# `--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)` needs
# NO special storage: it is a value like any other, and the browser resolves the
# `var()` at use time. Change `--fs-accent` and every derived token shifts with
# it, in every mode, from one declaration.
#
# That last part is the real win. A derived token declared once in the base layer
# follows its source through dark mode automatically, because `var()` resolves in
# whatever context it is used rather than where it is written. Storing a computed
# literal instead would need one row per mode AND would silently stop tracking
# the source the moment the source changed.
#
# What derivation DOES need is the check below. A formula pointing at a token
# that does not exist is invalid-at-computed-value-time: the browser drops the
# declaration and the element falls back to inheritance or nothing. Silent, like
# everything else in this family.


def token_dependencies(tokens) -> dict[str, set[str]]:
    """Each token name mapped to the token names its own values reference."""
    deps: dict[str, set[str]] = {}
    for token in tokens:
        name = getattr(token, "name", "")
        if not name:
            continue
        refs: set[str] = set()
        for value in (getattr(token, "value_by_mode", None) or {}).values():
            refs |= referenced_tokens(str(value))
        deps[name] = refs - {name}
    return deps


def _find_cycles(deps: dict[str, set[str]]) -> list[list[str]]:
    """Derivation loops, each reported once as the names involved.

    CSS degrades a loop to invalid-at-computed-value-time rather than hanging, so
    this is about telling the operator, not about protecting the renderer. A
    token that quietly resolves to nothing is the failure worth naming.
    """
    cycles: list[list[str]] = []
    seen_cycles: set[frozenset] = set()

    def walk(node: str, path: list[str], visiting: set[str]) -> None:
        for dep in sorted(deps.get(node, ())):
            if dep in visiting:
                loop = path[path.index(dep):]
                key = frozenset(loop)
                if loop and key not in seen_cycles:
                    seen_cycles.add(key)
                    cycles.append(loop)
                continue
            if dep in deps:
                walk(dep, path + [dep], visiting | {dep})

    for name in sorted(deps):
        walk(name, [name], {name})
    return cycles


def derivation_report(tokens) -> dict:
    """Which tokens are formulas, and which of those are broken.

    `derived`      name -> the tokens it is computed from
    `unknown_refs` name -> references that resolve to no token in this system.
                   The browser drops such a declaration entirely; nothing errors.
    `cycles`       derivation loops, which resolve to nothing for the same reason
    """
    deps = token_dependencies(tokens)
    known = set(deps)

    derived = {name: sorted(refs) for name, refs in deps.items() if refs}
    unknown = {
        name: sorted(refs - known)
        for name, refs in deps.items()
        if refs - known
    }
    return {
        "derived": derived,
        "unknown_refs": unknown,
        "cycles": _find_cycles(deps),
    }
