"""Genome schema validation."""

from __future__ import annotations

import re
from typing import Any

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    field_validator,
    model_validator,
)

# Golden ratio for rhythm validation
PHI: float = 1.618033988749895
PHI_TOLERANCE: float = 0.15  # 15% tolerance on rhythm ratios

_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
_RGBA_RE = re.compile(r"^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[0-9.]+\s*)?\)$")


def _is_hex(value: str) -> bool:
    return bool(_HEX_RE.match(value))


def _is_color(value: str) -> bool:
    """True for hex (``#RRGGBB``) or rgb/rgba (``rgba(R,G,B,A)``) values.

    Genomes that participate in atmospheric layering (v0.2.23 codex skin)
    declare translucent rgba surfaces so a backdrop gradient can bleed
    through. Genomes that don't atmosphere-layer keep using hex.
    """
    return _is_hex(value) or bool(_RGBA_RE.match(value))


def _parse_duration(value: str) -> float:
    v = value.strip().lower()
    if v.endswith("ms"):
        return float(v[:-2]) / 1000.0
    if v.endswith("s"):
        return float(v[:-1])
    return float(v)


# Canonical badge state-indicator shapes. Each maps to a
# templates/frames/badge/indicators/<shape>-indicator.j2 partial. Shared by
# GenomeSpec.state_glyph_shape, ComposeSpec.state_glyph_shape, ParadigmBadgeConfig
# .indicator_shape, and the variant-override validator — one source of truth so
# a slug can never reach the include dispatch without a partial behind it.
INDICATOR_SHAPES: frozenset[str] = frozenset({"square", "circle", "diamond"})


# -- Field-to-CSS mapping for the core genome properties --
_CORE_CSS_MAP: dict[str, str] = {
    "surface_0": "--dna-surface",
    "surface_1": "--dna-surface-alt",
    "surface_2": "--dna-surface-deep",
    "ink": "--dna-ink-primary",
    "ink_secondary": "--dna-ink-muted",
    "ink_on_accent": "--dna-ink-on-accent",
    "accent": "--dna-signal",
    "accent_complement": "--dna-signal-dim",
    "accent_signal": "--dna-status-passing-core",
    "accent_warning": "--dna-status-warning-core",
    "accent_error": "--dna-status-failing-core",
    "stroke": "--dna-border",
    "shadow_color": "--dna-shadow-color",
    "shadow_opacity": "--dna-shadow-opacity",
    "glow": "--dna-glow",
    "corner": "--dna-corner",
    "rhythm_base": "--dna-rhythm-base",
    "rhythm_slow": "--dna-rhythm-slow",
    "rhythm_fast": "--dna-rhythm-fast",
    "density": "--dna-density",
}

_EXTENDED_CSS_MAP: dict[str, str] = {
    "bg": "--dna-bg",
    "bg_alt": "--dna-bg-alt",
    "ink_bright": "--dna-ink-bright",
    "ink_sub": "--dna-ink-sub",
    "brand_text": "--dna-brand-text",
    "metric_text": "--dna-metric-text",
    "label_text": "--dna-label-text",
    "border_tint": "--dna-border-tint",
    "glyph_inner": "--dna-glyph-inner",
    "seam_gap": "--dna-seam-gap",
    "badge_value_text": "--dna-badge-value-text",
    "badge_pass_sep": "--dna-badge-pass-sep",
    "badge_warn_color": "--dna-badge-warn-color",
}

_MATERIAL_CSS_MAP: dict[str, str] = {
    "material_specular": "--dna-material-specular",
    "material_roughness": "--dna-material-roughness",
}


