"""PPTX parser — reads existing .pptx files into structured PresentationDefinition."""

from __future__ import annotations

import re
from pathlib import Path
from typing import Optional

from pptx import Presentation

from pptx_mcp.models.diagrams import (
    CycleStep,
    GanttTask,
    MatrixData,
    MatrixQuadrant,
    OrgNode,
    PyramidLayer,
    SpokeItem,
    VennCircle,
    VennData,
    WaterfallBar,
)
from pptx_mcp.models.slides import (
    AgendaItem,
    ChartData,
    ChartDataSeries,
    ChartElement,
    ChartType,
    ColumnContent,
    FunnelStage,
    KpiCard,
    Paragraph,
    Position,
    PresentationDefinition,
    PresentationMetadata,
    ProcessStep,
    ShapeElement,
    ShapeType,
    SlideDefinition,
    SlideElement,
    SlideType,
    SwotData,
    TableCell,
    TableElement,
    TeamMember,
    TextBox,
    TextRun,
    TextStyle,
)
from pptx_mcp.utils.errors import ParseError
from pptx_mcp.utils.logging import get_logger

logger = get_logger("engine.parser")

# Pattern to detect big numbers/percentages (e.g. "47%", "$1.2M", "3.5x", "10,000")
_BIG_NUMBER_RE = re.compile(
    r"^[\$\€\£]?\s*[\d,]+\.?\d*\s*[%xXMBKmkTt]?$"
)

# Bullet characters used by python-pptx and common presentations
_BULLET_CHARS = {"•", "●", "○", "■", "□", "►", "▪", "▸", "‣", "–", "—", "∙", "·"}

# Title keyword patterns for slide type detection (lowercased matching)
_TITLE_KEYWORDS: dict[SlideType, re.Pattern[str]] = {
    SlideType.AGENDA: re.compile(r"\b(agenda|outline|today'?s topics)\b", re.I),
    SlideType.DASHBOARD: re.compile(r"\b(dashboard|key metrics|kpi|financial overview|scorecard)\b", re.I),
    SlideType.SWOT: re.compile(r"\b(swot|strategic assessment)\b", re.I),
    SlideType.PROCESS: re.compile(r"\b(process|approach|methodology|how we|initiatives?|implementation)\b", re.I),
    SlideType.TIMELINE: re.compile(r"\b(timeline|roadmap|milestones)\b", re.I),
    SlideType.TEAM: re.compile(r"\b(the team|our team|leadership|team members)\b", re.I),
    SlideType.FUNNEL: re.compile(r"\b(funnel|pipeline|conversion)\b", re.I),
    SlideType.CLOSING: re.compile(r"\b(thank|next steps|get started|the ask|contact|closing)\b", re.I),
    SlideType.ICON_GRID: re.compile(r"\b(why us|our values|features|capabilities|what we offer)\b", re.I),
    SlideType.PROS_CONS: re.compile(r"\b(pros|cons|risk|trade.?off|advantages)\b", re.I),
    SlideType.STEP_CARDS: re.compile(r"\b(step[s ]|how to|getting started)\b", re.I),
    SlideType.CARD_GRID: re.compile(r"\b(services|offerings|our products|solutions|packages)\b", re.I),
    # Diagram types (v0.6.0)
    SlideType.ORG_CHART: re.compile(r"\b(org.?chart|organization|reporting.?structure|hierarchy)\b", re.I),
    SlideType.MATRIX: re.compile(r"\b(matrix|quadrant|2x2|prioriti[sz]ation.?matrix)\b", re.I),
    SlideType.PYRAMID: re.compile(r"\b(pyramid|hierarchy.?levels?|tier model)\b", re.I),
    SlideType.VENN: re.compile(r"\b(venn|overlap|intersection)\b", re.I),
    SlideType.WATERFALL: re.compile(r"\b(waterfall|bridge.?chart|revenue.?bridge|variance)\b", re.I),
    SlideType.HUB_SPOKE: re.compile(r"\b(hub.?(?:and|&)?.?spoke|ecosystem|radial)\b", re.I),
    SlideType.CYCLE: re.compile(r"\b(cycle|continuous.?loop|circular.?process|iterative)\b", re.I),
    SlideType.GANTT: re.compile(r"\b(gantt|project.?plan|project.?timeline|work.?breakdown)\b", re.I),
}


_REVERSE_SHAPE_MAP: dict[int, ShapeType] = {
    1: ShapeType.RECTANGLE,
    5: ShapeType.ROUNDED_RECTANGLE,
    9: ShapeType.OVAL,
    55: ShapeType.ARROW_RIGHT,
    66: ShapeType.ARROW_LEFT,
    68: ShapeType.ARROW_UP,
    67: ShapeType.ARROW_DOWN,
    52: ShapeType.CHEVRON,
    4: ShapeType.DIAMOND,
    7: ShapeType.TRIANGLE,
    56: ShapeType.PENTAGON,
    10: ShapeType.HEXAGON,
}


def _extract_fill_color(obj: object) -> str | None:
    """Extract solid fill color as hex string from a shape, textbox, or table cell."""
    try:
        fill = obj.fill  # type: ignore[attr-defined]
        if fill.type is not None:
            rgb = fill.fore_color.rgb
            if rgb is not None:
                return f"#{rgb}"
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _extract_border_color(shape: object) -> str | None:
    """Extract border/line color from a shape as hex string."""
    try:
        line = shape.line  # type: ignore[attr-defined]
        if line.color and line.color.rgb is not None:
            return f"#{line.color.rgb}"
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _extract_border_width(shape: object) -> float | None:
    """Extract border/line width from a shape in points."""
    try:
        line = shape.line  # type: ignore[attr-defined]
        if line.width is not None:
            return round(line.width / 12700, 2)  # EMU to points
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _emu_to_inches(emu: int | None) -> float:
    """Convert EMU to inches."""
    if emu is None:
        return 0.0
    return round(emu / 914400, 3)


def _extract_position(shape: object) -> Position:
    """Extract position from a pptx shape object."""
    return Position(
        left=_emu_to_inches(getattr(shape, "left", 0)),
        top=_emu_to_inches(getattr(shape, "top", 0)),
        width=_emu_to_inches(getattr(shape, "width", 0)),
        height=_emu_to_inches(getattr(shape, "height", 0)),
    )


def _extract_text_style(run: object) -> Optional[TextStyle]:
    """Extract text style from a run object."""
    font = getattr(run, "font", None)
    if font is None:
        return None

    color = None
    try:
        rgb = font.color.rgb
        if rgb is not None:
            color = f"#{rgb}"
    except (AttributeError, TypeError):
        pass

    size = None
    if font.size is not None:
        size = int(font.size.pt)

    return TextStyle(
        font_name=font.name,
        font_size=size,
        bold=bool(font.bold),
        italic=bool(font.italic),
        color=color,
    )


def _extract_paragraph(para: object) -> Paragraph:
    """Extract a Paragraph model from a pptx paragraph."""
    runs = []
    for run in para.runs:  # type: ignore[attr-defined]
        style = _extract_text_style(run)
        runs.append(TextRun(text=run.text, style=style))

    alignment = None
    if para.alignment is not None:  # type: ignore[attr-defined]
        align_map = {0: "left", 1: "center", 2: "right", 3: "justify"}
        alignment = align_map.get(int(para.alignment), None)  # type: ignore[attr-defined]

    return Paragraph(
        runs=runs,
        level=para.level if hasattr(para, "level") else 0,  # type: ignore[attr-defined]
        alignment=alignment,
    )


