"""Style playbook loader.

Loads, validates, and lists style playbook YAML files from styles/.
Includes design intelligence: color palette analysis, typography intelligence,
and accessibility validation (D3.5.5, D3.5.6, D3.5.7).
"""

from __future__ import annotations

import colorsys
import json
import math
from pathlib import Path
from typing import Any, Optional

import yaml
import jsonschema

STYLES_DIR = Path(__file__).resolve().parent
SCHEMA_PATH = (
    Path(__file__).resolve().parent.parent
    / "schemas"
    / "styles"
    / "playbook.schema.json"
)


def _load_playbook_schema() -> dict:
    with open(SCHEMA_PATH) as f:
        return json.load(f)


def load_playbook(name: str, styles_dir: Optional[Path] = None) -> dict[str, Any]:
    """Load and validate a style playbook by name.

    Args:
        name: Playbook name (without .yaml extension).
        styles_dir: Override directory for playbook files.

    Returns:
        Validated playbook dict.
    """
    styles_dir = styles_dir or STYLES_DIR
    path = styles_dir / f"{name}.yaml"
    if not path.exists():
        raise FileNotFoundError(f"Playbook not found: {path}")

    with open(path) as f:
        playbook = yaml.safe_load(f)

    validate_playbook(playbook)
    return playbook


def validate_playbook(playbook: dict) -> None:
    """Validate a playbook dict against the schema."""
    schema = _load_playbook_schema()
    jsonschema.validate(instance=playbook, schema=schema)


def list_playbooks(styles_dir: Optional[Path] = None) -> list[str]:
    """List all available playbook names."""
    styles_dir = styles_dir or STYLES_DIR
    return [
        p.stem
        for p in styles_dir.glob("*.yaml")
        if p.stem != "__pycache__"
    ]


# ---------------------------------------------------------------------------
# Color math helpers (pure Python, no external deps)
# ---------------------------------------------------------------------------

def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    """Convert hex color string to (R, G, B) tuple (0-255)."""
    h = hex_color.lstrip("#")
    if len(h) == 3:
        h = h[0] * 2 + h[1] * 2 + h[2] * 2
    # Handle 8-char hex (with alpha) by taking first 6 chars
    if len(h) == 8:
        h = h[:6]
    return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)


def _has_alpha(hex_color: str) -> bool:
    """Check if a hex color string includes an alpha channel (8-char hex)."""
    return len(hex_color.lstrip("#")) == 8


def _composite_alpha(fg_hex: str, bg_hex: str) -> str:
    """Composite an RGBA foreground color onto an opaque background.

    Args:
        fg_hex: Foreground color as 8-char hex (#RRGGBBAA).
        bg_hex: Opaque background color as 6-char hex (#RRGGBB).

    Returns:
        Composited opaque hex color string.
    """
    h = fg_hex.lstrip("#")
    alpha = int(h[6:8], 16) / 255.0
    fg_r, fg_g, fg_b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
    bg_r, bg_g, bg_b = _hex_to_rgb(bg_hex)
    r = round(alpha * fg_r + (1 - alpha) * bg_r)
    g = round(alpha * fg_g + (1 - alpha) * bg_g)
    b = round(alpha * fg_b + (1 - alpha) * bg_b)
    return _rgb_to_hex(r, g, b)


def _rgb_to_hex(r: int, g: int, b: int) -> str:
    """Convert (R, G, B) tuple to hex string."""
    r = max(0, min(255, round(r)))
    g = max(0, min(255, round(g)))
    b = max(0, min(255, round(b)))
    return f"#{r:02X}{g:02X}{b:02X}"


def _srgb_linearize(c: float) -> float:
    """Convert sRGB channel (0-1) to linear RGB."""
    if c <= 0.04045:
        return c / 12.92
    return ((c + 0.055) / 1.055) ** 2.4


def _relative_luminance(hex_color: str) -> float:
    """Calculate sRGB relative luminance per WCAG 2.1.

    L = 0.2126*R + 0.7152*G + 0.0722*B
    where R, G, B are linearized sRGB values.
    """
    r, g, b = _hex_to_rgb(hex_color)
    r_lin = _srgb_linearize(r / 255.0)
    g_lin = _srgb_linearize(g / 255.0)
    b_lin = _srgb_linearize(b / 255.0)
    return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin


