#!/usr/bin/env python3
"""Deterministic ASU brand compliance linter for Canvas HTML content.

CLI mirrors ``check_interactive_a11y.py`` exactly:
  python scripts/check_asu_brand.py <paths...>

Output JSON: {"pass": bool, "failures": [], "warnings": []}
Exit nonzero when pass is false.

Content surfaces only. Exempt paths (by PATH pattern):
  - templates/mockup-html/proposal-base.html  (warm-neutral preview chrome)
  - *report-html*  / *audit_report*  / *unified_staging_review*  (report chrome)

Checks:
  1. font-family must lead with Arial (inline style= and <style> blocks)
  2. <i>, <em>, or font-style:italic -> FAILURE (brand: no italics ever)
  3. Banned same-element contrast pairs in inline style= (color: + background-color:)
  4. Off-palette resolved color -> WARNING (advisory first release, D1 decision)
"""

import argparse
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path

# One-directional: import parse_style/parse_color utilities from shared module.
# wcag_checks must NEVER import this module back.
from wcag_checks import issue, parse_color, parse_style


# ── ASU brand palette (single source of truth, importable by tests) ──────────

ASU_PALETTE = {
    # Primary
    "#8c1d40", "#ffc627", "#000000", "#ffffff",
    # Secondary
    "#78be20", "#00a3e0", "#ff7f32", "#747474", "#af674b", "#4ab7c4", "#e74973",
    # Grays
    "#191919", "#484848", "#747474", "#bfbfbf", "#d0d0d0", "#e8e8e8", "#fafafa",
    # Documented maroon shades
    "#5c0f2d", "#2d0618", "#4a0a24", "#5c0a26", "#c99700",
}

# Named colors the tokenizer resolves (subset: brand-relevant)
_NAMED_COLORS = {
    "white": "#ffffff",
    "black": "#000000",
    "transparent": None,  # skip
}

# Banned same-element inline contrast pairs: (fg_norm, bg_norm) where norm is lowercase hex.
# Tuples sorted so we can match regardless of which direction the pair appears.
_BANNED_PAIRS_DESCRIPTIONS = {
    frozenset({"#ffc627", "#ffffff"}): "gold-on-white or white-on-gold",
    frozenset({"#ffc627", "#fafafa"}): "gold-on-near-white or near-white-on-gold",
    frozenset({"#8c1d40", "#000000"}): "maroon-on-black or black-on-maroon",
}

# Exempt file path patterns (checked against str(path))
_EXEMPT_PATTERNS = [
    "mockup-html/proposal-base",
    "report-html",
    "audit_report",
    "unified_staging_review",
]

# Monospace font families — exempt from the Arial-lead check
_MONOSPACE_STACK_SIGNALS = {
    "monospace", "courier", "courier new", "consolas", "menlo",
    "'courier new'", '"courier new"', "monaco", "inconsolata",
}


def _is_exempt(path: Path) -> bool:
    s = str(path).replace("\\", "/")
    return any(pat in s for pat in _EXEMPT_PATTERNS)


# ── Color tokenizer ───────────────────────────────────────────────────────────

_HEX3_RE = re.compile(r"#([0-9a-f]{3})\b", re.I)
_HEX6_RE = re.compile(r"#([0-9a-f]{6})\b", re.I)
_RGB_RE = re.compile(r"rgba?\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})", re.I)
_VAR_RE = re.compile(r"var\(\s*(--[a-z0-9_-]+)\s*(?:,\s*([^)]*))?\)", re.I)
_NAMED_RE = re.compile(r"\b(white|black|transparent)\b", re.I)


def _rgb_to_hex(r, g, b):
    return "#{:02x}{:02x}{:02x}".format(int(r), int(g), int(b))


def extract_colors(value: str, root_vars: dict) -> list:
    """Extract all colors from a CSS value string.

    Handles: #rgb, #rrggbb, rgb(), rgba(), named (white/black),
    var(--x) resolved from root_vars, linear-gradient() contents.
    Returns list of lowercase 6-digit hex strings.
    """
    # Resolve var() references first (single pass)
    def _resolve_var(m):
        name = m.group(1).lower()
        fallback = m.group(2) or ""
        resolved = root_vars.get(name)
        if resolved is not None:
            return resolved
        return fallback

    resolved = _VAR_RE.sub(_resolve_var, value)

    colors = []

    # #rrggbb (must check before #rgb to avoid partial matches)
    for m in _HEX6_RE.finditer(resolved):
        colors.append("#" + m.group(1).lower())

    # #rgb
    for m in _HEX3_RE.finditer(resolved):
        raw = m.group(1).lower()
        colors.append("#" + "".join(c * 2 for c in raw))

    # rgb() / rgba()
    for m in _RGB_RE.finditer(resolved):
        r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
        if all(0 <= x <= 255 for x in (r, g, b)):
            colors.append(_rgb_to_hex(r, g, b))

    # named colors
    for m in _NAMED_RE.finditer(resolved):
        name = m.group(1).lower()
        hex_val = _NAMED_COLORS.get(name)
        if hex_val:
            colors.append(hex_val)

    return colors