def _classify_slide(shapes: list, slide_index: int, total_slides: int = 0) -> SlideType:
    """Heuristic classification of slide type based on shape contents.

    Detection order (first match wins):
    1. Has chart shape -> CHART
    2. Has table shape -> TABLE
    3. First slide in deck -> TITLE
    4. Only 1 short title, no content -> SECTION
    5. Has 1 large number/percentage as main text -> BIG_NUMBER
    6. Has italic/quoted text with attribution -> QUOTE
    7. Keyword + structure-based detection for advanced types
    8. Has 2+ text columns side by side -> TWO_COLUMN
    9. Default -> CONTENT
    """
    text_shapes = [s for s in shapes if hasattr(s, "text_frame")]
    chart_shapes = [s for s in shapes if getattr(s, "has_chart", False)]
    table_shapes = [s for s in shapes if getattr(s, "has_table", False)]

    # 1. Chart detection
    if chart_shapes:
        return SlideType.CHART

    # 2. Table detection
    if table_shapes:
        return SlideType.TABLE

    # Gather text info for remaining heuristics
    texts_with_info: list[dict] = []
    for s in text_shapes:
        tf = s.text_frame
        text = tf.text.strip()
        if not text:
            continue
        font_size_pt = 0.0
        is_italic = False
        is_bold = False
        for para in tf.paragraphs:
            if para.runs:
                r = para.runs[0]
                if r.font.size:
                    font_size_pt = r.font.size.pt
                is_italic = bool(r.font.italic)
                is_bold = bool(r.font.bold)
                break
        texts_with_info.append({
            "text": text,
            "word_count": len(text.split()),
            "font_size": font_size_pt,
            "is_italic": is_italic,
            "is_bold": is_bold,
            "left": _emu_to_inches(getattr(s, "left", 0)),
            "top": _emu_to_inches(getattr(s, "top", 0)),
            "width": _emu_to_inches(getattr(s, "width", 0)),
            "height": _emu_to_inches(getattr(s, "height", 0)),
        })

    total_text = " ".join(t["text"] for t in texts_with_info)
    total_words = len(total_text.split()) if total_text.strip() else 0

    # 3. Title slide: first slide with limited text, OR centered layout
    #    with large heading in the middle of the slide (y > 2.0)
    if slide_index == 0 and total_words < 30:
        return SlideType.TITLE

    # 3b. Title slide by layout: ≤3 centered text shapes, large font, vertically centered
    if total_words < 30 and len(texts_with_info) <= 3:
        centered_shapes = [
            t for t in texts_with_info
            if t["top"] > 2.0 and t["font_size"] >= 20
        ]
        if centered_shapes and len(centered_shapes) <= 2:
            has_large_title = any(t["font_size"] >= 28 for t in centered_shapes)
            if has_large_title:
                return SlideType.TITLE

    # Identify the title text (largest font or highest position)
    title_text = ""
    if texts_with_info:
        sorted_by_top = sorted(texts_with_info, key=lambda t: t["top"])
        for t in sorted_by_top:
            if t["font_size"] >= 24 or t["top"] < 1.5:
                title_text = t["text"]
                break
        if not title_text:
            title_text = sorted_by_top[0]["text"]

    # Count non-title, non-footer shapes in content area
    content_shapes = [s for s in shapes if hasattr(s, "text_frame") or hasattr(s, "shape_type")]
    non_footer_shapes = [
        s for s in content_shapes
        if _emu_to_inches(getattr(s, "top", 0)) < 6.5  # exclude footer area
    ]
    total_non_footer = len(non_footer_shapes)

    # 4. Section slide: very short content (single heading, <5 words)
    non_decorative = [t for t in texts_with_info if t["word_count"] > 0]
    if len(non_decorative) <= 2 and total_words < 8:
        has_heading = any(t["font_size"] >= 24 for t in non_decorative)
        if has_heading or (len(non_decorative) == 1 and non_decorative[0]["word_count"] <= 5):
            # But first check if it could be a closing slide (last slide + keywords)
            if total_slides > 0 and slide_index >= total_slides - 2:
                if _TITLE_KEYWORDS[SlideType.CLOSING].search(title_text):
                    return SlideType.CLOSING
            return SlideType.SECTION

    # 5. Big number
    large_texts = [t for t in texts_with_info if t["font_size"] >= 48]
    if large_texts:
        candidate = large_texts[0]["text"].strip()
        if _BIG_NUMBER_RE.match(candidate):
            return SlideType.BIG_NUMBER

    # 6. Quote
    italic_texts = [t for t in texts_with_info if t["is_italic"] and t["word_count"] >= 5]
    if italic_texts:
        non_italic = [t for t in texts_with_info if not t["is_italic"] and t["text"] != italic_texts[0]["text"]]
        has_attribution = any(
            t["text"].startswith("\u2014") or t["text"].startswith("--") or t["text"].startswith("-")
            for t in non_italic
        )
        if has_attribution or (italic_texts[0]["font_size"] >= 24 and len(non_italic) <= 3):
            return SlideType.QUOTE

    # 7. Keyword + structure-based detection for advanced types
    # Check title keywords against known patterns
    keyword_match = _keyword_classify(title_text, total_text, total_non_footer, total_slides, slide_index)
    if keyword_match is not None:
        return keyword_match

    # 8. Two column: 2+ text shapes with distinct left positions in content area
    # Only count clusters with 2+ shapes each (filters out process/icon layouts)
    content_texts = [t for t in texts_with_info if t["top"] > 1.5]
    if len(content_texts) >= 4:
        lefts = sorted(t["left"] for t in content_texts)
        cluster_map: dict[float, int] = {}
        for l_val in lefts:
            found = False
            for rep in cluster_map:
                if abs(l_val - rep) <= 1.0:
                    cluster_map[rep] += 1
                    found = True
                    break
            if not found:
                cluster_map[l_val] = 1
        # Only count clusters with 2+ shapes as real columns
        real_clusters = [k for k, v in cluster_map.items() if v >= 2]
        if len(real_clusters) == 2:
            return SlideType.TWO_COLUMN
        if len(real_clusters) >= 3:
            return SlideType.COMPARISON

    # 9. Default: CONTENT
    return SlideType.CONTENT


def _keyword_classify(
    title_text: str,
    total_text: str,
    total_non_footer_shapes: int,
    total_slides: int,
    slide_index: int,
) -> SlideType | None:
    """Use title keywords and shape structure to detect advanced slide types.

    All keyword-based checks run FIRST. The shape-count fallback only triggers
    if no keyword matched, preventing false positives for dashboard.
    """
    # Closing: last 2 slides with closing keywords
    if total_slides > 0 and slide_index >= total_slides - 2:
        if _TITLE_KEYWORDS[SlideType.CLOSING].search(title_text):
            return SlideType.CLOSING

    # SWOT: keyword match — very distinctive, check before dashboard
    if _TITLE_KEYWORDS[SlideType.SWOT].search(title_text):
        return SlideType.SWOT
    swot_labels = sum(1 for w in ["strengths", "weaknesses", "opportunities", "threats"]
                      if w in total_text.lower())
    if swot_labels >= 3:
        return SlideType.SWOT

    # Agenda: keyword match — check before dashboard (agendas have many shapes)
    if _TITLE_KEYWORDS[SlideType.AGENDA].search(title_text):
        return SlideType.AGENDA

    # Process: keyword match
    if _TITLE_KEYWORDS[SlideType.PROCESS].search(title_text):
        return SlideType.PROCESS

    # Timeline: keyword match
    if _TITLE_KEYWORDS[SlideType.TIMELINE].search(title_text):
        return SlideType.TIMELINE

    # Team: keyword match
    if _TITLE_KEYWORDS[SlideType.TEAM].search(title_text):
        return SlideType.TEAM

    # Funnel: keyword match
    if _TITLE_KEYWORDS[SlideType.FUNNEL].search(title_text):
        return SlideType.FUNNEL

    # Card grid: keyword match
    if _TITLE_KEYWORDS[SlideType.CARD_GRID].search(title_text):
        return SlideType.CARD_GRID

    # Icon grid: keyword match
    if _TITLE_KEYWORDS[SlideType.ICON_GRID].search(title_text):
        return SlideType.ICON_GRID

    # Pros/cons: keyword match
    if _TITLE_KEYWORDS[SlideType.PROS_CONS].search(title_text):
        return SlideType.PROS_CONS

    # Step cards: keyword match
    if _TITLE_KEYWORDS[SlideType.STEP_CARDS].search(title_text):
        return SlideType.STEP_CARDS

    # Dashboard: keyword match (checked after more specific types)
    if _TITLE_KEYWORDS[SlideType.DASHBOARD].search(title_text):
        return SlideType.DASHBOARD

    # Diagram types (v0.6.0) — keyword match
    if _TITLE_KEYWORDS[SlideType.ORG_CHART].search(title_text):
        return SlideType.ORG_CHART
    if _TITLE_KEYWORDS[SlideType.MATRIX].search(title_text):
        return SlideType.MATRIX
    if _TITLE_KEYWORDS[SlideType.PYRAMID].search(title_text):
        return SlideType.PYRAMID
    if _TITLE_KEYWORDS[SlideType.VENN].search(title_text):
        return SlideType.VENN
    if _TITLE_KEYWORDS[SlideType.WATERFALL].search(title_text):
        return SlideType.WATERFALL
    if _TITLE_KEYWORDS[SlideType.HUB_SPOKE].search(title_text):
        return SlideType.HUB_SPOKE
    if _TITLE_KEYWORDS[SlideType.CYCLE].search(title_text):
        return SlideType.CYCLE
    if _TITLE_KEYWORDS[SlideType.GANTT].search(title_text):
        return SlideType.GANTT

    # Shape-count fallback: only if no keyword matched above
    if total_non_footer_shapes >= 12:
        return SlideType.DASHBOARD

    return None


