#!/usr/bin/env python3
"""Find elements whose classes have no base rule — the dangling-selector bug.

THE FAILURE THIS CATCHES

Deleting a CSS rule from a scoped stylesheet is not the local edit it looks
like. Three ways it goes wrong, all of them silent:

  1. A rule is deleted and its `:hover` / modifier survives. The selector still
     exists, so nothing reads as unused, but the element renders with no base
     styling at all. `.btn-workspace:hover` outlived `.btn-workspace` and a
     router-link rendered as raw browser blue for days.

  2. The parent's layout rule is deleted while the children keep theirs.
     `.milestone-header` was a flex row; its children still declare `flex: 1`
     and `flex-shrink: 0`. Without the parent they stack vertically, and a
     milestone that was one line becomes five. Nothing errors — the page just
     wastes space, which reads as a design decision.

  3. A rule is removed from a comma-separated group, leaving `.a,` dangling in
     front of the next rule and swallowing it. That one at least has a
     brace-balance tell; these two do not.

None of it is visible to `vue-tsc`, which is the frontend's entire check. A
dead style typechecks perfectly.

WHAT IT REPORTS

An element whose every static class is styled NOWHERE as a base rule, while at
least one of them appears in the file's CSS. That conjunction is the signal: a
class nobody styles is ordinary (a hook for a test, a semantic label), and a
class with only modifier rules is a deletion that went half-way.

Descendant selectors count as a base — `.panel .row {}` styles `.row` — because
from the element's side there is no difference. Only the LAST compound of a
selector is what it styles, and a compound's whole class SET is what it
requires: `.pane.empty` and `td.num` are base rules for the element carrying
those classes, not modifiers. Reading them as modifiers cost this check four
false reports on its first run, and a check with false reports is one that gets
skimmed.

REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate,
so this cannot be a gate without a suppression mechanism nobody would maintain.
A count that grows is the signal to look. Where a wrapper is bare on purpose,
say so in a comment beside its descendant rules — the remaining reports here
all carry one, so a new entry means something changed.

KNOWN BLIND SPOT: a class with NO rule anywhere is invisible to this, because
it cannot be told from a semantic-only hook. `.systems-list` had lost its
entire rule and was rendering with browser bullets; it was found by reading the
file next to a class that WAS half-styled, not by this check.

INSTANCE-AGNOSTIC (rule #115). Nothing here knows a class name, a component, or
a convention; point it at any Vue tree.
"""
from __future__ import annotations

import argparse
import pathlib
import re
import sys

STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
# `class="a b"` only — never `:class="[...]"`, whose value is an expression.
# The negative lookbehind is the whole point: a bound class list mentions names
# that a static parse would misread as the element's only classes.
STATIC_CLASS = re.compile(r'(?<![:\w-])class="([^"{}\[\]]*)"')
SELECTOR = re.compile(r"([^{}]+)\{")
CLASS_TOKEN = re.compile(r"\.([A-Za-z][\w-]*)")


def shared_classes(sheets: list[pathlib.Path]) -> set[str]:
    """Class names any global stylesheet defines — a base rule from elsewhere."""
    names: set[str] = set()
    for sheet in sheets:
        if sheet.exists():
            names |= set(CLASS_TOKEN.findall(sheet.read_text()))
    return names


def base_class_sets(css: str) -> list[frozenset[str]]:
    """Class combinations this stylesheet gives a base rule to.

    The last compound of a selector is what the rule styles: in
    `.panel .row:hover` that is `.row:hover`, a state — but in `.panel .row` it
    is `.row`, a base.

    A compound may carry more than one class, and a type selector alongside
    them. `.pane.empty` and `td.num` are both base rules for the element that
    matches, so each is recorded as the SET of classes it requires; an element
    is styled when it carries all of them. Recording the classes individually
    instead would clear `.pane` everywhere on the strength of a rule that only
    ever applies with `.empty` — precision matters more here than reach, since
    a missed base is a false report and a wrong one is a defect gone quiet.

    A compound with no class at all (`ul`, `li`) is skipped: it styles by tag,
    which this cannot verify without parsing the template's elements, and an
    empty set would clear every element in the file.
    """
    out: list[frozenset[str]] = []
    for selector in SELECTOR.findall(css):
        for part in selector.split(","):
            part = part.strip()
            if not part or part.startswith("@"):
                continue
            last = re.split(r"[\s>+~]+", part)[-1]
            # A pseudo-class, pseudo-element or attribute selector makes it a
            # state or a variant, not the element's base appearance.
            if ":" in last or "[" in last:
                continue
            names = CLASS_TOKEN.findall(last)
            # Everything outside the class tokens must be a bare type selector.
            if names and re.fullmatch(r"[A-Za-z][\w-]*|\*|", CLASS_TOKEN.sub("", last)):
                out.append(frozenset(names))
    return out


def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]:
    source = path.read_text()
    template = source.split("<style")[0]
    css = "\n".join(CSS_COMMENT.sub("", block) for block in STYLE_BLOCK.findall(source))
    if not css.strip():
        return []

    base_sets = base_class_sets(css)
    mentioned = set(CLASS_TOKEN.findall(css))

    findings: list[tuple[str, list[str]]] = []
    for attr in sorted(set(STATIC_CLASS.findall(template))):
        names = [n for n in attr.split() if re.fullmatch(r"[A-Za-z][\w-]*", n)]
        if not names:
            continue
        carried = set(names)
        if any(carried >= required for required in base_sets):
            continue
        if any(n in shared for n in names):
            continue
        dangling = [n for n in names if n in mentioned]
        if dangling:
            findings.append((attr, dangling))
    return findings


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", default="frontend/src", help="tree of .vue files")
    parser.add_argument(
        "--shared",
        action="append",
        default=None,
        help="global stylesheet whose classes count as a base rule (repeatable)",
    )
    args = parser.parse_args()

    root = pathlib.Path(args.root)
    if not root.exists():
        print(f"{root}: no such directory", file=sys.stderr)
        return 2

    sheets = [pathlib.Path(s) for s in (args.shared or [])]
    if not sheets:
        sheets = sorted(root.glob("assets/*.css"))
    shared = shared_classes(sheets)

    total = 0
    for path in sorted(root.rglob("*.vue")):
        for attr, dangling in scan(path, shared):
            total += 1
            print(f'{path}: class="{attr}" — styled but never based: {", ".join(dangling)}')

    print()
    if total:
        print(
            f"REPORT: {total} element(s) whose classes carry modifier rules but no base "
            f"rule. Each is either a deleted rule that left its :hover behind, or a "
            f"deliberately bare wrapper."
        )
    else:
        print("OK — every styled class has a base rule.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
