"""Markdown spec parser — converts structured markdown presentation specs into PresentationDefinitions.

Two-layer architecture:
  1. Structural extraction: Parse markdown into NormalizedSpec IR (slides, tokens, global config)
  2. Semantic mapping: Convert NormalizedSpec into PresentationDefinition + BrandConfig

This enables agnostic intake of markdown specs regardless of which AI generated them.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Optional

from pptx_mcp.models.brand import (
    BrandConfig,
    ColorPalette,
    FontConfig,
    HeaderFooterConfig,
    LogoConfig,
    ShapeStyleConfig,
    SpacingConfig,
    ToneStyle,
)
from pptx_mcp.models.slides import (
    CalloutBox,
    CardDefinition,
    ChartData,
    ChartDataSeries,
    ChartElement,
    ChartType,
    ColumnContent,
    KpiCard,
    PresentationDefinition,
    PresentationMetadata,
    ProcessStep,
    SlideDefinition,
    SlideType,
    TableCell,
    TableElement,
)
from pptx_mcp.utils.logging import get_logger

logger = get_logger("engine.spec_parser")


# ---------------------------------------------------------------------------
# Intermediate representation (IR) — the NormalizedSpec
# ---------------------------------------------------------------------------


@dataclass
class ColorToken:
    """A named color from the spec's design system."""
    name: str
    value: str
    usage: str = ""


@dataclass
class TypographyToken:
    """A named typography style from the spec's design system."""
    name: str
    font: str = ""
    size: int = 0
    weight: str = ""
    color_ref: str = ""


@dataclass
class ContentBlock:
    """A content block within a spec slide (text, bullet list, card, table, etc.)."""
    block_type: str  # "text", "bullets", "cards", "table", "kpi", "callout", "chart"
    content: dict = field(default_factory=dict)


@dataclass
class SpecSlide:
    """A slide definition from the parsed markdown spec."""
    number: int
    title: str
    layout_hint: str = ""  # e.g. "two-column-asymmetric", "card-grid", "full-width"
    content_blocks: list[ContentBlock] = field(default_factory=list)
    notes: str = ""
    background: str = ""


@dataclass
class GlobalConfig:
    """Global design system extracted from the spec."""
    colors: list[ColorToken] = field(default_factory=list)
    typography: list[TypographyToken] = field(default_factory=list)
    slide_dimensions: tuple[float, float] = (13.333, 7.5)


@dataclass
class NormalizedSpec:
    """The intermediate representation between raw markdown and PresentationDefinition."""
    title: str = ""
    subtitle: str = ""
    author: str = ""
    company: str = ""
    global_config: GlobalConfig = field(default_factory=GlobalConfig)
    slides: list[SpecSlide] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Layer 1: Structural extraction (markdown → NormalizedSpec)
# ---------------------------------------------------------------------------


_SLIDE_HEADER = re.compile(r"^##\s+Slide\s+(\d+)\s*[:\-–]\s*(.+)", re.IGNORECASE)
_COLOR_TOKEN = re.compile(r"[`*]*(\w[\w\s]*?)[`*]*\s*[:\-–]\s*[`#]*(#[0-9A-Fa-f]{6})[`]*")
_HEX_COLOR = re.compile(r"#[0-9A-Fa-f]{6}")


def _extract_global_config(lines: list[str]) -> GlobalConfig:
    """Extract color tokens, typography, and dimensions from the spec header."""
    config = GlobalConfig()
    in_colors = False
    in_typography = False

    for line in lines:
        stripped = line.strip()
        lower = stripped.lower()

        # Section detection
        if "color" in lower and ("palette" in lower or "token" in lower or "system" in lower):
            in_colors = True
            in_typography = False
            continue
        if "typograph" in lower or "font" in lower:
            in_colors = False
            in_typography = True
            continue
        if stripped.startswith("##"):
            in_colors = False
            in_typography = False
            continue

        if in_colors:
            m = _COLOR_TOKEN.search(stripped)
            if m:
                name = m.group(1).strip().lower().replace(" ", "_")
                value = m.group(2)
                usage = stripped
                config.colors.append(ColorToken(name=name, value=value, usage=usage))

        if in_typography:
            # Simple heuristic: look for size mentions
            size_match = re.search(r"(\d+)\s*pt", stripped, re.IGNORECASE)
            if size_match and ":" in stripped:
                parts = stripped.split(":", 1)
                name = parts[0].strip().strip("*`- ").lower().replace(" ", "_")
                config.typography.append(TypographyToken(
                    name=name,
                    size=int(size_match.group(1)),
                ))

    return config