def parse_presentation(path: str | Path) -> PresentationDefinition:
    """Parse an existing PPTX file into a PresentationDefinition.

    Args:
        path: Path to the .pptx file.

    Returns:
        Parsed PresentationDefinition.

    Raises:
        ParseError: If the file cannot be read or parsed.
    """
    path = Path(path)
    if not path.exists():
        raise ParseError(f"File not found: {path.name}")

    try:
        prs = Presentation(str(path))
    except Exception as e:
        raise ParseError(f"Failed to open PPTX file '{path.name}'") from e

    metadata = PresentationMetadata(
        title=prs.core_properties.title or path.stem,
        author=prs.core_properties.author,
        subject=prs.core_properties.subject,
        category=prs.core_properties.category,
    )

    slides: list[SlideDefinition] = []
    total_slides = len(prs.slides)

    for slide_index, slide in enumerate(prs.slides):
        try:
            slide_def = _parse_slide(slide, slide_index, total_slides)
            slides.append(slide_def)
        except Exception as e:
            logger.warning("Failed to parse slide %d: %s", slide_index, e)
            slides.append(SlideDefinition(
                slide_type=SlideType.BLANK,
                speaker_notes="[Parse error on this slide]",
            ))

    width_inches = round(prs.slide_width / 914400, 3) if prs.slide_width else 13.333
    height_inches = round(prs.slide_height / 914400, 3) if prs.slide_height else 7.5

    return PresentationDefinition(
        metadata=metadata,
        slides=slides,
        width_inches=width_inches,
        height_inches=height_inches,
    )


def _extract_text_shapes_info(shapes: list) -> list[dict]:
    """Extract rich info from text-bearing shapes for slide parsing.

    Returns a list of dicts with text, font_size, is_italic, position info,
    bullets list, and the raw paragraphs.
    """
    result: list[dict] = []
    for shape in shapes:
        if not hasattr(shape, "text_frame"):
            continue
        tf = shape.text_frame
        text = tf.text.strip()
        if not text:
            continue

        # Extract first-run styling
        font_size_pt = 0.0
        is_italic = False
        is_bold = False
        for para in tf.paragraphs:
            if para.runs:
                r = para.runs[0]
                if r.font.size:
                    font_size_pt = r.font.size.pt
                is_italic = bool(r.font.italic)
                is_bold = bool(r.font.bold)
                break

        # Extract bullets from this shape using multiple detection methods
        shape_bullets: list[str] = []
        has_bullets = False
        _a_ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
        non_empty_paras = [p for p in tf.paragraphs if p.text.strip()]
        for para in non_empty_paras:
            para_text = para.text.strip()
            # Method 1: Check for buChar XML element
            pPr = para._p.find(f"{_a_ns}pPr")
            if pPr is not None:
                buChar = pPr.find(f"{_a_ns}buChar")
                buAutoNum = pPr.find(f"{_a_ns}buAutoNum")
                buFont = pPr.find(f"{_a_ns}buFont")
                if buChar is not None or buAutoNum is not None or buFont is not None:
                    has_bullets = True
                    shape_bullets.append(para_text)
                    continue
            # Method 2: Paragraph level > 0 indicates indented bullet
            if hasattr(para, "level") and para.level and para.level > 0:
                has_bullets = True
                shape_bullets.append(para_text)
                continue
            # Method 3: Text starts with bullet character
            if para_text and para_text[0] in _BULLET_CHARS:
                has_bullets = True
                # Strip the bullet character
                shape_bullets.append(para_text[1:].strip())
                continue
        # Method 4: Multi-paragraph text frame with 3+ short paragraphs
        # (likely a bullet list even without explicit bullet markers)
        if not has_bullets and len(non_empty_paras) >= 3:
            all_short = all(len(p.text.strip().split()) <= 20 for p in non_empty_paras)
            if all_short:
                has_bullets = True
                shape_bullets = [p.text.strip() for p in non_empty_paras]

        # Extract structured paragraphs
        paragraphs = [_extract_paragraph(para) for para in tf.paragraphs]

        result.append({
            "shape": shape,
            "text": text,
            "word_count": len(text.split()),
            "font_size": font_size_pt,
            "is_italic": is_italic,
            "is_bold": is_bold,
            "left": _emu_to_inches(getattr(shape, "left", 0)),
            "top": _emu_to_inches(getattr(shape, "top", 0)),
            "width": _emu_to_inches(getattr(shape, "width", 0)),
            "height": _emu_to_inches(getattr(shape, "height", 0)),
            "has_bullets": has_bullets,
            "bullets": shape_bullets,
            "paragraphs": paragraphs,
        })
    return result


def _extract_title_from_texts(texts_info: list[dict]) -> tuple[str | None, str | None]:
    """Extract title and subtitle from text shape info.

    Returns (title, subtitle). Identifies title by font size or top position.
    Never identifies a bullet-containing text frame as a subtitle.
    """
    if not texts_info:
        return None, None

    title = None
    subtitle = None

    # Sort by top position to find the highest text
    sorted_by_top = sorted(texts_info, key=lambda t: t["top"])

    title_top = 0.0
    for t in sorted_by_top:
        if title is None and len(t["text"]) < 200:
            # Title: large font, or near top of slide, or first short text
            if t["font_size"] >= 24 or t["top"] < 1.5:
                title = t["text"]
                title_top = t["top"]
                continue

        if title is not None and subtitle is None:
            # Subtitle: next text after title, smaller font, short,
            # must be close to title (within 1.0"), not a bullet text
            if (t["word_count"] < 30
                    and t["font_size"] < (texts_info[0].get("font_size", 24))
                    and not t.get("has_bullets", False)
                    and "\n" not in t["text"]
                    and t["top"] - title_top < 1.0):
                subtitle = t["text"]
                break

    # Fallback: first text as title if nothing was detected
    if title is None and sorted_by_top:
        title = sorted_by_top[0]["text"]

    return title, subtitle


