#!/usr/bin/env python3
"""Check the frontend's CSS against the tokens its stylesheet declares.

The gap this closes: `services/design_stylesheet.check_code_against_tokens` has
always been able to answer "does this code use the sheet correctly?", but the
only thing ever fed to it was recorded SNIPPETS. The app's own components — where
sixteen unresolvable references were found living quietly (#2319) — were checked
by nothing at all.

That was structural rather than an oversight. The drift panel runs in the browser
and cannot read source files, and the server has no repo access. CI is the only
place holding both the component sources and the ability to run the check, and it
only became cheap once `theme.css` became a generated artifact — so the source of
truth is a local file, with no network and no credentials.

INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should
be called or which literals are discouraged. Both come from the stylesheet: the
declarations, and the `SUPERSEDES` block the generator emits. Point it at a
different install's sheet and it checks that install's rules.

Two severities, and the split is deliberate:

  FAIL   an unresolvable `var()` reference. Currently zero, so this is a ratchet
         that holds a line already reached rather than a backlog that keeps CI
         red. It also cannot false-positive: either the name is declared or it
         is not.
  REPORT superseded literals and raw colour literals. Hundreds today, so gating
         on them would mean a permanently failing job that everyone learns to
         ignore — which is worse than no check.
"""
from __future__ import annotations

import argparse
import pathlib
import re
import sys

# A declaration is `--name:`; a reference is `var(--name)` or `var(--name, …)`.
DECLARATION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
SUPERSEDES_LINE = re.compile(r"^\s*\*\s*(\S+)\s*->\s*(--[A-Za-z0-9_-]+)\s*$")
HEX_LITERAL = re.compile(r"#[0-9a-fA-F]{3,8}\b")
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)


def declared_tokens(sheet: str) -> set[str]:
    """Every custom property the stylesheet declares.

    Anchored on the colon alone. Anchoring on `{` or `;` instead silently drops
    every declaration that follows a comment — a mistake made once already, which
    lost `--color-bg` and 2 others without erroring.
    """
    return set(DECLARATION.findall(sheet))


def superseded_literals(sheet: str) -> dict[str, str]:
    """`{literal: token}` from the generator's SUPERSEDES block, lowercased."""
    out: dict[str, str] = {}
    for line in sheet.splitlines():
        match = SUPERSEDES_LINE.match(line)
        if match:
            out[match.group(1).lower()] = match.group(2)
    return out


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

    `#fff` must not fire inside `#ffffff`: different colours, and a finding on
    the wrong one sends someone to change correct code.
    """
    return re.compile(
        r"(?<![0-9A-Za-z_#-])" + re.escape(literal) + r"(?![0-9A-Za-z_-])",
        re.IGNORECASE,
    )


def style_source(path: pathlib.Path) -> str:
    """The CSS in a file — `<style>` blocks for an SFC, the whole of a .css.

    Comments are stripped, and that is load-bearing rather than tidy. A comment
    EXPLAINING a rule mentions the very literal the rule forbids: this file's own
    stylesheet documents why it avoids `#fff`, and the first run of this checker
    reported that explanation as a violation. A checker that flags the
    documentation of a rule teaches people to stop documenting rules.
    """
    text = path.read_text(encoding="utf-8", errors="replace")
    css = "\n".join(STYLE_BLOCK.findall(text)) if path.suffix == ".vue" else text
    return CSS_COMMENT.sub(" ", css)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
    parser.add_argument("--root", default="frontend/src")
    parser.add_argument(
        "--report-literals", action="store_true",
        help="also list raw colour literals (advisory, never fails)",
    )
    args = parser.parse_args()

    sheet_path = pathlib.Path(args.sheet)
    if not sheet_path.is_file():
        print(f"error: stylesheet not found: {sheet_path}", file=sys.stderr)
        return 2

    sheet = sheet_path.read_text()
    declared = declared_tokens(sheet)
    supersedes = superseded_literals(sheet)
    print(f"{sheet_path}: {len(declared)} tokens declared, "
          f"{len(supersedes)} superseded literals recorded\n")

    root = pathlib.Path(args.root)
    sources = sorted(
        [p for p in root.rglob("*.vue")] + [p for p in root.rglob("*.css")]
    )

    unresolved: list[tuple[pathlib.Path, str]] = []
    superseded_hits: list[tuple[pathlib.Path, str, str]] = []
    literal_count = 0

    for path in sources:
        if path == sheet_path:
            continue
        css = style_source(path)
        if not css.strip():
            continue

        # A component may legitimately declare a local custom property; a
        # reference to it is not unresolved.
        local = set(DECLARATION.findall(css))
        for name in sorted(set(REFERENCE.findall(css))):
            if name not in declared and name not in local:
                unresolved.append((path, name))

        for literal, token in supersedes.items():
            if _literal_pattern(literal).search(css):
                superseded_hits.append((path, literal, token))

        literal_count += len(HEX_LITERAL.findall(css))

    if unresolved:
        print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
        print("  These render as the fallback if given one, or as nothing at all.")
        print("  Either way nothing errors, which is why they survive.\n")
        for path, name in unresolved:
            print(f"    {path}: {name}")
        print()
    else:
        print("OK — every var() reference resolves to a declared token.\n")

    if superseded_hits:
        print(f"REPORT — {len(superseded_hits)} superseded literal(s). "
              "The sheet says what to write instead:")
        seen: set[tuple[str, str]] = set()
        for path, literal, token in superseded_hits:
            key = (str(path), literal)
            if key in seen:
                continue
            seen.add(key)
            print(f"    {path}: {literal} -> {token}")
        print()

    if args.report_literals:
        print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
        print("  Advisory: a literal is a value stated outside the system, so it "
              "cannot follow a palette change.\n")

    return 1 if unresolved else 0


if __name__ == "__main__":
    sys.exit(main())