def _hex_to_hsl(hex_color: str) -> tuple[float, float, float]:
    """Convert hex color to HSL (h: 0-360, s: 0-1, l: 0-1)."""
    r, g, b = _hex_to_rgb(hex_color)
    h, l, s = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
    return h * 360.0, s, l


def _hsl_to_hex(h: float, s: float, l: float) -> str:
    """Convert HSL (h: 0-360, s: 0-1, l: 0-1) to hex color."""
    h_norm = (h % 360) / 360.0
    r, g, b = colorsys.hls_to_rgb(h_norm, l, s)
    return _rgb_to_hex(round(r * 255), round(g * 255), round(b * 255))


# ---------------------------------------------------------------------------
# Color-blind simulation matrices
# ---------------------------------------------------------------------------

# Confusion pair thresholds for each type of color vision deficiency.
# These simulate which hue ranges become indistinguishable.
_CVD_CONFUSION_PAIRS: dict[str, list[tuple[tuple[int, int], tuple[int, int]]]] = {
    # Deuteranopia: red-green confusion (most common, ~6% of males)
    "deuteranopia": [
        ((0, 30), (90, 150)),      # Red vs green
        ((30, 60), (90, 130)),     # Orange vs green
        ((330, 360), (90, 150)),   # Magenta-red vs green
    ],
    # Protanopia: red-green confusion (shifted, ~1% of males)
    "protanopia": [
        ((0, 40), (80, 140)),      # Red-orange vs green
        ((340, 360), (80, 140)),   # Red vs green
        ((0, 20), (170, 200)),     # Red vs cyan
    ],
    # Tritanopia: blue-yellow confusion (~0.01%)
    "tritanopia": [
        ((200, 270), (50, 100)),   # Blue vs yellow-green
        ((220, 260), (40, 80)),    # Blue vs yellow
        ((170, 210), (300, 340)),  # Cyan vs pink
    ],
}


def _hue_in_range(hue: float, hue_range: tuple[int, int]) -> bool:
    """Check if a hue falls within a range (handles wrap-around)."""
    low, high = hue_range
    if low <= high:
        return low <= hue <= high
    # Wrap-around (e.g., 330-30 means 330-360 and 0-30)
    return hue >= low or hue <= high


# ---------------------------------------------------------------------------
# D3.5.5 — Color palette intelligence
# ---------------------------------------------------------------------------

def validate_contrast(fg_hex: str, bg_hex: str) -> dict:
    """Calculate WCAG 2.1 contrast ratio between foreground and background.

    Uses sRGB relative luminance: L = 0.2126*R + 0.7152*G + 0.0722*B

    Args:
        fg_hex: Foreground color as hex string (e.g., "#1F2937").
        bg_hex: Background color as hex string (e.g., "#FFFFFF").

    Returns:
        Dict with ratio, AA/AAA pass/fail for normal and large text.
    """
    l1 = _relative_luminance(fg_hex)
    l2 = _relative_luminance(bg_hex)
    lighter = max(l1, l2)
    darker = min(l1, l2)
    ratio = (lighter + 0.05) / (darker + 0.05)

    return {
        "foreground": fg_hex,
        "background": bg_hex,
        "ratio": round(ratio, 2),
        "normal_text": {
            "AA": ratio >= 4.5,
            "AAA": ratio >= 7.0,
        },
        "large_text": {
            "AA": ratio >= 3.0,
            "AAA": ratio >= 4.5,
        },
    }


def check_color_blind_safety(colors: list[str]) -> dict:
    """Check a list of colors for color-blind confusion pairs.

    Simulates deuteranopia, protanopia, and tritanopia confusion.
    Flags color pairs that may become indistinguishable.

    Args:
        colors: List of hex color strings.

    Returns:
        Dict with per-deficiency results and flagged pairs.
    """
    hues = []
    for c in colors:
        h, s, l = _hex_to_hsl(c)
        hues.append({"hex": c, "hue": h, "saturation": s, "lightness": l})

    results: dict[str, Any] = {
        "safe": True,
        "colors_analyzed": len(colors),
        "issues": [],
    }

    for cvd_type, confusion_ranges in _CVD_CONFUSION_PAIRS.items():
        for i in range(len(hues)):
            for j in range(i + 1, len(hues)):
                c1 = hues[i]
                c2 = hues[j]

                # Skip very desaturated colors (grays) — distinguishable by
                # lightness alone regardless of hue perception.
                if c1["saturation"] < 0.15 or c2["saturation"] < 0.15:
                    continue

                # Check if lightness difference alone saves them.
                if abs(c1["lightness"] - c2["lightness"]) > 0.3:
                    continue

                for range_a, range_b in confusion_ranges:
                    a_in_a = _hue_in_range(c1["hue"], range_a)
                    b_in_b = _hue_in_range(c2["hue"], range_b)
                    a_in_b = _hue_in_range(c1["hue"], range_b)
                    b_in_a = _hue_in_range(c2["hue"], range_a)
                    if (a_in_a and b_in_b) or (a_in_b and b_in_a):
                        results["safe"] = False
                        results["issues"].append({
                            "type": cvd_type,
                            "color_a": c1["hex"],
                            "color_b": c2["hex"],
                            "severity": "warning",
                            "message": (
                                f"{c1['hex']} and {c2['hex']} may be "
                                f"indistinguishable for {cvd_type} viewers"
                            ),
                        })

    return results