def _parse_slide(slide: object, slide_index: int, total_slides: int = 0) -> SlideDefinition:
    """Parse a single slide into a SlideDefinition with type-aware field mapping."""
    shapes = list(slide.shapes)  # type: ignore[attr-defined]
    slide_type = _classify_slide(shapes, slide_index, total_slides)

    # Extract speaker notes
    notes = None
    try:
        if slide.has_notes_slide:  # type: ignore[attr-defined]
            notes_slide = slide.notes_slide  # type: ignore[attr-defined]
            notes = notes_slide.notes_text_frame.text.strip() or None
    except Exception:
        pass

    # Separate shape types
    chart_shapes = [s for s in shapes if hasattr(s, "has_chart") and s.has_chart]
    table_shapes = [s for s in shapes if hasattr(s, "has_table") and s.has_table]
    texts_info = _extract_text_shapes_info(shapes)

    # Parse chart and table elements
    elements: list[SlideElement] = []
    for s in chart_shapes:
        elem = _parse_chart(s)
        if elem:
            elements.append(elem)
    for s in table_shapes:
        elem = _parse_table(s)
        if elem:
            elements.append(elem)

    # Extract title and subtitle
    title, subtitle = _extract_title_from_texts(texts_info)

    # Collect all bullets and body text from non-title shapes
    all_bullets: list[str] = []
    body_parts: list[str] = []
    non_title_texts: list[dict] = []

    for t in texts_info:
        if t["text"] == title or t["text"] == subtitle:
            continue
        non_title_texts.append(t)
        if t["has_bullets"]:
            all_bullets.extend(t["bullets"])
        else:
            body_parts.append(t["text"])

    # Add remaining text shapes as elements for non-title/non-bullet content
    for t in non_title_texts:
        if not t["has_bullets"] and t["text"] not in (title, subtitle):
            elements.append(TextBox(
                paragraphs=t["paragraphs"],
                position=_extract_position(t["shape"]),
                fill_color=_extract_fill_color(t["shape"]),
            ))

    # Also collect non-text, non-chart, non-table shapes (including auto shapes
    # with text_frame but empty text — these are geometric shapes, not textboxes)
    _text_shape_ids = {id(t["shape"]) for t in texts_info}
    for shape in shapes:
        has_chart = hasattr(shape, "has_chart") and shape.has_chart
        has_table = hasattr(shape, "has_table") and shape.has_table
        is_text_shape = id(shape) in _text_shape_ids
        has_shape_type = hasattr(shape, "shape_type")
        if not has_chart and not has_table and not is_text_shape and has_shape_type:
            # Detect actual shape type from auto_shape_type
            detected_type = ShapeType.RECTANGLE
            try:
                auto_type = shape.auto_shape_type
                if auto_type is not None:
                    detected_type = _REVERSE_SHAPE_MAP.get(int(auto_type), ShapeType.RECTANGLE)
            except (AttributeError, TypeError, ValueError):
                pass
            elements.append(ShapeElement(
                shape_type=detected_type,
                text=getattr(shape, "text", None),
                position=_extract_position(shape),
                fill_color=_extract_fill_color(shape),
                border_color=_extract_border_color(shape),
                border_width=_extract_border_width(shape),
            ))

    # Build SlideDefinition based on detected type
    if slide_type == SlideType.TITLE:
        return SlideDefinition(
            slide_type=SlideType.TITLE,
            title=title,
            subtitle=subtitle or (body_parts[0] if body_parts else None),
            speaker_notes=notes,
        )

    if slide_type == SlideType.SECTION:
        return SlideDefinition(
            slide_type=SlideType.SECTION,
            title=title,
            subtitle=subtitle or (body_parts[0] if body_parts else None),
            speaker_notes=notes,
        )

    if slide_type == SlideType.BIG_NUMBER:
        # The large number goes in body, the label goes in title
        # Find the large-font number text
        big_number_text = None
        label_text = title
        for t in texts_info:
            if t["font_size"] >= 48 and _BIG_NUMBER_RE.match(t["text"].strip()):
                big_number_text = t["text"].strip()
                # If the big number was used as title, find the next best title
                if big_number_text == title:
                    other_texts = [x for x in texts_info if x["text"] != big_number_text]
                    label_text = other_texts[0]["text"] if other_texts else None
                break
        return SlideDefinition(
            slide_type=SlideType.BIG_NUMBER,
            body=big_number_text or (body_parts[0] if body_parts else "0"),
            title=label_text,
            subtitle=subtitle,
            bullets=all_bullets[:1],  # Optional comparison text
            speaker_notes=notes,
        )

    if slide_type == SlideType.QUOTE:
        # Find the italic (quote) text and attribution
        quote_text = None
        attribution = None
        attribution_role = None
        for t in texts_info:
            if t["is_italic"] and t["word_count"] >= 5 and quote_text is None:
                quote_text = t["text"]
            elif quote_text is not None and attribution is None:
                # Text after quote is attribution
                attr_text = t["text"]
                if attr_text.startswith("\u2014") or attr_text.startswith("--"):
                    attr_text = attr_text.lstrip("\u2014- ").strip()
                attribution = attr_text
            elif attribution is not None and attribution_role is None:
                attribution_role = t["text"]
        return SlideDefinition(
            slide_type=SlideType.QUOTE,
            title=title if title != quote_text else None,
            body=quote_text or (body_parts[0] if body_parts else ""),
            quote_attribution=attribution,
            quote_attribution_role=attribution_role,
            speaker_notes=notes,
        )

    if slide_type == SlideType.TWO_COLUMN:
        # Split content into two columns by left position
        content_texts = [t for t in non_title_texts if t["top"] > 1.5]
        if content_texts:
            lefts = sorted(set(t["left"] for t in content_texts))
            # Find midpoint between column clusters
            midpoint = (lefts[0] + lefts[-1]) / 2
            left_col_texts = [t for t in content_texts if t["left"] < midpoint]
            right_col_texts = [t for t in content_texts if t["left"] >= midpoint]

            columns: list[ColumnContent] = []
            for col_texts in (left_col_texts, right_col_texts):
                col_title = None
                col_bullets: list[str] = []
                for t in sorted(col_texts, key=lambda x: x["top"]):
                    if col_title is None and t["is_bold"] and t["word_count"] < 10:
                        col_title = t["text"]
                    elif t["has_bullets"]:
                        col_bullets.extend(t["bullets"])
                    elif t["text"] not in (title, subtitle):
                        col_bullets.append(t["text"])
                columns.append(ColumnContent(title=col_title, bullets=col_bullets))

            return SlideDefinition(
                slide_type=SlideType.TWO_COLUMN,
                title=title,
                columns=columns,
                speaker_notes=notes,
            )

    if slide_type in (SlideType.CHART, SlideType.TABLE):
        return SlideDefinition(
            slide_type=slide_type,
            title=title,
            bullets=all_bullets,
            elements=elements,
            speaker_notes=notes,
        )

    if slide_type == SlideType.CLOSING:
        return SlideDefinition(
            slide_type=SlideType.CLOSING,
            title=title,
            subtitle=subtitle or (body_parts[0] if body_parts else None),
            cta_text=_extract_cta_text(non_title_texts),
            speaker_notes=notes,
        )

    if slide_type == SlideType.DASHBOARD:
        kpis = _extract_kpis(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.DASHBOARD,
            title=title,
            kpis=kpis,
            bullets=all_bullets if not kpis else [],
            speaker_notes=notes,
        )

    if slide_type == SlideType.SWOT:
        swot = _extract_swot(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.SWOT,
            title=title,
            swot=swot,
            speaker_notes=notes,
        )

    if slide_type in (SlideType.PROCESS, SlideType.TIMELINE, SlideType.STEP_CARDS):
        steps = _extract_steps(non_title_texts)
        return SlideDefinition(
            slide_type=slide_type,
            title=title,
            steps=steps,
            bullets=all_bullets if not steps else [],
            speaker_notes=notes,
        )

    if slide_type == SlideType.AGENDA:
        agenda_items = _extract_agenda_items(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.AGENDA,
            title=title,
            agenda_items=agenda_items,
            bullets=all_bullets if not agenda_items else [],
            speaker_notes=notes,
        )

    if slide_type == SlideType.FUNNEL:
        funnel_stages = _extract_funnel_stages(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.FUNNEL,
            title=title,
            funnel_stages=funnel_stages,
            speaker_notes=notes,
        )

    if slide_type == SlideType.TEAM:
        members = _extract_team_members(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.TEAM,
            title=title,
            team_members=members,
            speaker_notes=notes,
        )

    if slide_type == SlideType.ICON_GRID:
        # Icon grid items are hard to reconstruct — fall through to bullets
        return SlideDefinition(
            slide_type=SlideType.ICON_GRID,
            title=title,
            bullets=all_bullets or [t["text"] for t in non_title_texts[:8]],
            speaker_notes=notes,
        )

    if slide_type == SlideType.PROS_CONS:
        pros, cons = _extract_pros_cons(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.PROS_CONS,
            title=title,
            pros=pros,
            cons=cons,
            speaker_notes=notes,
        )

    if slide_type == SlideType.COMPARISON:
        # Multi-column comparison — extract into ColumnContent
        columns = _extract_comparison_columns(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.COMPARISON,
            title=title,
            columns=columns,
            speaker_notes=notes,
        )

    # Diagram types (v0.6.0)
    if slide_type == SlideType.ORG_CHART:
        org_chart = _extract_org_chart(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.ORG_CHART,
            title=title,
            org_chart=org_chart,
            speaker_notes=notes,
        )

    if slide_type == SlideType.MATRIX:
        matrix = _extract_matrix(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.MATRIX,
            title=title,
            matrix=matrix,
            speaker_notes=notes,
        )

    if slide_type == SlideType.PYRAMID:
        pyramid_layers = _extract_pyramid_layers(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.PYRAMID,
            title=title,
            pyramid_layers=pyramid_layers,
            speaker_notes=notes,
        )

    if slide_type == SlideType.VENN:
        venn = _extract_venn(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.VENN,
            title=title,
            venn=venn,
            speaker_notes=notes,
        )

    if slide_type == SlideType.WATERFALL:
        waterfall_bars = _extract_waterfall_bars(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.WATERFALL,
            title=title,
            waterfall_bars=waterfall_bars,
            speaker_notes=notes,
        )

    if slide_type == SlideType.HUB_SPOKE:
        hub_center, spokes = _extract_hub_spoke(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.HUB_SPOKE,
            title=title,
            hub_spoke_center=hub_center,
            spokes=spokes,
            speaker_notes=notes,
        )

    if slide_type == SlideType.CYCLE:
        cycle_steps = _extract_cycle_steps(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.CYCLE,
            title=title,
            cycle_steps=cycle_steps,
            speaker_notes=notes,
        )

    if slide_type == SlideType.GANTT:
        gantt_tasks, gantt_phases = _extract_gantt_tasks(non_title_texts)
        return SlideDefinition(
            slide_type=SlideType.GANTT,
            title=title,
            gantt_tasks=gantt_tasks,
            gantt_phases=gantt_phases,
            speaker_notes=notes,
        )

    # Default: CONTENT
    return SlideDefinition(
        slide_type=SlideType.CONTENT,
        title=title,
        body=body_parts[0] if body_parts else None,
        bullets=all_bullets,
        elements=elements,
        speaker_notes=notes,
    )