def _parse_content_blocks(lines: list[str]) -> list[ContentBlock]:
    """Parse content blocks from slide body lines."""
    blocks: list[ContentBlock] = []
    current_bullets: list[str] = []
    current_card_title: str | None = None
    current_card_bullets: list[str] = []

    def _flush_bullets() -> None:
        nonlocal current_bullets
        if current_bullets:
            blocks.append(ContentBlock(block_type="bullets", content={"items": current_bullets}))
            current_bullets = []

    def _flush_card() -> None:
        nonlocal current_card_title, current_card_bullets
        if current_card_title:
            blocks.append(ContentBlock(
                block_type="card",
                content={"title": current_card_title, "bullets": current_card_bullets},
            ))
            current_card_title = None
            current_card_bullets = []

    for line in lines:
        stripped = line.strip()

        # Card block detection: ### heading starts a new card
        if stripped.startswith("### "):
            _flush_bullets()
            _flush_card()
            current_card_title = stripped.lstrip("# ").strip()
            continue

        # Callout detection: > **Note:** / > **Warning:** / > **Info:** / > **Success:**
        if stripped.startswith("> "):
            callout_text = stripped[2:].strip()
            callout_match = re.match(
                r"\*\*(Note|Warning|Info|Success|Tip)\s*[:\-–]*\s*\*\*\s*(.*)",
                callout_text, re.IGNORECASE,
            )
            if callout_match:
                _flush_bullets()
                _flush_card()
                variant_map = {"note": "info", "info": "info", "tip": "info", "warning": "warning", "success": "success"}
                variant = variant_map.get(callout_match.group(1).lower(), "info")
                blocks.append(ContentBlock(
                    block_type="callout",
                    content={"variant": variant, "body": callout_match.group(2).strip()},
                ))
                continue

        # Bullet items — route to card or top-level
        if stripped.startswith(("- ", "* ", "• ")):
            bullet_text = stripped.lstrip("-*• ").strip()
            if current_card_title is not None:
                current_card_bullets.append(bullet_text)
            else:
                current_bullets.append(bullet_text)
            continue

        # Flush accumulated bullets on non-bullet line
        if current_card_title is None and current_bullets and not stripped.startswith(("- ", "* ", "• ")):
            _flush_bullets()

        # Table detection (markdown pipes)
        if stripped.startswith("|") and stripped.endswith("|"):
            _flush_card()
            cells = [c.strip() for c in stripped.strip("|").split("|")]
            if cells and not all(set(c) <= {"-", ":", " "} for c in cells):
                if blocks and blocks[-1].block_type == "table":
                    blocks[-1].content["rows"].append(cells)
                else:
                    blocks.append(ContentBlock(block_type="table", content={"rows": [cells]}))
            continue

        # KPI / metric detection (number-heavy lines)
        if re.search(r"\d+[%$MKBkmb]", stripped) and len(stripped) < 80:
            _flush_card()
            blocks.append(ContentBlock(block_type="kpi", content={"text": stripped}))
            continue

        # Plain text
        if stripped and not stripped.startswith("#"):
            _flush_card()
            blocks.append(ContentBlock(block_type="text", content={"text": stripped}))

    # Flush remaining
    _flush_bullets()
    _flush_card()

    return blocks