# ── :root CSS variable extractor ─────────────────────────────────────────────

_ROOT_VAR_RE = re.compile(
    r"(--[a-z0-9_-]+)\s*:\s*([^;}\n]+)",
    re.I,
)
_ROOT_BLOCK_RE = re.compile(r":root\s*\{([^}]*)\}", re.I | re.S)


def extract_root_vars(css_text: str) -> dict:
    """Parse :root { --x: value; ... } blocks and return {--name: value_str}."""
    vars_map = {}
    for block_m in _ROOT_BLOCK_RE.finditer(css_text):
        block = block_m.group(1)
        for m in _ROOT_VAR_RE.finditer(block):
            name = m.group(1).lower()
            value = m.group(2).strip()
            vars_map[name] = value
    return vars_map


# ── HTML parser ───────────────────────────────────────────────────────────────

class BrandParser(HTMLParser):
    """Single-pass ASU brand linter.

    Collects:
    - <style> block content
    - inline style="" per element
    - <i> / <em> tags
    - font-family declarations (style= + <style> blocks)
    """

    def __init__(self, path: Path):
        super().__init__(convert_charrefs=True)
        self.path = path
        self.failures = []
        self.warnings = []
        self._in_style = False
        self._style_blocks = []
        self._current_style_text = []
        self._root_vars = {}  # populated after first full style-block pass

    # HTMLParser hooks

    def handle_starttag(self, tag, attrs):
        tag_lower = tag.lower()
        attr = {k.lower(): (v or "") for k, v in attrs}

        # Italic tags
        if tag_lower in {"i", "em"}:
            self.failures.append(issue(
                self.path,
                "BRAND.ITALIC",
                f"<{tag_lower}> tag violates ASU brand italics ban; use bold or quotes for emphasis",
                tag_lower,
            ))

        # Inline style
        style = attr.get("style", "")
        if style:
            self._check_inline_style(style, tag_lower)

        if tag_lower == "style":
            self._in_style = True
            self._current_style_text = []

    def handle_endtag(self, tag):
        if tag.lower() == "style":
            self._in_style = False
            block = "".join(self._current_style_text)
            self._style_blocks.append(block)
            self._current_style_text = []

    def handle_data(self, data):
        if self._in_style:
            self._current_style_text.append(data)

    def finalize(self):
        """Called after feed()+close(). Runs style-block checks."""
        # Build :root var map from all collected style blocks
        full_css = "\n".join(self._style_blocks)
        self._root_vars = extract_root_vars(full_css)

        # Check style blocks
        for css in self._style_blocks:
            self._check_style_block(css)

    def _check_inline_style(self, style: str, tag: str):
        """Check inline style="" for font, italic, contrast pairs, and off-palette."""
        decls = parse_style(style)

        # 1. Font-family check
        ff = decls.get("font-family", "")
        if ff:
            self._check_font_family(ff, location=f"<{tag}> inline style")

        # 2. font-style:italic
        fs = decls.get("font-style", "")
        if "italic" in fs.lower():
            self.failures.append(issue(
                self.path,
                "BRAND.ITALIC",
                f"font-style:italic in <{tag}> inline style violates ASU brand italics ban",
                tag,
            ))

        # 3. Banned contrast pair (both color + background-color in same element)
        fg_raw = decls.get("color", "")
        bg_raw = decls.get("background-color", "") or decls.get("background", "")
        if fg_raw and bg_raw:
            fg_colors = extract_colors(fg_raw, self._root_vars)
            bg_colors = extract_colors(bg_raw, self._root_vars)
            for fg in fg_colors:
                for bg in bg_colors:
                    pair = frozenset({fg, bg})
                    if pair in _BANNED_PAIRS_DESCRIPTIONS:
                        desc = _BANNED_PAIRS_DESCRIPTIONS[pair]
                        self.failures.append(issue(
                            self.path,
                            "BRAND.CONTRAST_PAIR",
                            f"Banned ASU contrast pair ({desc}) in <{tag}> inline style: color:{fg_raw} bg:{bg_raw}",
                            tag,
                        ))

        # 4. Off-palette colors (all color properties)
        color_props = {k: v for k, v in decls.items() if "color" in k or k == "background"}
        for prop, val in color_props.items():
            for color in extract_colors(val, self._root_vars):
                if color not in ASU_PALETTE:
                    self.warnings.append(issue(
                        self.path,
                        "BRAND.OFF_PALETTE",
                        f"Off-palette color {color} in <{tag}> inline style ({prop}:{val})",
                        tag,
                    ))

    def _check_style_block(self, css: str):
        """Check a full <style> block for font declarations, italics, and off-palette vars."""
        # Font-family declarations in <style>
        for m in re.finditer(r"font-family\s*:\s*([^;}\n]+)", css, re.I):
            ff = m.group(1).strip()
            self._check_font_family(ff, location="<style> block")

        # font-style:italic in <style>
        for m in re.finditer(r"font-style\s*:\s*([^;}\n]+)", css, re.I):
            val = m.group(1).strip().lower()
            if "italic" in val:
                self.failures.append(issue(
                    self.path,
                    "BRAND.ITALIC",
                    f"font-style:italic in <style> block violates ASU brand italics ban (context: ...{m.group(0)[:60]}...)",
                ))

        # Off-palette colors in :root CSS variables
        root_vars = extract_root_vars(css)
        for var_name, var_value in root_vars.items():
            for color in extract_colors(var_value, {}):
                if color not in ASU_PALETTE:
                    self.warnings.append(issue(
                        self.path,
                        "BRAND.OFF_PALETTE",
                        f"Off-palette color {color} in CSS variable {var_name}: {var_value}",
                    ))

        # Off-palette colors in non-variable CSS declarations
        for m in re.finditer(r"(?<!--)(?:color|background(?:-color)?|border(?:-color)?|fill|stroke)\s*:\s*([^;}\n]+)", css, re.I):
            prop_val = m.group(1).strip()
            # Skip if it's a var() only reference (already handled via root_vars check)
            if prop_val.startswith("var("):
                continue
            for color in extract_colors(prop_val, root_vars):
                if color not in ASU_PALETTE:
                    self.warnings.append(issue(
                        self.path,
                        "BRAND.OFF_PALETTE",
                        f"Off-palette color {color} in <style> block property (value: {prop_val[:60]})",
                    ))

    def _check_font_family(self, ff: str, location: str):
        """Verify font-family leads with Arial; exempt inherit and monospace stacks."""
        ff_clean = ff.strip().lower().strip("\"'")

        # Exempt: inherit
        if ff_clean in {"inherit", "initial", "unset", "revert"}:
            return

        # Exempt: monospace stacks (first token is a monospace family)
        first_token = ff.strip().split(",")[0].strip().strip("\"'").lower()
        if first_token in _MONOSPACE_STACK_SIGNALS or "monospace" in first_token:
            return

        # Must lead with Arial
        if first_token != "arial":
            self.failures.append(issue(
                self.path,
                "BRAND.FONT",
                (
                    f"font-family does not lead with Arial in {location}: "
                    f"found '{ff.strip()}'. "
                    "Use: Arial, Helvetica, 'Nimbus Sans L', 'Liberation Sans', FreeSans, sans-serif"
                ),
            ))