def validate_palette(playbook: dict) -> list[dict]:
    """Run contrast and color-blind checks on all text/bg pairs in a playbook.

    Examines visual_language.color_palette and overlays for text/background
    combinations and chart_palette if present.

    Args:
        playbook: Loaded playbook dict.

    Returns:
        List of issue dicts with severity (error/warning/info).
    """
    issues: list[dict] = []
    palette = playbook.get("visual_language", {}).get("color_palette", {})
    bg = palette.get("background", "#FFFFFF")
    text = palette.get("text", "#000000")
    muted = palette.get("muted")

    # Check main text on background
    result = validate_contrast(text, bg)
    if not result["normal_text"]["AA"]:
        issues.append({
            "pair": f"text ({text}) on background ({bg})",
            "ratio": result["ratio"],
            "severity": "error",
            "message": f"Fails WCAG AA for normal text (ratio {result['ratio']}:1, need 4.5:1)",
        })
    elif not result["normal_text"]["AAA"]:
        issues.append({
            "pair": f"text ({text}) on background ({bg})",
            "ratio": result["ratio"],
            "severity": "info",
            "message": f"Passes AA but not AAA for normal text (ratio {result['ratio']}:1)",
        })

    # Check muted text on background
    if muted:
        result = validate_contrast(muted, bg)
        if not result["large_text"]["AA"]:
            issues.append({
                "pair": f"muted ({muted}) on background ({bg})",
                "ratio": result["ratio"],
                "severity": "error",
                "message": f"Muted text fails AA even for large text (ratio {result['ratio']}:1)",
            })
        elif not result["normal_text"]["AA"]:
            issues.append({
                "pair": f"muted ({muted}) on background ({bg})",
                "ratio": result["ratio"],
                "severity": "warning",
                "message": f"Muted text fails AA for normal text (ratio {result['ratio']}:1, OK for large)",
            })

    # Check overlay text/bg pairs
    overlays = playbook.get("overlays", {})
    for overlay_name, overlay in overlays.items():
        o_bg = overlay.get("bg")
        o_text = overlay.get("text")
        if o_bg and o_text:
            # Composite alpha colors against page background
            if _has_alpha(o_bg):
                o_bg = _composite_alpha(o_bg, bg)
            if _has_alpha(o_text):
                o_text = _composite_alpha(o_text, bg)
            result = validate_contrast(o_text, o_bg)
            if not result["normal_text"]["AA"]:
                issues.append({
                    "pair": f"overlay.{overlay_name}: text ({o_text}) on bg ({o_bg})",
                    "ratio": result["ratio"],
                    "severity": "error",
                    "message": (
                        f"Overlay '{overlay_name}' fails WCAG AA "
                        f"(ratio {result['ratio']}:1)"
                    ),
                })

    # Color-blind safety on primary + accent + chart_palette
    all_colors = []
    all_colors.extend(palette.get("primary", []))
    all_colors.extend(palette.get("accent", []))
    chart_palette = playbook.get("visual_language", {}).get(
        "color_palette", {}
    ).get("chart_palette") or playbook.get("chart_palette", [])
    all_colors.extend(chart_palette)

    if len(all_colors) >= 2:
        cvd_result = check_color_blind_safety(all_colors)
        for cvd_issue in cvd_result.get("issues", []):
            issues.append({
                "pair": f"{cvd_issue['color_a']} / {cvd_issue['color_b']}",
                "severity": "warning",
                "message": cvd_issue["message"],
            })

    return issues