def _extract_cta_text(texts: list[dict]) -> str | None:
    """Extract CTA button text from a closing slide."""
    for t in texts:
        text = t["text"].strip()
        # Short, bold text is likely a CTA button
        if t["is_bold"] and t["word_count"] <= 5 and len(text) < 50:
            return text
    return None


def _extract_kpis(texts: list[dict]) -> list[KpiCard]:
    """Extract KPI cards from dashboard slide shapes.

    Dashboard cards typically have a large number value and a smaller label.
    Group shapes by vertical position to find value+label pairs.
    """
    kpis: list[KpiCard] = []
    # Group texts by spatial proximity — KPI value (large font) + label (small font)
    # Sort by left position to get cards in order
    sorted_texts = sorted(texts, key=lambda t: (t["left"], t["top"]))

    # Strategy: find large-font texts (values) and pair with nearby small texts (labels)
    large = [t for t in sorted_texts if t["font_size"] >= 24]
    small = [t for t in sorted_texts if t["font_size"] < 24 and t["font_size"] > 0]

    used_small: set[int] = set()
    for val_t in large:
        value_text = val_t["text"].strip()
        label_text = ""
        change_text = None
        # Find the nearest small text below this value (within same card area)
        best_idx = -1
        best_dist = float("inf")
        for j, lbl_t in enumerate(small):
            if j in used_small:
                continue
            # Must be close horizontally (within 2 inches) and below
            h_dist = abs(lbl_t["left"] - val_t["left"])
            v_dist = lbl_t["top"] - val_t["top"]
            if h_dist < 2.0 and 0 < v_dist < 1.5:
                dist = h_dist + v_dist
                if dist < best_dist:
                    best_dist = dist
                    best_idx = j
        if best_idx >= 0:
            label_text = small[best_idx]["text"].strip()
            used_small.add(best_idx)
            # Look for a change indicator nearby
            for j, chg_t in enumerate(small):
                if j in used_small:
                    continue
                h_dist = abs(chg_t["left"] - val_t["left"])
                v_dist = chg_t["top"] - val_t["top"]
                if h_dist < 2.0 and 0.5 < v_dist < 2.0:
                    txt = chg_t["text"].strip()
                    if any(c in txt for c in "▲▼▶●+-"):
                        change_text = txt
                        used_small.add(j)
                        break

        if value_text:
            trend = "neutral"
            if change_text:
                if "▲" in change_text or "+" in change_text:
                    trend = "up"
                elif "▼" in change_text or "-" in change_text:
                    trend = "down"
            kpis.append(KpiCard(
                value=value_text,
                label=label_text or value_text,
                change=change_text,
                trend=trend,
            ))

    # Fallback: if no large-font values found, try parsing multi-line texts
    if not kpis:
        for t in sorted_texts:
            lines = t["text"].strip().split("\n")
            if len(lines) >= 2:
                kpis.append(KpiCard(
                    value=lines[0].strip(),
                    label=lines[1].strip(),
                ))

    return kpis[:12]


def _extract_swot(texts: list[dict]) -> SwotData:
    """Extract SWOT quadrant data from slide shapes.

    Uses spatial pairing: each SWOT label is matched with the nearest
    bullet/text shape directly below it.
    """
    quadrants: dict[str, list[str]] = {
        "strengths": [], "weaknesses": [], "opportunities": [], "threats": [],
    }

    _label_map = {
        "strengths": "strengths", "strength": "strengths",
        "weaknesses": "weaknesses", "weakness": "weaknesses",
        "opportunities": "opportunities", "opportunity": "opportunities",
        "threats": "threats", "threat": "threats",
    }

    # Find label shapes and content shapes
    label_shapes: list[tuple[str, dict]] = []  # (quadrant_key, shape_info)
    content_shapes: list[dict] = []

    for t in texts:
        text_lower = t["text"].strip().lower()
        matched_q = _label_map.get(text_lower)
        if matched_q:
            label_shapes.append((matched_q, t))
        else:
            content_shapes.append(t)

    if label_shapes:
        # Pair each label with the nearest content shape below it
        used_content: set[int] = set()
        for q_key, label_t in label_shapes:
            best_idx = -1
            best_dist = float("inf")
            for j, ct in enumerate(content_shapes):
                if j in used_content:
                    continue
                # Must be close horizontally and below the label
                h_dist = abs(ct["left"] - label_t["left"])
                v_dist = ct["top"] - label_t["top"]
                if h_dist < 2.0 and 0 < v_dist < 3.0:
                    dist = h_dist + v_dist
                    if dist < best_dist:
                        best_dist = dist
                        best_idx = j
            if best_idx >= 0:
                used_content.add(best_idx)
                ct = content_shapes[best_idx]
                if ct["has_bullets"]:
                    quadrants[q_key].extend(ct["bullets"])
                else:
                    for line in ct["text"].strip().split("\n"):
                        line = line.strip().lstrip("•●-– ").strip()
                        if line:
                            quadrants[q_key].append(line)
    else:
        # No explicit labels — try sequential extraction from body text
        for t in sorted(texts, key=lambda x: (x["top"], x["left"])):
            text_lower = t["text"].strip().lower()
            for label, q_key in _label_map.items():
                if text_lower.startswith(label):
                    remaining = t["text"].strip()[len(label):].strip().lstrip(":").strip()
                    if remaining:
                        for line in remaining.split("\n"):
                            line = line.strip().lstrip("•●-– ").strip()
                            if line:
                                quadrants[q_key].append(line)
                    break

    return SwotData(**quadrants)