def extract_spec(markdown: str) -> NormalizedSpec:
    """Parse a markdown presentation spec into a NormalizedSpec IR.

    Handles common markdown spec formats:
    - `## Slide N: Title` headers for slide boundaries
    - Color palettes in the global design section
    - Bullet lists, tables, and KPI blocks within slides
    """
    lines = markdown.split("\n")
    spec = NormalizedSpec()

    # Extract title from first H1
    for line in lines:
        if line.strip().startswith("# ") and not line.strip().startswith("##"):
            spec.title = line.strip().lstrip("# ").strip()
            break

    # Find the first slide header to split preamble from slides
    first_slide_idx = None
    for i, line in enumerate(lines):
        if _SLIDE_HEADER.match(line.strip()):
            first_slide_idx = i
            break

    # Extract global config from preamble
    preamble = lines[:first_slide_idx] if first_slide_idx else lines
    spec.global_config = _extract_global_config(preamble)

    # Extract slides
    if first_slide_idx is not None:
        slide_starts: list[tuple[int, int, str]] = []
        for i, line in enumerate(lines):
            m = _SLIDE_HEADER.match(line.strip())
            if m:
                slide_starts.append((i, int(m.group(1)), m.group(2).strip()))

        for idx, (start, num, title) in enumerate(slide_starts):
            end = slide_starts[idx + 1][0] if idx + 1 < len(slide_starts) else len(lines)
            body_lines = lines[start + 1:end]
            content_blocks = _parse_content_blocks(body_lines)

            spec.slides.append(SpecSlide(
                number=num,
                title=title,
                content_blocks=content_blocks,
            ))

    logger.info(
        "Extracted spec: %d slides, %d color tokens, %d typography tokens",
        len(spec.slides),
        len(spec.global_config.colors),
        len(spec.global_config.typography),
    )
    return spec


# ---------------------------------------------------------------------------
# Layer 2: Semantic mapping (NormalizedSpec → PresentationDefinition)
# ---------------------------------------------------------------------------


_TYPE_KEYWORDS: dict[str, list[str]] = {
    "title": ["title slide", "cover"],
    "section": ["section", "divider"],
    "dashboard": ["dashboard", "kpi", "metrics", "scorecard"],
    "chart": ["chart", "graph", "trend"],
    "table": ["table", "matrix", "comparison table"],
    "process": ["process", "workflow", "steps"],
    "timeline": ["timeline", "roadmap", "milestones"],
    "quote": ["quote", "testimonial"],
    "big_number": ["big number", "hero metric", "key stat"],
    "team": ["team", "leadership", "people"],
    "closing": ["closing", "thank you", "next steps", "contact"],
    "swot": ["swot"],
    "funnel": ["funnel", "pipeline"],
    "agenda": ["agenda"],
    "two_column": ["two column", "split", "side by side"],
    "pros_cons": ["pros", "cons", "advantages", "disadvantages"],
    "comparison": ["comparison", "versus", "vs"],
    "icon_grid": ["values", "pillars", "principles", "features"],
    "card_grid": ["card grid", "cards", "capabilities", "services", "offerings"],
}


def _infer_slide_type(title: str, blocks: list[ContentBlock]) -> SlideType:
    """Infer the best SlideType from a spec slide's title and content blocks."""
    title_lower = title.lower()

    # Check keywords
    for type_name, keywords in _TYPE_KEYWORDS.items():
        for kw in keywords:
            if kw in title_lower:
                return SlideType(type_name)

    # Content-based inference
    has_table = any(b.block_type == "table" for b in blocks)
    has_kpi = any(b.block_type == "kpi" for b in blocks)
    has_bullets = any(b.block_type == "bullets" for b in blocks)
    has_cards = any(b.block_type == "card" for b in blocks)
    card_count = sum(1 for b in blocks if b.block_type == "card")
    bullet_count = sum(
        len(b.content.get("items", [])) for b in blocks if b.block_type == "bullets"
    )

    if has_cards and card_count >= 2:
        return SlideType.CARD_GRID
    if has_table:
        return SlideType.TABLE
    if has_kpi:
        return SlideType.DASHBOARD
    if bullet_count > 6:
        return SlideType.TWO_COLUMN
    if has_bullets:
        return SlideType.CONTENT

    return SlideType.CONTENT