def generate_harmony(base_hex: str, harmony_type: str) -> list[str]:
    """Generate a color harmony palette from a base color.

    Uses HSL math to create harmonious palettes.

    Args:
        base_hex: Base color as hex string.
        harmony_type: One of "complementary", "analogous", "triadic",
                      "split-complementary".

    Returns:
        List of hex color strings including the base color.
    """
    h, s, l = _hex_to_hsl(base_hex)

    if harmony_type == "complementary":
        offsets = [0, 180]
    elif harmony_type == "analogous":
        offsets = [-30, 0, 30]
    elif harmony_type == "triadic":
        offsets = [0, 120, 240]
    elif harmony_type == "split-complementary":
        offsets = [0, 150, 210]
    else:
        raise ValueError(
            f"Unknown harmony type: {harmony_type!r}. "
            f"Choose from: complementary, analogous, triadic, split-complementary"
        )

    return [_hsl_to_hex((h + offset) % 360, s, l) for offset in offsets]


# ---------------------------------------------------------------------------
# D3.5.6 — Typography intelligence
# ---------------------------------------------------------------------------

# Named modular type scale ratios
TYPE_SCALE_RATIOS: dict[str, float] = {
    "minor_second": 1.067,
    "major_second": 1.125,
    "minor_third": 1.2,
    "major_third": 1.25,
    "perfect_fourth": 1.333,
    "golden": 1.618,
}


def compute_type_scale(
    base_size: int, ratio: str = "major_third"
) -> dict:
    """Generate a modular type scale from a base size and ratio.

    Produces sizes for caption, body, subheading, heading, display levels.

    Args:
        base_size: Base font size in pixels (e.g., 24 for video).
        ratio: Named ratio string or a numeric string. Supported names:
               minor_second (1.067), major_second (1.125), minor_third (1.2),
               major_third (1.25), perfect_fourth (1.333), golden (1.618).

    Returns:
        Dict mapping role names to pixel sizes and the ratio used.
    """
    if ratio in TYPE_SCALE_RATIOS:
        r = TYPE_SCALE_RATIOS[ratio]
    else:
        try:
            r = float(ratio)
        except ValueError:
            raise ValueError(
                f"Unknown type scale ratio: {ratio!r}. "
                f"Choose from: {', '.join(TYPE_SCALE_RATIOS.keys())} or a number."
            )

    scale = {
        "ratio_name": ratio if ratio in TYPE_SCALE_RATIOS else "custom",
        "ratio_value": round(r, 4),
        "base_size_px": base_size,
        "sizes": {
            "caption": round(base_size / r),
            "body": base_size,
            "subheading": round(base_size * r),
            "heading": round(base_size * r ** 2),
            "display": round(base_size * r ** 3),
        },
    }
    return scale


def validate_type_hierarchy(playbook: dict) -> list[dict]:
    """Validate that typography sizes follow a clear hierarchy.

    Checks that heading > subheading > body > caption sizes are properly
    ordered with sufficient contrast between levels.

    Args:
        playbook: Loaded playbook dict.

    Returns:
        List of issue dicts. Empty list means hierarchy is valid.
    """
    issues: list[dict] = []
    typography = playbook.get("typography", {})

    # Extract size multipliers (or infer relative weights from weight values)
    roles = ["headings", "body", "code", "stat_card"]
    role_weights: dict[str, int] = {}
    role_multipliers: dict[str, float] = {}

    for role in roles:
        spec = typography.get(role, {})
        if spec:
            role_weights[role] = spec.get("weight", 400)
            role_multipliers[role] = spec.get("size_multiplier", 1.0)

    # Check that headings weight >= body weight
    head_w = role_weights.get("headings", 700)
    body_w = role_weights.get("body", 400)
    if head_w <= body_w:
        issues.append({
            "roles": "headings vs body",
            "severity": "warning",
            "message": (
                f"Heading weight ({head_w}) should be greater than "
                f"body weight ({body_w}) for clear hierarchy"
            ),
        })

    # Check that stat_card multiplier > 1.0 (should be larger than body)
    stat_mult = role_multipliers.get("stat_card", 1.0)
    if stat_mult <= 1.0:
        issues.append({
            "roles": "stat_card",
            "severity": "warning",
            "message": (
                f"stat_card size_multiplier ({stat_mult}) should be > 1.0 "
                f"for visual prominence"
            ),
        })

    # Check weight differentiation between heading and body is sufficient
    if head_w - body_w < 200:
        issues.append({
            "roles": "headings vs body",
            "severity": "info",
            "message": (
                f"Weight difference between headings ({head_w}) and "
                f"body ({body_w}) is only {head_w - body_w}. "
                f"Consider >= 200 difference for clear visual separation."
            ),
        })

    # Check scale_system if present
    scale_system = typography.get("scale_system")
    if scale_system and scale_system in TYPE_SCALE_RATIOS:
        ratio = TYPE_SCALE_RATIOS[scale_system]
        if ratio < 1.1:
            issues.append({
                "roles": "scale_system",
                "severity": "info",
                "message": (
                    f"Scale ratio '{scale_system}' ({ratio}) is very tight. "
                    f"Consider a larger ratio for video content."
                ),
            })

    return issues