def _extract_steps(texts: list[dict]) -> list[ProcessStep]:
    """Extract process/timeline steps from slide shapes."""
    steps: list[ProcessStep] = []
    _num_prefix = re.compile(r"^(\d+)[.):\s]+\s*")

    # Strategy: look for numbered or sequentially positioned shapes
    sorted_texts = sorted(texts, key=lambda t: (t["left"], t["top"]))

    # Classify shapes
    numbered_standalone: list[dict] = []  # standalone digit shapes ("1", "2", etc.)
    numbered_titled: list[dict] = []  # "1. Title text" shapes
    descriptive: list[dict] = []

    for t in sorted_texts:
        text = t["text"].strip()
        if text.isdigit() and int(text) <= 20:
            numbered_standalone.append(t)
        elif _num_prefix.match(text):
            numbered_titled.append(t)
        elif len(text) > 1:
            descriptive.append(t)

    if numbered_standalone and descriptive:
        # Pair standalone number indicators with nearby descriptive text
        for num_t in sorted(numbered_standalone, key=lambda t: (t["left"], t["top"])):
            best = None
            best_dist = float("inf")
            for desc_t in descriptive:
                dist = abs(desc_t["left"] - num_t["left"]) + abs(desc_t["top"] - num_t["top"])
                if dist < best_dist:
                    best_dist = dist
                    best = desc_t
            if best and best_dist < 3.0:
                lines = best["text"].strip().split("\n")
                title = lines[0].strip()
                desc = "\n".join(lines[1:]).strip() if len(lines) > 1 else None
                steps.append(ProcessStep(title=title, description=desc))
                descriptive.remove(best)
    elif numbered_titled:
        # "N. Title" shapes — pair each with a description shape at same left
        used: set[int] = set()
        for nt in sorted(numbered_titled, key=lambda t: (t["left"], t["top"])):
            step_title = _num_prefix.sub("", nt["text"].strip())
            # Find a description shape at the same left position, below this one
            best_idx = -1
            best_dist = float("inf")
            for j, dt in enumerate(descriptive):
                if j in used:
                    continue
                h_dist = abs(dt["left"] - nt["left"])
                v_dist = dt["top"] - nt["top"]
                if h_dist < 0.5 and 0 < v_dist < 3.0:
                    dist = h_dist + v_dist
                    if dist < best_dist:
                        best_dist = dist
                        best_idx = j
            desc = descriptive[best_idx]["text"].strip() if best_idx >= 0 else None
            if best_idx >= 0:
                used.add(best_idx)
            steps.append(ProcessStep(title=step_title, description=desc))
    elif descriptive:
        # No numbers — group shapes by left position (same column = one step)
        from itertools import groupby

        def _left_key(t: dict) -> float:
            return round(t["left"], 0)  # round to ~1" buckets

        groups = groupby(
            sorted(descriptive, key=lambda t: (round(t["left"], 0), t["top"])),
            key=_left_key,
        )
        for _, group_items in groups:
            items = sorted(group_items, key=lambda t: t["top"])
            title = items[0]["text"].strip()
            desc_parts = [it["text"].strip() for it in items[1:]]
            desc = "\n".join(desc_parts) if desc_parts else None
            steps.append(ProcessStep(title=title, description=desc))

    return steps[:10]


def _extract_agenda_items(texts: list[dict]) -> list[AgendaItem]:
    """Extract agenda items from slide shapes."""
    items: list[AgendaItem] = []
    sorted_texts = sorted(texts, key=lambda t: t["top"])

    for t in sorted_texts:
        text = t["text"].strip()
        if text.isdigit():
            continue  # Skip standalone numbers
        if len(text) > 1:
            lines = text.split("\n")
            title = lines[0].strip()
            desc = "\n".join(lines[1:]).strip() if len(lines) > 1 else None
            items.append(AgendaItem(title=title, description=desc))

    return items[:12]


def _extract_funnel_stages(texts: list[dict]) -> list[FunnelStage]:
    """Extract funnel stages from shapes arranged vertically with decreasing width."""
    stages: list[FunnelStage] = []
    sorted_texts = sorted(texts, key=lambda t: t["top"])

    for t in sorted_texts:
        text = t["text"].strip()
        if not text:
            continue
        lines = text.split("\n")
        label = lines[0].strip()
        value = lines[1].strip() if len(lines) > 1 else None
        desc = "\n".join(lines[2:]).strip() if len(lines) > 2 else None
        stages.append(FunnelStage(label=label, value=value, description=desc))

    return stages[:8]


def _extract_team_members(texts: list[dict]) -> list[TeamMember]:
    """Extract team member info from card-like shapes."""
    members: list[TeamMember] = []
    sorted_texts = sorted(texts, key=lambda t: t["left"])

    for t in sorted_texts:
        text = t["text"].strip()
        lines = [line.strip() for line in text.split("\n") if line.strip()]
        if len(lines) >= 2:
            name = lines[0]
            role = lines[1]
            bio = "\n".join(lines[2:]) if len(lines) > 2 else None
            members.append(TeamMember(name=name, role=role, bio=bio))
        elif len(lines) == 1 and len(text) < 50:
            # Single-line text that might be just a name (with initials)
            if len(text) <= 3:
                continue  # Skip initials circles
            members.append(TeamMember(name=text, role=""))

    return members[:8]


def _extract_pros_cons(texts: list[dict]) -> tuple[list[str], list[str]]:
    """Extract pros and cons from a two-column layout."""
    pros: list[str] = []
    cons: list[str] = []

    sorted_texts = sorted(texts, key=lambda t: t["left"])
    if not sorted_texts:
        return pros, cons

    # Split into left and right halves
    midpoint = (sorted_texts[0]["left"] + sorted_texts[-1]["left"]) / 2

    for t in sorted_texts:
        if t["has_bullets"]:
            items = t["bullets"]
        else:
            items = [line.strip() for line in t["text"].split("\n") if line.strip()]

        if t["left"] < midpoint:
            pros.extend(items)
        else:
            cons.extend(items)

    return pros[:20], cons[:20]


def _extract_comparison_columns(texts: list[dict]) -> list[ColumnContent]:
    """Extract multi-column comparison data."""
    columns: list[ColumnContent] = []
    sorted_texts = sorted(texts, key=lambda t: t["left"])
    if not sorted_texts:
        return columns

    # Cluster by left position
    cluster_tolerance = 1.0
    clusters: list[list[dict]] = []
    for t in sorted_texts:
        if not clusters or abs(t["left"] - clusters[-1][0]["left"]) > cluster_tolerance:
            clusters.append([t])
        else:
            clusters[-1].append(t)

    for cluster in clusters:
        sorted_by_top = sorted(cluster, key=lambda t: t["top"])
        col_title = None
        col_bullets: list[str] = []
        for t in sorted_by_top:
            if col_title is None and t["is_bold"] and t["word_count"] < 10:
                col_title = t["text"]
            elif t["has_bullets"]:
                col_bullets.extend(t["bullets"])
            else:
                col_bullets.append(t["text"])
        columns.append(ColumnContent(title=col_title, bullets=col_bullets))

    return columns[:6]