def _map_slide(spec_slide: SpecSlide) -> SlideDefinition:
    """Convert a SpecSlide into a SlideDefinition."""
    slide_type = _infer_slide_type(spec_slide.title, spec_slide.content_blocks)

    # Collect all bullet items and text
    all_bullets: list[str] = []
    all_text: list[str] = []
    tables: list[TableElement] = []
    kpis: list[KpiCard] = []
    cards: list[CardDefinition] = []
    callout: CalloutBox | None = None

    for block in spec_slide.content_blocks:
        if block.block_type == "bullets":
            all_bullets.extend(block.content.get("items", []))
        elif block.block_type == "text":
            all_text.append(block.content.get("text", ""))
        elif block.block_type == "table":
            rows_raw = block.content.get("rows", [])
            if rows_raw:
                table_rows = [[TableCell(text=c) for c in row] for row in rows_raw]
                tables.append(TableElement(rows=table_rows, alternate_row_shading=True))
        elif block.block_type == "kpi":
            text = block.content.get("text", "")
            # Try to extract number and label
            m = re.match(r"([\d.,]+[%$MKBkmb]*)\s*[:\-–]?\s*(.*)", text)
            if m:
                kpis.append(KpiCard(value=m.group(1), label=m.group(2) or spec_slide.title))
        elif block.block_type == "card":
            cards.append(CardDefinition(
                title=block.content.get("title", ""),
                bullets=block.content.get("bullets", []),
            ))
        elif block.block_type == "callout" and callout is None:
            callout = CalloutBox(
                variant=block.content.get("variant", "info"),
                body=block.content.get("body", ""),
            )

    # Build SlideDefinition based on type
    kwargs: dict = {
        "slide_type": slide_type,
        "title": spec_slide.title,
    }

    if slide_type == SlideType.CARD_GRID:
        kwargs["cards"] = cards[:12]

    elif slide_type == SlideType.CONTENT:
        kwargs["body"] = "\n".join(all_text) if all_text else None
        kwargs["bullets"] = all_bullets[:10]

    elif slide_type == SlideType.TWO_COLUMN:
        mid = len(all_bullets) // 2
        kwargs["columns"] = [
            ColumnContent(bullets=all_bullets[:mid]),
            ColumnContent(bullets=all_bullets[mid:]),
        ]

    elif slide_type == SlideType.TABLE:
        if tables:
            kwargs["elements"] = tables

    elif slide_type == SlideType.DASHBOARD:
        if kpis:
            kwargs["kpis"] = kpis
        elif all_bullets:
            kwargs["bullets"] = all_bullets[:6]

    elif slide_type in (SlideType.PROCESS, SlideType.TIMELINE):
        kwargs["steps"] = [
            ProcessStep(title=b, description=None) for b in all_bullets[:5]
        ]

    elif slide_type in (SlideType.TITLE, SlideType.SECTION, SlideType.CLOSING):
        kwargs["subtitle"] = all_text[0] if all_text else None

    else:
        kwargs["bullets"] = all_bullets[:10]

    # Attach callout if detected
    if callout:
        kwargs["callout"] = callout

    return SlideDefinition(**kwargs)


def _map_brand(global_config: GlobalConfig) -> Optional[BrandConfig]:
    """Convert spec global config to a BrandConfig if enough tokens are present."""
    colors = {t.name: t.value for t in global_config.colors}
    if len(colors) < 2:
        return None

    # Map common token names to ColorPalette fields
    palette_kwargs: dict = {}
    custom: dict[str, str] = {}

    _field_map = {
        "primary": ["primary", "primary_color", "brand_primary"],
        "secondary": ["secondary", "secondary_color"],
        "accent": ["accent", "accent_color", "highlight"],
        "background": ["background", "bg", "surface_white", "white"],
        "text_primary": ["text_primary", "text_dark", "dark", "charcoal"],
        "text_secondary": ["text_secondary", "text_light", "text_muted", "medium_gray"],
        "success": ["success", "green", "positive"],
        "danger": ["danger", "red", "negative", "error"],
    }

    for field_name, aliases in _field_map.items():
        for alias in aliases:
            if alias in colors:
                palette_kwargs[field_name] = colors[alias]
                break

    # Anything not mapped goes to custom_tokens
    mapped_values = set(palette_kwargs.values())
    for name, value in colors.items():
        if value not in mapped_values:
            custom[name] = value

    if "primary" not in palette_kwargs:
        # Use first color as primary
        first = next(iter(colors.values()))
        palette_kwargs["primary"] = first
    if "secondary" not in palette_kwargs:
        palette_kwargs["secondary"] = palette_kwargs["primary"]
    if "accent" not in palette_kwargs:
        palette_kwargs["accent"] = palette_kwargs.get("secondary", palette_kwargs["primary"])

    palette_kwargs["custom_tokens"] = custom

    return BrandConfig(
        name="Spec Brand",
        colors=ColorPalette(**palette_kwargs),
    )