# Known-good font pairings database
_FONT_PAIRINGS: dict[str, list[dict]] = {
    # Sans-serif fonts
    "Inter": [
        {
            "font": "Lora",
            "category": "serif",
            "rationale": "Geometric sans + transitional serif. High x-height match.",
        },
        {
            "font": "Playfair Display",
            "category": "serif",
            "rationale": "Clean sans + high-contrast serif for elegant contrast.",
        },
        {
            "font": "JetBrains Mono",
            "category": "monospace",
            "rationale": "Matched x-height for code blocks alongside Inter body text.",
        },
    ],
    "Space Grotesk": [
        {
            "font": "Space Mono",
            "category": "monospace",
            "rationale": "Same type family. Unified geometric DNA.",
        },
        {
            "font": "DM Serif Display",
            "category": "serif",
            "rationale": "Bold serif headlines with geometric sans body.",
        },
        {
            "font": "Fira Code",
            "category": "monospace",
            "rationale": "Ligature-rich code font pairs well with geometric body text.",
        },
    ],
    "IBM Plex Sans": [
        {
            "font": "IBM Plex Serif",
            "category": "serif",
            "rationale": "Same type family. Perfect metric compatibility.",
        },
        {
            "font": "IBM Plex Mono",
            "category": "monospace",
            "rationale": "Same type family. Unified design language for technical content.",
        },
        {
            "font": "Merriweather",
            "category": "serif",
            "rationale": "Humanist sans + humanist serif. Both optimized for screen readability.",
        },
    ],
    # Serif fonts
    "Lora": [
        {
            "font": "Inter",
            "category": "sans-serif",
            "rationale": "Transitional serif + geometric sans. Clean modern pairing.",
        },
        {
            "font": "Source Sans Pro",
            "category": "sans-serif",
            "rationale": "Classic serif + humanist sans. Traditional yet readable.",
        },
    ],
    "Playfair Display": [
        {
            "font": "Source Sans Pro",
            "category": "sans-serif",
            "rationale": "High-contrast display serif + neutral sans-serif body.",
        },
        {
            "font": "Raleway",
            "category": "sans-serif",
            "rationale": "Elegant serif + thin geometric sans for luxury feel.",
        },
    ],
    # Monospace fonts
    "JetBrains Mono": [
        {
            "font": "Inter",
            "category": "sans-serif",
            "rationale": "Matched x-height. Both designed for screen readability.",
        },
    ],
    "Fira Code": [
        {
            "font": "Fira Sans",
            "category": "sans-serif",
            "rationale": "Same type family. Unified design language.",
        },
        {
            "font": "Space Grotesk",
            "category": "sans-serif",
            "rationale": "Both geometric with similar proportions.",
        },
    ],
}

# Generic fallback suggestions by font category keywords
_CATEGORY_PAIRINGS: dict[str, list[dict]] = {
    "sans": [
        {
            "font": "Lora",
            "category": "serif",
            "rationale": "A versatile serif that pairs well with most sans-serif fonts.",
        },
        {
            "font": "Source Serif Pro",
            "category": "serif",
            "rationale": "Neutral serif with excellent readability alongside sans-serif.",
        },
    ],
    "serif": [
        {
            "font": "Inter",
            "category": "sans-serif",
            "rationale": "Clean geometric sans-serif that complements most serif fonts.",
        },
        {
            "font": "Source Sans Pro",
            "category": "sans-serif",
            "rationale": "Humanist sans-serif with broad serif compatibility.",
        },
    ],
    "mono": [
        {
            "font": "Inter",
            "category": "sans-serif",
            "rationale": "Versatile sans-serif body text alongside monospace code.",
        },
    ],
}