def _extract_org_chart(texts: list[dict]) -> OrgNode | None:
    """Extract org chart hierarchy from card-like shapes.

    Heuristic: shapes arranged vertically with bold names and smaller roles.
    Builds a flat tree rooted at the topmost shape, with remaining shapes as children.
    """
    if not texts:
        return None

    sorted_by_top = sorted(texts, key=lambda t: (t["top"], t["left"]))
    nodes: list[OrgNode] = []

    for i, t in enumerate(sorted_by_top):
        lines = [line.strip() for line in t["text"].split("\n") if line.strip()]
        if not lines:
            continue
        name = lines[0]
        role = lines[1] if len(lines) > 1 else None
        nodes.append(OrgNode(id=f"node_{i}", name=name, role=role))

    if not nodes:
        return None

    # Root is the first (topmost) node; rest are children
    root = nodes[0]
    root.children = nodes[1:]
    return root


def _extract_matrix(texts: list[dict]) -> MatrixData | None:
    """Extract 2x2 matrix data from four quadrant shapes.

    Heuristic: 4 content shapes arranged in a 2x2 grid.
    Classifies by position: top-left, top-right, bottom-left, bottom-right.
    """
    # Filter to content-area shapes (below title area)
    content = [t for t in texts if t["top"] > 1.5]
    if len(content) < 4:
        # Not enough shapes for a 2x2; build from available text
        labels = [t["text"].split("\n")[0].strip() for t in content]
        while len(labels) < 4:
            labels.append("")
        return MatrixData(
            top_left=MatrixQuadrant(label=labels[0]),
            top_right=MatrixQuadrant(label=labels[1]),
            bottom_left=MatrixQuadrant(label=labels[2]),
            bottom_right=MatrixQuadrant(label=labels[3]),
        )

    # Find center point
    lefts = [t["left"] for t in content]
    tops = [t["top"] for t in content]
    mid_x = (min(lefts) + max(lefts)) / 2
    mid_y = (min(tops) + max(tops)) / 2

    def _quadrant_items(shapes: list[dict]) -> tuple[str, list[str]]:
        if not shapes:
            return "", []
        # Sort by top, first is label
        s = sorted(shapes, key=lambda x: x["top"])
        label = s[0]["text"].split("\n")[0].strip()
        items: list[str] = []
        for sh in s:
            for line in sh["text"].split("\n"):
                line = line.strip().lstrip("•●-– ").strip()
                if line and line != label:
                    items.append(line)
        return label, items

    tl_shapes = [t for t in content if t["left"] < mid_x and t["top"] < mid_y]
    tr_shapes = [t for t in content if t["left"] >= mid_x and t["top"] < mid_y]
    bl_shapes = [t for t in content if t["left"] < mid_x and t["top"] >= mid_y]
    br_shapes = [t for t in content if t["left"] >= mid_x and t["top"] >= mid_y]

    tl_label, tl_items = _quadrant_items(tl_shapes)
    tr_label, tr_items = _quadrant_items(tr_shapes)
    bl_label, bl_items = _quadrant_items(bl_shapes)
    br_label, br_items = _quadrant_items(br_shapes)

    return MatrixData(
        top_left=MatrixQuadrant(label=tl_label or "Top Left", items=tl_items),
        top_right=MatrixQuadrant(label=tr_label or "Top Right", items=tr_items),
        bottom_left=MatrixQuadrant(label=bl_label or "Bottom Left", items=bl_items),
        bottom_right=MatrixQuadrant(label=br_label or "Bottom Right", items=br_items),
    )


def _extract_pyramid_layers(texts: list[dict]) -> list[PyramidLayer]:
    """Extract pyramid layers from vertically stacked shapes (top to bottom)."""
    sorted_by_top = sorted(texts, key=lambda t: t["top"])
    layers: list[PyramidLayer] = []
    for t in sorted_by_top:
        lines = [line.strip() for line in t["text"].split("\n") if line.strip()]
        if not lines:
            continue
        label = lines[0]
        desc = " ".join(lines[1:]) if len(lines) > 1 else None
        layers.append(PyramidLayer(label=label, description=desc))
    return layers[:8]


def _extract_venn(texts: list[dict]) -> VennData | None:
    """Extract Venn diagram data from overlapping circle shapes.

    Heuristic: text shapes represent circle labels, ordered by left position.
    """
    if len(texts) < 2:
        return None

    sorted_by_left = sorted(texts, key=lambda t: t["left"])
    circles: list[VennCircle] = []
    intersection_label = None

    for t in sorted_by_left:
        lines = [line.strip() for line in t["text"].split("\n") if line.strip()]
        if not lines:
            continue
        label = lines[0]
        items = lines[1:]
        circles.append(VennCircle(label=label, items=items))

    if len(circles) < 2:
        return None

    # If there are more circles than expected (2-3), the middle one might be intersection
    if len(circles) > 3:
        # Take first 3 as circles
        circles = circles[:3]
    elif len(circles) == 3:
        # Check if middle circle is actually the intersection label
        # (positioned between the other two horizontally)
        pass  # Keep as 3-circle Venn

    return VennData(circles=circles[:3], intersection_label=intersection_label)


def _extract_waterfall_bars(texts: list[dict]) -> list[WaterfallBar]:
    """Extract waterfall bar data from shape-based bar chart.

    Heuristic: shapes arranged left-to-right with labels; try to extract values.
    """
    sorted_by_left = sorted(texts, key=lambda t: t["left"])
    bars: list[WaterfallBar] = []

    for t in sorted_by_left:
        lines = [line.strip() for line in t["text"].split("\n") if line.strip()]
        if not lines:
            continue
        label = lines[0]
        # Try to extract a numeric value
        value = 0.0
        bar_type: str = "increase"
        for line in lines:
            nums = re.findall(r"-?[\d,.]+", line)
            if nums:
                try:
                    value = float(nums[0].replace(",", ""))
                except ValueError:
                    pass
        if value < 0:
            bar_type = "decrease"
        # Check for "total" keywords
        if any(w in label.lower() for w in ("total", "net", "sum", "end", "start", "baseline")):
            bar_type = "total"
        bars.append(WaterfallBar(label=label, value=value, bar_type=bar_type))

    return bars[:20]


def _extract_hub_spoke(texts: list[dict]) -> tuple[str | None, list[SpokeItem]]:
    """Extract hub-and-spoke data from radially arranged shapes.

    Heuristic: the shape nearest the center is the hub; others are spokes.
    """
    if not texts:
        return None, []

    # Find center of mass
    avg_left = sum(t["left"] for t in texts) / len(texts)
    avg_top = sum(t["top"] for t in texts) / len(texts)

    # Shape closest to center is the hub
    def _dist_to_center(t: dict) -> float:
        return ((t["left"] - avg_left) ** 2 + (t["top"] - avg_top) ** 2) ** 0.5

    sorted_by_center = sorted(texts, key=_dist_to_center)
    hub_text = sorted_by_center[0]["text"].split("\n")[0].strip() if sorted_by_center else None

    spokes: list[SpokeItem] = []
    for t in sorted_by_center[1:]:
        lines = [line.strip() for line in t["text"].split("\n") if line.strip()]
        if lines:
            spokes.append(SpokeItem(
                label=lines[0],
                description=" ".join(lines[1:]) if len(lines) > 1 else None,
            ))

    return hub_text, spokes[:8]