def spec_to_presentation(
    spec: NormalizedSpec,
) -> tuple[PresentationDefinition, Optional[BrandConfig]]:
    """Convert a NormalizedSpec into a PresentationDefinition and optional BrandConfig.

    Returns:
        Tuple of (PresentationDefinition, BrandConfig or None).
    """
    slides = [_map_slide(s) for s in spec.slides]
    brand = _map_brand(spec.global_config)

    definition = PresentationDefinition(
        metadata=PresentationMetadata(
            title=spec.title or "Untitled",
            author=spec.author or None,
            company=spec.company or None,
        ),
        slides=slides,
    )

    return definition, brand


# ---------------------------------------------------------------------------
# Public API — single-call convenience
# ---------------------------------------------------------------------------


def parse_spec(markdown: str) -> tuple[PresentationDefinition, Optional[BrandConfig]]:
    """Parse a markdown presentation spec end-to-end.

    Args:
        markdown: Raw markdown text of the presentation spec.

    Returns:
        Tuple of (PresentationDefinition, BrandConfig or None).
    """
    spec = extract_spec(markdown)
    return spec_to_presentation(spec)


# ---------------------------------------------------------------------------
# Brand guide parser — markdown/YAML → BrandConfig
# ---------------------------------------------------------------------------

_TONE_ALIASES: dict[str, str] = {
    "executive": "executive",
    "technical": "technical",
    "creative": "creative",
    "minimal": "minimal",
    "corporate": "corporate",
    "healthcare": "healthcare",
    "startup": "startup",
    "academic": "academic",
    "government": "government",
    "finance": "finance",
    "nonprofit": "nonprofit",
}

_FONT_SIZE_PATTERN = re.compile(r"(\d+)\s*pt", re.IGNORECASE)
_FLOAT_PATTERN = re.compile(r"([\d.]+)")
_INT_PATTERN = re.compile(r"(\d+)")
_BOOL_TRUE = {"true", "yes", "on", "1", "enabled"}
_BOOL_FALSE = {"false", "no", "off", "0", "disabled"}


def _parse_bool(value: str) -> bool | None:
    """Parse a boolean from a string value. Returns None if unrecognized."""
    v = value.strip().lower()
    if v in _BOOL_TRUE:
        return True
    if v in _BOOL_FALSE:
        return False
    return None


def _split_kv(line: str) -> tuple[str, str] | None:
    """Split a 'key: value' or 'key - value' line. Returns None if no split found."""
    stripped = line.strip().lstrip("-*• ").strip()
    for sep in (":", "–", "—"):
        if sep in stripped:
            parts = stripped.split(sep, 1)
            key = parts[0].strip().strip("`*_")
            val = parts[1].strip().strip("`*_")
            if key and val:
                return key, val
    return None


def _normalize_key(key: str) -> str:
    """Normalize a key to snake_case lowercase."""
    return re.sub(r"[\s\-]+", "_", key.lower().strip())


def _split_sections(lines: list[str]) -> dict[str, list[str]]:
    """Split markdown lines into sections keyed by ## heading (lowercased)."""
    sections: dict[str, list[str]] = {}
    current: str | None = None
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("## "):
            current = stripped.lstrip("# ").strip().lower()
            sections[current] = []
        elif stripped.startswith("# ") and not stripped.startswith("##"):
            # H1 — treat as preamble / name
            sections["_name"] = [stripped.lstrip("# ").strip()]
            current = None
        elif current is not None:
            sections[current].append(line)
    return sections