def suggest_font_pairing(primary_font: str) -> list[dict]:
    """Suggest complementary fonts for a given primary font.

    Returns known-good pairings from a curated database, falling back to
    category-based suggestions for unknown fonts.

    Args:
        primary_font: Primary font name (e.g., "Inter").

    Returns:
        List of dicts with font, category, and rationale.
    """
    # Direct lookup
    if primary_font in _FONT_PAIRINGS:
        return _FONT_PAIRINGS[primary_font]

    # Category-based fallback
    font_lower = primary_font.lower()
    if "mono" in font_lower or "code" in font_lower:
        return _CATEGORY_PAIRINGS["mono"]
    elif "serif" in font_lower and "sans" not in font_lower:
        return _CATEGORY_PAIRINGS["serif"]
    else:
        return _CATEGORY_PAIRINGS["sans"]


# ---------------------------------------------------------------------------
# D3.5.7 — Accessibility validation
# ---------------------------------------------------------------------------

# Minimum font size in pixels for video content readability
MIN_VIDEO_BODY_SIZE_PX = 24


def validate_accessibility(playbook: dict) -> dict:
    """Comprehensive accessibility validation for a playbook.

    Checks:
    - WCAG contrast ratios for all text/background pairs
    - Minimum font sizes for video readability (24px body minimum)
    - Color-blind safety for chart palettes and accent colors
    - Overlay text contrast

    Designed to be callable as a pre-render validation step.

    Args:
        playbook: Loaded playbook dict.

    Returns:
        Dict with overall pass/fail, score, and categorized issues.
    """
    issues: list[dict] = []

    # --- Contrast checks (reuse validate_palette) ---
    palette_issues = validate_palette(playbook)
    for pi in palette_issues:
        issues.append({
            "category": "contrast" if "ratio" in pi else "color_blind",
            **pi,
        })

    # --- Font size checks ---
    typography = playbook.get("typography", {})
    body_spec = typography.get("body", {})
    body_base_size = body_spec.get("size_multiplier", 1.0) * MIN_VIDEO_BODY_SIZE_PX

    # We check the scale_system if present to compute actual sizes
    scale_system = typography.get("scale_system")
    if scale_system:
        scale = compute_type_scale(MIN_VIDEO_BODY_SIZE_PX, scale_system)
        sizes = scale["sizes"]
        if sizes["caption"] < 16:
            issues.append({
                "category": "font_size",
                "severity": "warning",
                "message": (
                    f"Caption size ({sizes['caption']}px) is below 16px. "
                    f"May be unreadable on mobile video."
                ),
            })
    else:
        # No scale system — just check multiplier conventions
        stat_mult = typography.get("stat_card", {}).get("size_multiplier", 1.0)
        if stat_mult < 2.0:
            issues.append({
                "category": "font_size",
                "severity": "info",
                "message": (
                    f"stat_card size_multiplier ({stat_mult}) is modest. "
                    f"Consider >= 2.0x for video stat cards."
                ),
            })

    # --- Type hierarchy checks ---
    hierarchy_issues = validate_type_hierarchy(playbook)
    for hi in hierarchy_issues:
        issues.append({"category": "typography", **hi})

    # --- Chart palette color-blind check ---
    chart_palette = playbook.get("chart_palette", [])
    if chart_palette and len(chart_palette) >= 2:
        cvd_result = check_color_blind_safety(chart_palette)
        if not cvd_result["safe"]:
            for ci in cvd_result["issues"]:
                issues.append({
                    "category": "color_blind",
                    "severity": "warning",
                    "message": ci["message"],
                })

    # --- Weight matrix checks ---
    weight_matrix = typography.get("weight_matrix", {})
    if weight_matrix:
        expected_order = ["title", "heading", "body", "caption"]
        prev_weight = 1000
        for role in expected_order:
            w = weight_matrix.get(role)
            if w is not None and w > prev_weight:
                issues.append({
                    "category": "typography",
                    "severity": "warning",
                    "message": (
                        f"Weight matrix: '{role}' weight ({w}) should not "
                        f"exceed the weight of higher-priority roles."
                    ),
                })
            if w is not None:
                prev_weight = w

    # --- Compute overall result ---
    error_count = sum(1 for i in issues if i.get("severity") == "error")
    warning_count = sum(1 for i in issues if i.get("severity") == "warning")

    return {
        "pass": error_count == 0,
        "error_count": error_count,
        "warning_count": warning_count,
        "total_issues": len(issues),
        "issues": issues,
    }