# ── File + directory walker (mirrors check_interactive_a11y.py) ──────────────

def collect_html_files(paths):
    files = []
    for raw in paths:
        path = Path(raw)
        if path.is_dir():
            files.extend(sorted(p for p in path.rglob("*.html") if p.is_file()))
        elif path.is_file():
            files.append(path)
    return files


def check_file(path: Path):
    parser = BrandParser(path)
    parser.feed(path.read_text(encoding="utf-8", errors="replace"))
    parser.close()
    parser.finalize()
    return parser.failures, parser.warnings


def run(paths):
    failures = []
    warnings = []
    files = collect_html_files(paths)
    if not files:
        failures.append(issue(Path("."), "NO_HTML", "No HTML files found"))
    for path in files:
        if _is_exempt(path):
            continue
        file_failures, file_warnings = check_file(path)
        failures.extend(file_failures)
        warnings.extend(file_warnings)
    return {"pass": not failures, "failures": failures, "warnings": warnings}


# ── CLI ───────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="Run deterministic ASU brand compliance checks on HTML files."
    )
    parser.add_argument("paths", nargs="+", help="HTML file or directory to check")
    args = parser.parse_args()

    report = run(args.paths)
    print(json.dumps(report, indent=2))
    if not report["pass"]:
        sys.exit(1)
    print(
        "Brand check passed (font, italics, contrast pairs). Off-palette warnings are advisory.",
        file=sys.stderr,
    )


if __name__ == "__main__":
    main()