def _parse_colors_section(lines: list[str]) -> dict:
    """Parse a ## Colors section into ColorPalette kwargs."""
    colors: dict[str, str] = {}
    custom: dict[str, str] = {}
    chart_colors: list[str] = []

    _field_map = {
        "primary": ["primary", "primary_color", "brand_primary"],
        "secondary": ["secondary", "secondary_color"],
        "accent": ["accent", "accent_color", "highlight"],
        "background": ["background", "bg", "surface_white", "white"],
        "text_primary": ["text_primary", "text_dark", "dark", "charcoal", "text"],
        "text_secondary": ["text_secondary", "text_light", "text_muted", "muted"],
        "success": ["success", "green", "positive"],
        "danger": ["danger", "red", "negative", "error"],
        "surface": ["surface", "card_bg", "card_background"],
        "border": ["border", "divider", "border_color"],
    }

    for line in lines:
        stripped = line.strip()
        # Chart colors list
        if "chart" in stripped.lower() and "color" in stripped.lower():
            hex_matches = _HEX_COLOR.findall(stripped)
            if hex_matches:
                chart_colors.extend(hex_matches)
            continue

        m = _COLOR_TOKEN.search(stripped)
        if m:
            name = _normalize_key(m.group(1))
            value = m.group(2)
            colors[name] = value

    # Map to palette fields
    palette_kwargs: dict = {}
    for field_name, aliases in _field_map.items():
        for alias in aliases:
            if alias in colors:
                palette_kwargs[field_name] = colors[alias]
                break

    # Ensure required fields
    if "primary" not in palette_kwargs and colors:
        palette_kwargs["primary"] = next(iter(colors.values()))
    if "secondary" not in palette_kwargs:
        palette_kwargs["secondary"] = palette_kwargs.get("primary", "#333333")
    if "accent" not in palette_kwargs:
        palette_kwargs["accent"] = palette_kwargs.get("secondary", palette_kwargs.get("primary", "#0066CC"))

    # Anything not mapped goes to custom_tokens
    mapped_values = set(palette_kwargs.values())
    for name, value in colors.items():
        if value not in mapped_values:
            custom[name] = value

    if custom:
        palette_kwargs["custom_tokens"] = custom
    if chart_colors:
        palette_kwargs["chart_colors"] = chart_colors

    return palette_kwargs


def _parse_typography_section(lines: list[str]) -> dict:
    """Parse a ## Typography section into FontConfig kwargs."""
    kwargs: dict = {}

    _field_map = {
        "heading_font": ["heading_font", "heading", "headings", "title_font"],
        "body_font": ["body_font", "body", "body_text"],
        "mono_font": ["mono_font", "mono", "monospace", "code_font", "code"],
        "display_font": ["display_font", "display", "hero_font", "hero"],
        "title_size": ["title_size", "title"],
        "heading_size": ["heading_size", "heading"],
        "subheading_size": ["subheading_size", "subheading", "subtitle_size"],
        "body_size": ["body_size", "body"],
        "caption_size": ["caption_size", "caption", "small"],
        "display_size": ["display_size", "display"],
        "line_spacing": ["line_spacing", "line_height"],
        "paragraph_spacing": ["paragraph_spacing", "paragraph_space"],
        "font_weight_heading": ["font_weight_heading", "heading_weight"],
        "font_weight_body": ["font_weight_body", "body_weight"],
    }

    _size_fields = {
        "title_size", "heading_size", "subheading_size", "body_size",
        "caption_size", "display_size",
    }
    _float_fields = {"line_spacing", "paragraph_spacing"}

    for line in lines:
        kv = _split_kv(line)
        if not kv:
            continue
        key, val = kv
        norm = _normalize_key(key)

        for field_name, aliases in _field_map.items():
            if norm in aliases:
                if field_name in _size_fields:
                    m = _FONT_SIZE_PATTERN.search(val) or _INT_PATTERN.search(val)
                    if m:
                        kwargs[field_name] = int(m.group(1))
                elif field_name in _float_fields:
                    m = _FLOAT_PATTERN.search(val)
                    if m:
                        kwargs[field_name] = float(m.group(1))
                elif field_name in ("font_weight_heading", "font_weight_body"):
                    kwargs[field_name] = val.strip()
                else:
                    # Font name
                    kwargs[field_name] = val.strip()
                break

    return kwargs


def _parse_spacing_section(lines: list[str]) -> dict:
    """Parse a ## Spacing section into SpacingConfig kwargs."""
    kwargs: dict = {}

    _field_map = {
        "margin_left": ["margin_left", "left_margin"],
        "margin_right": ["margin_right", "right_margin"],
        "margin_top": ["margin_top", "top_margin"],
        "margin_bottom": ["margin_bottom", "bottom_margin"],
        "title_top": ["title_top"],
        "content_top": ["content_top"],
        "element_gap": ["element_gap", "gap"],
        "column_gap": ["column_gap"],
        "padding": ["padding"],
        "base_unit_pt": ["base_unit_pt", "base_unit", "grid_unit"],
    }

    for line in lines:
        kv = _split_kv(line)
        if not kv:
            continue
        key, val = kv
        norm = _normalize_key(key)

        for field_name, aliases in _field_map.items():
            if norm in aliases:
                if field_name == "base_unit_pt":
                    m = _INT_PATTERN.search(val)
                    if m:
                        kwargs[field_name] = int(m.group(1))
                else:
                    m = _FLOAT_PATTERN.search(val)
                    if m:
                        kwargs[field_name] = float(m.group(1))
                break

    return kwargs