class GenomeSpec(BaseModel):
    """Complete genome definition with validation."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    # -- Identity --
    id: str = Field(description="Genome slug (e.g. 'brutalist')")
    name: str = Field(description="Human-readable name")
    category: str = Field(description="'dark' or 'light'")
    profile: str = Field(description="Profile ID reference (e.g. 'brutalist')")
    glyph_tint: dict[str, str] = Field(
        default_factory=dict,
        description=(
            "Per-frame default glyph fill selection (ink | brand | full), "
            "e.g. {'matrix': 'brand'}. Callers override via ComposeSpec "
            "glyph_tint; per-slot IR declarations outrank both. Frames "
            "absent from the dict default to ink."
        ),
    )

    # -- Surfaces --
    surface_0: str = Field(description="Primary surface color (hex)")
    surface_1: str = Field(description="Alternate surface color (hex)")
    surface_2: str = Field(description="Deep surface color (hex)")

    # -- Inks --
    ink: str = Field(description="Primary ink/text color (hex)")
    ink_secondary: str = Field(description="Secondary/muted ink color (hex)")
    ink_on_accent: str = Field(description="Ink on accent backgrounds (hex)")

    # -- Accents --
    accent: str = Field(description="Primary accent/signal color (hex)")
    accent_complement: str = Field(description="Complement accent (hex)")
    diamond_stroke: str = Field(
        default="",
        description=(
            "Chrome diamond ring stroke color (hex). Single-responsibility var "
            "consumed by --dna-diamond-stroke; eliminates --dna-signal-dim "
            "aliasing collisions on the chrome status indicator."
        ),
    )
    diamond_housing: str = Field(
        default="",
        description=(
            "Chrome diamond recessed housing fill color (hex). Single-responsibility "
            "var consumed by --dna-diamond-housing; pairs with diamond_stroke."
        ),
    )
    accent_signal: str = Field(description="Status passing color (hex)")
    accent_warning: str = Field(description="Status warning color (hex)")
    accent_error: str = Field(description="Status error/failing color (hex)")

    # -- Structure --
    stroke: str = Field(description="Border/stroke color (hex)")
    shadow_color: str = Field(description="Shadow color (hex)")
    shadow_opacity: str = Field(description="Shadow opacity (e.g. '0.08')")
    glow: str = Field(default="0px", description="Glow radius (CSS value)")
    corner: str = Field(description="Corner radius (CSS value)")

    # -- Rhythm --
    rhythm_base: str = Field(description="Base animation duration (CSS)")
    rhythm_slow: str = Field(default="", description="Slow rhythm (phi * base)")
    rhythm_fast: str = Field(default="", description="Fast rhythm (base / phi)")

    # -- Density --
    density: str = Field(description="Visual density multiplier")

    # -- Motion --
    compatible_motions: list[str] = Field(description="Allowed motion primitives for this genome")

    # -- Extended palette (optional, empty string = not set) --
    bg: str = Field(default="", description="Bridge allele: background")
    bg_alt: str = Field(default="", description="Bridge allele: alt background")
    ink_bright: str = Field(default="", description="Bridge allele: bright ink")
    ink_sub: str = Field(default="", description="Bridge allele: sub ink")
    brand_text: str = Field(default="", description="Brand text color")
    metric_text: str = Field(default="", description="Metric text color")
    label_text: str = Field(default="", description="Label text color")
    border_tint: str = Field(default="", description="Border tint for wells")
    glyph_inner: str = Field(default="", description="Glyph inner detail color")
    seam_gap: str = Field(default="", description="Seam gap fill between halves")
    frame_fill: str = Field(default="", description="Outer frame fill (darker than surface)")
    badge_value_text: str = Field(default="", description="Badge value text color")
    badge_pass_sep: str = Field(default="", description="Badge passing separator")
    badge_pass_core: str = Field(default="", description="Badge passing indicator inner fill (brighter than ring)")
    badge_warn_color: str = Field(default="", description="Badge warning override")

    # -- Material (optional) --
    material_specular: str = Field(default="", description="Specular intensity")
    material_roughness: str = Field(default="", description="Surface roughness")

    # -- Atmospheric backdrop (optional, v0.2.23) --
    # When non-empty, the receipt renders a full-canvas linear gradient as the
    # backdrop and insets the substrate card by ``card_inset`` pixels so the
    # atmosphere is visible as a colored ring around the card. Sourced from
    # the v9 codex specimen's ``codex-atmo`` gradient (linear top-left → bottom-right).
    atmosphere_stops: list[dict[str, str]] = Field(
        default_factory=list,
        description="Linear gradient stops painted full-canvas behind the substrate",
    )
    atmosphere_blooms: list[dict[str, Any]] = Field(
        default_factory=list,
        description=(
            "Radial gradient overlays painted between atmosphere and substrate. "
            "Each entry: {id, cx, cy, r, stops: [{offset, color, opacity}]}. "
            "Sourced from v9 codex ``bloom-cool`` and ``bloom-violet``."
        ),
    )
    card_top_highlight: bool = Field(
        default=False,
        description=(
            "When True, paint a 32px <color>→transparent linear gradient over the "
            "card's top edge (glass-edge highlight; v9 codex specimen). The color "
            "is ``card_top_highlight_color`` (required when this is True) — NOT "
            "ink, which is text tone and turns the edge into a dark wash on light "
            "skins."
        ),
    )
    card_top_highlight_color: str = Field(
        default="",
        description=(
            "Surface-light color for the card top-edge glass highlight, emitted "
            "as --dna-card-top-highlight. Required (non-empty) when "
            "card_top_highlight is True; enforced by _require_card_top_highlight_color."
        ),
    )
    card_inset: int = Field(
        default=0,
        description="Substrate inset in px when atmosphere_stops is active (v9 codex uses 6px)",
    )

    # -- Badge state-indicator shape (optional) --
    state_glyph_shape: str = Field(
        default="",
        description=(
            "Genome/variant default state-indicator shape: '' (defer to paradigm), "
            "'square', 'circle', or 'diamond'. Selects indicators/<shape>-indicator.j2. "
            "Overrides the paradigm default; overridden by request-time "
            "?state_glyph_shape=. Light brutalist variants set 'circle'."
        ),
    )

    # -- Chrome profile rendering (optional) --
    envelope_stops: list[dict[str, str]] = Field(
        default_factory=list, description="Chrome envelope gradient stops [{offset, color}]"
    )
    well_top: str = Field(default="", description="Chrome well gradient top color")
    well_bottom: str = Field(default="", description="Chrome well gradient bottom color")
    icon_well_top: str = Field(
        default="",
        description=(
            "Icon-specific well gradient top color (v0.2.16+). Lets the icon's small "
            "radial well use a more saturated navy than the wider marquee/strip well "
            "without forcing the same hex on every frame. Empty falls back to well_top."
        ),
    )
    icon_well_bottom: str = Field(
        default="",
        description="Icon-specific well gradient bottom color. Empty falls back to well_bottom.",
    )
    chrome_icon_inner_stroke: str = Field(
        default="",
        description="Chrome icon inner hairline color separating the envelope bezel from the well.",
    )
    chrome_icon_top_accent: str = Field(
        default="",
        description="Chrome square-icon top accent hairline color.",
    )
    highlight_color: str = Field(default="", description="Top highlight line color")
    highlight_opacity: str = Field(default="0.08", description="Top highlight opacity")
    chrome_text_gradient: list[dict[str, str]] = Field(
        default_factory=list, description="Chrome text gradient stops for title text"
    )
    hero_text_gradient: list[dict[str, str]] = Field(
        default_factory=list, description="Hero value text gradient stops (icy silver for chrome)"
    )

    # -- Path B variant grammar (v0.2.19) --
    # Genome-declared whitelist for ComposeSpec.variant. Empty list = no variant
    # axis (validated at resolve-time, not Pydantic field-validator). flagship_variant
    # is the genome's default when spec.variant=="" and paradigm has no per-frame
    # default. Together these enable adding variants without Python edits — same
    # extensibility story Invariant 12 brought to paradigms.
    variants: list[str] = Field(
        default_factory=list,
        description="Allowed values for ComposeSpec.variant. Empty = no variant axis.",
    )
    flagship_variant: str = Field(
        default="",
        description="Default variant when spec.variant is empty and no paradigm default exists.",
    )
    variant_overrides: dict[str, dict[str, Any]] = Field(
        default_factory=dict,
        description=(
            "Per-variant genome-field overrides. Keys are variant slugs (must subset "
            "of variants[]); values are sparse genome-field dicts. Two effects: (1) the "
            "assembler emits CSS-var-mappable fields as inline style on SVG root, (2) the "
            "resolver merges the dict into the genome before render so templates reading "
            "baked fields directly (envelope_stops, well_top, etc.) also see the variant. "
            "Values can be any genome-field shape: str (hex), list[dict[str, str]] "
            "(gradient stops), dict (light_mode, etc.). Used by chrome-style holistic "
            "palette swaps. Automata-style compositional tones use variant_tones."
        ),
    )
    variant_tones: dict[str, dict[str, Any]] = Field(
        default_factory=dict,
        description=(
            "Tone primitive palette (automata-style compositional). Keys are tone slugs "
            "(violet, teal, bone, etc.); values declare 14 chromatic fields per tone "
            "(rim_stops, cellular_cells, area_tiers, chart_levels, dormant_range, label_slab, "
            "seam_mid, label_text, value_text, canvas_top, canvas_bottom, info_accent, "
            "mid_accent, header_band). Resolved into cellular_palette context dict by "
            "compose/palette.py. Pairing is expressed at request time via the URL grammar "
            "modifier ?variant=primary&pair=secondary, which composes any two tones."
        ),
    )
    variant_phenomenology: dict[str, str] = Field(
        default_factory=dict,
        description=(
            "Per-variant phenomenological/aesthetic identity statements. Documents the "
            "naming philosophy ('afterimage = the optical echo persisting...'). Lives in "
            "genome config (not per-artifact metadata) so the same description applies "
            "regardless of which frame type renders the variant. Optional."
        ),
    )

    # -- Ontological classification (v0.3.2) --
    # Ring/stratum identifier consumed by the `hw:stratum` metadata field.
    # Brutalist sits in Ring 002-TRIBE; future genomes declare their own.
    stratum: str = Field(
        default="",
        description="Ring/ontology classification emitted as hw:stratum (e.g. '002-TRIBE').",
    )

    # -- Substrate-aware typography (v0.3.2) --
    # Light substrate templates pair Barlow Condensed headings with JetBrains Mono
    # body/data text. Empty falls back to `hero_font` from typography cascade.
    scholar_heading_font: str = Field(
        default="",
        description=(
            "Heading font stack for light-substrate templates (brutalist strip grammar). Pairs with "
            "mono_font for body. Empty falls back to typography.hero_font."
        ),
    )
    dividers: list[str] = Field(
        default_factory=list,
        description=(
            "Genome-themed divider slugs allowed on /v1/divider/{slug}/{genome}. Editorial generics "
            "(block, current, takeoff, void, zeropoint) are NOT in this list — they live at /a/inneraura/."
        ),
    )

    # -- Cellular pulse animation config (paradigm infrastructure) --
    # The 22 flat variant_blue_*/variant_purple_*/variant_bifamily_bridge_*
    # fields previously declared here moved into the v0.3.0 compositional
    # schema: variant_tones (tone primitives) consumed via cellular_palette
    # by the resolver rather than reading flat fields, so the schema stays
    # compact regardless of how many tones a genome ships.
    cellular_pulse_base_duration: str = Field(
        default="", description="Cellular pattern pulse base duration (e.g. '6s')"
    )
    cellular_pulse_fast_duration: str = Field(
        default="", description="Cellular pattern pulse fast duration (phi-derived, e.g. '3s')"
    )
    cellular_pattern_opacity: str = Field(default="", description="Cellular pattern group opacity (e.g. '0.78')")

    # -- State palette (per-status core + bright pair; promoted to top-level
    # so every genome can populate state-badge variants. Default "" keeps
    # current brutalist/chrome unchanged until backfilled.) --
    state_passing_core: str = Field(default="", description="State=passing core color (e.g. emerald-400)")
    state_passing_bright: str = Field(default="", description="State=passing bright value-text color")
    state_warning_core: str = Field(default="", description="State=warning core color (e.g. amber-400)")
    state_warning_bright: str = Field(default="", description="State=warning bright value-text color")
    state_critical_core: str = Field(default="", description="State=critical core color (e.g. red-400)")
    state_critical_bright: str = Field(default="", description="State=critical bright value-text color")
    state_building_core: str = Field(default="", description="State=building core color (e.g. violet-400)")
    state_building_bright: str = Field(default="", description="State=building bright value-text color")
    state_offline_core: str = Field(default="", description="State=offline core color (e.g. slate-400)")
    state_offline_bright: str = Field(default="", description="State=offline bright value-text color")

    # -- Status-glyph badge indicator fills (primer). Universal CI-semantic disc
    # colours for the per-state status icon; substrate-invariant (green/amber/red
    # read on light AND dark). Building uses the variant accent at render time;
    # housing uses surface/border. Default "" leaves other genomes unaffected. --
    badge_status_passing: str = Field(default="", description="Status-glyph passing core fill (CI green)")
    badge_status_warning: str = Field(default="", description="Status-glyph warning disc fill (CI amber)")
    badge_status_critical: str = Field(default="", description="Status-glyph critical disc fill (CI red)")
    badge_status_offline: str = Field(default="", description="Status-glyph offline dim core fill")
    status_knockout: str = Field(default="", description="Status-glyph symbol knockout (cross/bang) fill")
    badge_status_critical_rim: str = Field(
        default="", description="Status-glyph critical disc rim stroke (deepened red)"
    )
    badge_status_warning_rim: str = Field(
        default="", description="Status-glyph warning disc rim stroke (deepened amber)"
    )
    badge_status_critical_knockout: str = Field(
        default="", description="Status-glyph critical X knockout (light, reads on red disc)"
    )
    badge_status_warning_knockout: str = Field(
        default="", description="Status-glyph warning ! knockout (dark, reads on amber disc)"
    )
    strip_status_dot: str = Field(
        default="", description="Strip ACTIVE live-dot, substrate-harmonized green (distinct from accent_signal)"
    )
    accent_deep: str = Field(
        default="", description="Deep accent (darker than accent): chart hero value + line-gradient end stop"
    )

    fonts: list[str] = Field(
        default_factory=lambda: ["jetbrains-mono"],
        description="Font slugs to embed as base64 WOFF2 (e.g. 'orbitron', 'jetbrains-mono')",
    )
    light_mode: dict[str, str] | None = Field(default=None, description="Light mode color overrides")

    # -- Icon variant (optional) --
    icon_variant: str = Field(default="", description="Icon rendering variant (e.g. 'binary-opposition')")

    # -- Typography (optional, genome can override default font stacks) --
    font_display: str = Field(default="", description="Display font stack")
    font_mono: str = Field(default="", description="Monospace font stack")

    # -- Tool-class colors (telemetry frames only, optional) --
    tool_explore: str = Field(default="", description="Tool class color: explore (Read, Glob, Grep)")
    tool_execute: str = Field(default="", description="Tool class color: execute (Bash)")
    tool_mutate: str = Field(default="", description="Tool class color: mutate (Edit, Write)")
    tool_coordinate: str = Field(default="", description="Tool class color: coordinate (Agent, Task)")
    tool_reflect: str = Field(default="", description="Tool class color: reflect")

    # -- Receipt compositor tokens (telemetry skins only, v0.2.21) --
    # Per-element pill / glyph / card-frame surface that lets receipt.svg.j2
    # stay branch-free. Values can be ``"transparent"`` to render the element
    # invisibly without a template conditional. Non-telemetry genomes leave
    # these empty (default="") and the assembler skips emitting the CSS vars.
    pill_outer_bg: str = Field(default="", description="Receipt pill base panel fill (layered skins) or transparent")
    pill_outer_stroke: str = Field(default="", description="Receipt pill base frame stroke or transparent")
    pill_inner_bg: str = Field(default="", description="Receipt pill phosphor / inner fill")
    pill_text: str = Field(default="", description="Receipt pill label text color")
    pill_rule_top: str = Field(default="", description="Receipt pill top hairline color or transparent")
    pill_rule_bottom: str = Field(default="", description="Receipt pill bottom hairline color or transparent")
    pill_rx: int = Field(default=4, description="Receipt pill corner radius (0=square, 11=full pill)")
    glyph_fill: str = Field(default="", description="Provider glyph (Claude/Codex) outer path fill")
    card_border: str = Field(default="", description="Receipt card outer border stroke or transparent")
    card_border_top: str = Field(default="", description="Receipt card top accent stripe color or transparent")
    card_inner_glyph: str = Field(default="", description="Codex glyph inner cutout fill (typically surface)")
    treemap_accent_side: str = Field(
        default="top",
        description="Treemap row accent direction: 'top' (1.5px full-width bar) or 'left' (4px full-height bar)",
    )

    # -- Paradigm dispatch (Principle 26: three-layer taxonomy) --
    # Maps FrameType enum value -> paradigm slug. Resolver uses this to pick
    # templates/frames/{frame_type}/{paradigm}-content.j2 at render time.
    # Missing entries default to "default". Grows freely within a profile.
    paradigms: dict[str, str] = Field(
        default_factory=dict,
        description="Frame-type -> paradigm-name dispatch map (Principle 26)",
    )

    # -- Structural cascade (Principle 24: templates read these as context) --
    # Values: stroke_linejoin, data_point_shape, data_layout, fill_density,
    # shape_rendering, etc. Consumed by chart_engine + frame resolvers.
    structural: dict[str, Any] = Field(
        default_factory=dict,
        description="Structural rendering hints (stroke_linejoin, data_point_shape, etc.)",
    )

    # -- Typographic cascade (optional, nested override for font roles) --
    typography: dict[str, Any] = Field(
        default_factory=dict,
        description="Typography hints: hero_font, mono_font, weight_hierarchy, etc.",
    )

    # -- Material cascade (optional, surface/depth/filter hints) --
    material: dict[str, Any] = Field(
        default_factory=dict,
        description="Material hints: surface (matte/gloss), depth, filter_chain",
    )

    # -- Text metrics (optional, per-zone width factors for empirical calibration) --
    # The text-measurement LUT is Inter-calibrated; genomes that render with wider
    # fonts (e.g. Orbitron 900 for chrome badge values) declare a
    # -- Kinetic cascade (optional, motion timing + compatible vocab) --
    motion_config: dict[str, Any] = Field(
        default_factory=dict,
        description="Motion config: timing_base, energy_range, entrance, pulse",
    )

    @field_validator("category")
    @classmethod
    def validate_category(cls, v: str) -> str:
        if v not in ("dark", "light"):
            msg = f"category must be 'dark' or 'light', got '{v}'"
            raise ValueError(msg)
        return v

    @field_validator(
        "surface_0",
        "surface_1",
        "surface_2",
        "stroke",
    )
    @classmethod
    def validate_surface_colors(cls, v: str) -> str:
        """Surfaces and strokes accept hex OR rgba — rgba enables atmospheric
        translucency in genomes layered over a backdrop gradient (v0.2.23 codex).
        """
        if not _is_color(v):
            msg = f"Expected hex (#RRGGBB) or rgba(...) color, got '{v}'"
            raise ValueError(msg)
        return v

    @field_validator(
        "ink",
        "ink_secondary",
        "ink_on_accent",
        "accent",
        "accent_complement",
        "accent_signal",
        "accent_warning",
        "accent_error",
        "shadow_color",
    )
    @classmethod
    def validate_hex_colors(cls, v: str) -> str:
        """Ink and accent colors must be hex — rgba would alpha-blend text
        into the backdrop, which destroys readability."""
        if not _is_hex(v):
            msg = f"Expected hex color (#RRGGBB), got '{v}'"
            raise ValueError(msg)
        return v

    @field_validator("compatible_motions")
    @classmethod
    def validate_motions_include_static(cls, v: list[str]) -> list[str]:
        if "static" not in v:
            msg = "compatible_motions must include 'static'"
            raise ValueError(msg)
        return v

    @field_validator("state_glyph_shape")
    @classmethod
    def validate_state_glyph_shape(cls, v: str) -> str:
        """Empty (defer to paradigm) or one of the canonical indicator shapes.

        Bounds the slug at load so it can never reach the
        ``indicators/<shape>-indicator.j2`` include without a partial behind it.
        """
        if v and v not in INDICATOR_SHAPES:
            msg = f"state_glyph_shape must be empty or one of {sorted(INDICATOR_SHAPES)}, got '{v}'"
            raise ValueError(msg)
        return v

    @model_validator(mode="after")
    def compute_rhythm_derivatives(self) -> GenomeSpec:
        """Compute rhythm_slow and rhythm_fast from base if not provided."""
        base = _parse_duration(self.rhythm_base)
        slow = self.rhythm_slow
        fast = self.rhythm_fast

        if not slow:
            object.__setattr__(self, "rhythm_slow", f"{base * PHI:.3f}s")
        else:
            actual = _parse_duration(slow)
            expected = base * PHI
            ratio = abs(actual - expected) / expected
            if ratio > PHI_TOLERANCE:
                msg = (
                    f"rhythm_slow ({slow}) deviates {ratio:.0%} from "
                    f"phi * base ({expected:.3f}s). Limit: {PHI_TOLERANCE:.0%}."
                )
                raise ValueError(msg)

        if not fast:
            object.__setattr__(self, "rhythm_fast", f"{base / PHI:.3f}s")
        else:
            actual = _parse_duration(fast)
            expected = base / PHI
            ratio = abs(actual - expected) / expected
            if ratio > PHI_TOLERANCE:
                msg = (
                    f"rhythm_fast ({fast}) deviates {ratio:.0%} from "
                    f"base / phi ({expected:.3f}s). Limit: {PHI_TOLERANCE:.0%}."
                )
                raise ValueError(msg)

        return self

    @model_validator(mode="after")
    def _require_card_top_highlight_color(self) -> GenomeSpec:
        """Fail loud when the card top-highlight gate is on but its color is unset.

        ``card_top_highlight`` toggles a gradient that sources
        ``--dna-card-top-highlight``. With no ``card_top_highlight_color`` the
        token never emits and the CSS var resolves empty — a silent broken edge
        (the v0.3.10→v0.3.13 codex regression, where the edge fell through to
        ink and rendered a dark wash). Enforce the pair at load so a future
        genome can't flip the gate without supplying the surface-light color.
        """
        if self.card_top_highlight and not self.card_top_highlight_color:
            msg = (
                f"Genome '{self.id}' sets card_top_highlight=True but leaves "
                "card_top_highlight_color empty — the glass-edge gradient would "
                "render no color. Declare card_top_highlight_color (a surface-light "
                "hex, e.g. '#FFFFFF')."
            )
            raise ValueError(msg)
        return self

    def genome_to_css(self) -> dict[str, str]:
        """Return the complete field-name to CSS-property mapping."""
        result: dict[str, str] = {}
        result.update(_CORE_CSS_MAP)
        result.update(_EXTENDED_CSS_MAP)
        result.update(_MATERIAL_CSS_MAP)
        return result

    def to_css_vars(self) -> dict[str, str]:
        """Return dict of CSS custom property name to value."""
        mapping = self.genome_to_css()
        result: dict[str, str] = {}
        for field_name, css_prop in mapping.items():
            value = str(getattr(self, field_name, ""))
            if value:
                result[css_prop] = value
        return result