def _extract_cycle_steps(texts: list[dict]) -> list[CycleStep]:
    """Extract cycle steps from circularly arranged shapes.

    Heuristic: shapes arranged in a roughly circular pattern, ordered by angle.
    """
    if not texts:
        return []

    import math

    # Find center of mass
    avg_left = sum(t["left"] for t in texts) / len(texts)
    avg_top = sum(t["top"] for t in texts) / len(texts)

    # Sort by angle from center
    def _angle(t: dict) -> float:
        return math.atan2(t["top"] - avg_top, t["left"] - avg_left)

    sorted_by_angle = sorted(texts, key=_angle)
    steps: list[CycleStep] = []
    for t in sorted_by_angle:
        lines = [line.strip() for line in t["text"].split("\n") if line.strip()]
        if lines:
            steps.append(CycleStep(
                label=lines[0],
                description=" ".join(lines[1:]) if len(lines) > 1 else None,
            ))

    return steps[:8]


def _extract_gantt_tasks(texts: list[dict]) -> tuple[list[GanttTask], list[str]]:
    """Extract Gantt chart tasks from horizontal bar shapes.

    Heuristic: shapes arranged as horizontal bars with labels on the left.
    Tasks are ordered by vertical position (top to bottom).
    Phases are extracted from column header shapes if present.
    """
    sorted_by_top = sorted(texts, key=lambda t: t["top"])
    tasks: list[GanttTask] = []
    phases: list[str] = []

    # First row shapes (lowest top) might be phase labels
    if len(sorted_by_top) >= 2:
        first_top = sorted_by_top[0]["top"]
        header_shapes = [t for t in sorted_by_top if abs(t["top"] - first_top) < 0.3]
        if len(header_shapes) >= 2:
            # Multiple shapes at top = phase headers
            header_shapes_sorted = sorted(header_shapes, key=lambda t: t["left"])
            phases = [t["text"].strip() for t in header_shapes_sorted]
            sorted_by_top = [t for t in sorted_by_top if t not in header_shapes]

    # Remaining shapes are task labels or bars
    for i, t in enumerate(sorted_by_top):
        text = t["text"].strip()
        if not text:
            continue
        # Use position to approximate start and duration
        # Normalize to 0.0-1.0 range based on slide width (~13.333")
        slide_w = 13.333
        margin = 2.0  # left margin for labels
        chart_w = slide_w - margin * 2
        start = max(0.0, min(1.0, (t["left"] - margin) / chart_w))
        duration = max(0.05, min(1.0, t["width"] / chart_w))

        tasks.append(GanttTask(
            label=text.split("\n")[0].strip(),
            start=round(start, 2),
            duration=round(duration, 2),
        ))

    return tasks[:20], phases[:10]


def _parse_chart(shape: object) -> Optional[ChartElement]:
    """Parse a chart shape into a ChartElement."""
    try:
        chart = shape.chart  # type: ignore[attr-defined]
        chart_type_val = chart.chart_type

        type_map = {
            51: ChartType.BAR,   # BAR_CLUSTERED
            4: ChartType.LINE,   # LINE_MARKERS
            5: ChartType.PIE,    # PIE
            57: ChartType.COLUMN,  # COLUMN_CLUSTERED
            1: ChartType.AREA,   # AREA
            # v0.6.0 chart types
            75: ChartType.SCATTER,  # XY_SCATTER
            -4169: ChartType.SCATTER,  # XY_SCATTER (alt enum)
            15: ChartType.BUBBLE,  # BUBBLE
            81: ChartType.RADAR,  # RADAR_MARKERS
            82: ChartType.RADAR,  # RADAR
            -4151: ChartType.DOUGHNUT,  # DOUGHNUT
            77: ChartType.DOUGHNUT,  # DOUGHNUT (alt)
            58: ChartType.STACKED_BAR,  # BAR_STACKED
            53: ChartType.STACKED_COLUMN,  # COLUMN_STACKED
        }
        chart_type = type_map.get(int(chart_type_val), ChartType.BAR)

        categories = [str(c) for c in chart.plots[0].categories]
        series_list = []
        for series in chart.series:
            values = list(series.values)
            series_list.append(ChartDataSeries(
                name=str(series.tx.text) if hasattr(series, "tx") and series.tx else "Series",
                values=[float(v) if v is not None else 0.0 for v in values],
            ))

        title = None
        if chart.has_title:
            title = chart.chart_title.text_frame.text

        return ChartElement(
            chart_type=chart_type,
            data=ChartData(categories=categories, series=series_list),
            title=title,
            position=_extract_position(shape),
        )
    except Exception as e:
        logger.warning("Failed to parse chart: %s", e)
        return None


def _parse_table(shape: object) -> Optional[TableElement]:
    """Parse a table shape into a TableElement."""
    try:
        table = shape.table  # type: ignore[attr-defined]
        rows = []
        for row in table.rows:
            cells = []
            for cell in row.cells:
                text = cell.text_frame.text.strip() if cell.text_frame else ""
                cell_fill = _extract_fill_color(cell)
                cell_style = None
                try:
                    tf = cell.text_frame
                    if tf and tf.paragraphs and tf.paragraphs[0].runs:
                        cell_style = _extract_text_style(tf.paragraphs[0].runs[0])
                except (AttributeError, IndexError):
                    pass
                cells.append(TableCell(
                    text=text,
                    fill_color=cell_fill,
                    style=cell_style,
                ))
            rows.append(cells)

        return TableElement(
            rows=rows,
            position=_extract_position(shape),
            header_row=True,
        )
    except Exception as e:
        logger.warning("Failed to parse table: %s", e)
        return None


def ingest_presentation(file_path: str | Path) -> PresentationDefinition:
    """Ingest an existing PPTX file into a PresentationDefinition suitable for re-generation.

    This is the primary entry point for the restyle workflow. It uses enhanced
    heuristics to detect slide types and maps content to the correct
    SlideDefinition fields so the deck can be re-rendered with a new brand.

    Unlike ``parse_presentation`` (which preserves raw elements for inspection),
    this function produces clean, renderable SlideDefinitions with elements
    stripped from types that have dedicated field mappings (e.g. TITLE, CONTENT,
    QUOTE, BIG_NUMBER, TWO_COLUMN). This ensures the ``DeckGenerator`` renders
    them through the correct layout renderer rather than falling through to
    generic element rendering.

    Args:
        file_path: Path to the .pptx file.

    Returns:
        PresentationDefinition ready for re-generation via DeckGenerator.

    Raises:
        ParseError: If the file cannot be read or parsed.
    """
    definition = parse_presentation(file_path)

    # Post-process: strip raw TextBox/Shape elements from slide types that
    # have dedicated renderers — keeping them would cause duplicate content.
    _TYPES_WITH_DEDICATED_FIELDS = {
        SlideType.TITLE,
        SlideType.SECTION,
        SlideType.CONTENT,
        SlideType.BIG_NUMBER,
        SlideType.QUOTE,
        SlideType.TWO_COLUMN,
        SlideType.CLOSING,
        SlideType.DASHBOARD,
        SlideType.SWOT,
        SlideType.PROCESS,
        SlideType.TIMELINE,
        SlideType.STEP_CARDS,
        SlideType.AGENDA,
        SlideType.FUNNEL,
        SlideType.TEAM,
        SlideType.ICON_GRID,
        SlideType.PROS_CONS,
        SlideType.COMPARISON,
        # Diagram types (v0.6.0)
        SlideType.ORG_CHART,
        SlideType.MATRIX,
        SlideType.PYRAMID,
        SlideType.VENN,
        SlideType.WATERFALL,
        SlideType.HUB_SPOKE,
        SlideType.CYCLE,
        SlideType.GANTT,
    }

    cleaned_slides: list[SlideDefinition] = []
    for slide_def in definition.slides:
        if slide_def.slide_type in _TYPES_WITH_DEDICATED_FIELDS:
            # Keep chart/table elements but drop text boxes and generic shapes
            # since the dedicated fields (title, body, bullets, columns, etc.)
            # already carry the content.
            kept_elements = [
                e for e in slide_def.elements
                if isinstance(e, (ChartElement, TableElement))
            ]
            slide_def = slide_def.model_copy(update={"elements": kept_elements})
        cleaned_slides.append(slide_def)

    return definition.model_copy(update={"slides": cleaned_slides})