def _parse_shape_style_section(lines: list[str]) -> dict:
    """Parse a ## Shape Style section into ShapeStyleConfig kwargs."""
    kwargs: dict = {}

    _field_map = {
        "card_corner_radius": ["card_corner_radius", "corner_radius", "radius"],
        "card_shadow": ["card_shadow", "shadow"],
        "shadow_blur_pt": ["shadow_blur_pt", "shadow_blur"],
        "shadow_offset_pt": ["shadow_offset_pt", "shadow_offset"],
        "shadow_opacity_pct": ["shadow_opacity_pct", "shadow_opacity"],
        "card_border_width_pt": ["card_border_width_pt", "border_width", "card_border"],
        "show_accent_bar": ["show_accent_bar", "accent_bar"],
        "shadow_preset": ["shadow_preset"],
        "edge_bar": ["edge_bar"],
        "edge_bar_width": ["edge_bar_width"],
        "divider_style": ["divider_style", "divider"],
        "line_dash_style": ["line_dash_style", "line_dash", "dash_style"],
        "text_auto_fit": ["text_auto_fit", "auto_fit"],
    }

    _bool_fields = {"card_shadow", "show_accent_bar", "text_auto_fit"}
    _float_fields = {"card_corner_radius", "shadow_blur_pt", "shadow_offset_pt", "card_border_width_pt", "edge_bar_width"}
    _int_fields = {"shadow_opacity_pct"}
    _literal_fields = {"shadow_preset", "edge_bar", "divider_style", "line_dash_style"}

    for line in lines:
        kv = _split_kv(line)
        if not kv:
            continue
        key, val = kv
        norm = _normalize_key(key)

        for field_name, aliases in _field_map.items():
            if norm in aliases:
                if field_name in _bool_fields:
                    b = _parse_bool(val)
                    if b is not None:
                        kwargs[field_name] = b
                elif field_name in _float_fields:
                    m = _FLOAT_PATTERN.search(val)
                    if m:
                        kwargs[field_name] = float(m.group(1))
                elif field_name in _int_fields:
                    m = _INT_PATTERN.search(val)
                    if m:
                        kwargs[field_name] = int(m.group(1))
                elif field_name in _literal_fields:
                    kwargs[field_name] = val.strip().lower()
                break

    return kwargs


def _parse_header_footer_section(lines: list[str]) -> dict:
    """Parse a ## Header/Footer section into HeaderFooterConfig kwargs."""
    kwargs: dict = {}

    _field_map = {
        "show_slide_numbers": ["show_slide_numbers", "slide_numbers"],
        "show_date": ["show_date", "date"],
        "date_text": ["date_text"],
        "show_company_name": ["show_company_name", "company_name"],
        "confidential_marking": ["confidential_marking", "confidential"],
        "skip_title_slide": ["skip_title_slide"],
    }

    _bool_fields = {"show_slide_numbers", "show_date", "show_company_name", "skip_title_slide"}

    for line in lines:
        kv = _split_kv(line)
        if not kv:
            continue
        key, val = kv
        norm = _normalize_key(key)

        for field_name, aliases in _field_map.items():
            if norm in aliases:
                if field_name in _bool_fields:
                    b = _parse_bool(val)
                    if b is not None:
                        kwargs[field_name] = b
                else:
                    kwargs[field_name] = val.strip()
                break

    return kwargs


def _parse_logo_section(lines: list[str]) -> dict:
    """Parse a ## Logo section into LogoConfig kwargs."""
    kwargs: dict = {}

    _field_map = {
        "path": ["path", "logo_path", "file"],
        "placement": ["placement", "position"],
        "max_height": ["max_height", "height"],
        "skip_on": ["skip_on"],
    }

    for line in lines:
        kv = _split_kv(line)
        if not kv:
            continue
        key, val = kv
        norm = _normalize_key(key)

        for field_name, aliases in _field_map.items():
            if norm in aliases:
                if field_name == "max_height":
                    m = _FLOAT_PATTERN.search(val)
                    if m:
                        kwargs[field_name] = float(m.group(1))
                elif field_name == "skip_on":
                    kwargs[field_name] = [s.strip() for s in val.split(",")]
                elif field_name == "placement":
                    kwargs[field_name] = val.strip().lower().replace(" ", "_")
                else:
                    kwargs[field_name] = val.strip()
                break

    return kwargs


def parse_brand_guide(text: str) -> BrandConfig:
    """Parse a markdown or YAML brand guide into a BrandConfig.

    Accepts two input formats:
    - **Markdown**: Sections headed by ``## Colors``, ``## Typography``,
      ``## Shape Style``, ``## Spacing``, ``## Header/Footer``, ``## Logo``.
    - **YAML**: Auto-detected when input does not start with ``#``. Parsed
      directly into BrandConfig via ``model_validate``.

    Args:
        text: Raw markdown or YAML text describing the brand.

    Returns:
        A fully populated BrandConfig (missing sections use defaults).
    """
    stripped = text.strip()

    # Auto-detect YAML: if the first non-empty line does not start with '#'
    if stripped and not stripped.startswith("#"):
        import yaml

        data = yaml.safe_load(stripped)
        if isinstance(data, dict):
            return BrandConfig.model_validate(data)
        raise ValueError("YAML brand guide must be a mapping at the top level")

    # Markdown path
    lines = text.split("\n")
    sections = _split_sections(lines)

    # Brand name from H1 or fallback
    name = "Custom Brand"
    if "_name" in sections:
        name = sections["_name"][0] if sections["_name"] else "Custom Brand"

    # Tone — check for a tone/style section or inline in preamble
    tone = ToneStyle.EXECUTIVE
    for section_name in sections:
        if "tone" in section_name or "style" in section_name:
            for line in sections[section_name]:
                kv = _split_kv(line)
                if kv:
                    val = _normalize_key(kv[1])
                    if val in _TONE_ALIASES:
                        tone = ToneStyle(_TONE_ALIASES[val])
                        break

    # Parse each section
    color_kwargs: dict = {}
    font_kwargs: dict = {}
    spacing_kwargs: dict = {}
    shape_kwargs: dict = {}
    hf_kwargs: dict = {}
    logo_kwargs: dict = {}

    for section_name, section_lines in sections.items():
        lower = section_name.lower()
        if "color" in lower and lower != "_name":
            color_kwargs = _parse_colors_section(section_lines)
        elif "typograph" in lower or "font" in lower:
            font_kwargs = _parse_typography_section(section_lines)
        elif "shape" in lower or "card" in lower:
            shape_kwargs = _parse_shape_style_section(section_lines)
        elif "spacing" in lower or "margin" in lower or "layout" in lower:
            spacing_kwargs = _parse_spacing_section(section_lines)
        elif "header" in lower or "footer" in lower:
            hf_kwargs = _parse_header_footer_section(section_lines)
        elif "logo" in lower:
            logo_kwargs = _parse_logo_section(section_lines)

    # Build BrandConfig — use defaults for missing sections
    brand_kwargs: dict = {"name": name, "tone": tone}

    if color_kwargs:
        brand_kwargs["colors"] = ColorPalette(**color_kwargs)
    else:
        brand_kwargs["colors"] = ColorPalette(
            primary="#1B365D", secondary="#2E86AB", accent="#E8630A",
        )

    if font_kwargs:
        brand_kwargs["fonts"] = FontConfig(**font_kwargs)
    if spacing_kwargs:
        brand_kwargs["spacing"] = SpacingConfig(**spacing_kwargs)
    if shape_kwargs:
        brand_kwargs["shape_style"] = ShapeStyleConfig(**shape_kwargs)
    if hf_kwargs:
        brand_kwargs["header_footer"] = HeaderFooterConfig(**hf_kwargs)
    if logo_kwargs:
        brand_kwargs["logo"] = LogoConfig(**logo_kwargs)

    logger.info("Parsed brand guide: %s (%d color tokens)", name, len(color_kwargs))
    return BrandConfig(**brand_kwargs)
