"""Tests for v0.8.2 — Layout overflow fixes.

Validates that dashboard and card grid slides respect bottom margins,
max cell height caps, and callout-aware spacing.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from pptx import Presentation
from pptx.util import Emu

from pptx_mcp.brand.defaults import executive_default
from pptx_mcp.engine.composition import Cell, LayoutGrid, Margin
from pptx_mcp.engine.generator import DeckGenerator
from pptx_mcp.models.brand import BrandConfig
from pptx_mcp.models.slides import (
    CalloutBox,
    CardDefinition,
    KpiCard,
    PresentationDefinition,
    PresentationMetadata,
    SlideDefinition,
    SlideType,
)


def _brand() -> BrandConfig:
    return executive_default()


def _make_definition(slides: list[SlideDefinition]) -> PresentationDefinition:
    return PresentationDefinition(
        metadata=PresentationMetadata(title="Test", author="test"),
        slides=slides,
    )


@pytest.fixture
def output_dir(tmp_path: Path) -> Path:
    return tmp_path


# ---------------------------------------------------------------------------
# _make_grid bottom margin
# ---------------------------------------------------------------------------


class TestMakeGridBottomMargin:
    """Verify _make_grid uses the new default bottom margin."""

    def test_default_bottom_is_0_75(self) -> None:
        from pptx_mcp.engine.layouts import _make_grid

        brand = _brand()
        grid = _make_grid(brand, 13.333)
        assert grid.margin.bottom == 0.75

    def test_custom_bottom_override(self) -> None:
        from pptx_mcp.engine.layouts import _make_grid

        brand = _brand()
        grid = _make_grid(brand, 13.333, bottom=1.5)
        assert grid.margin.bottom == 1.5

    def test_content_height_with_new_default(self) -> None:
        from pptx_mcp.engine.layouts import _make_grid

        brand = _brand()
        grid = _make_grid(brand, 13.333)
        # content_height = 7.5 - content_top - 0.75
        expected = 7.5 - brand.spacing.content_top - 0.75
        assert grid.content_height == pytest.approx(expected)


# ---------------------------------------------------------------------------
# Dashboard card bounds
# ---------------------------------------------------------------------------

SLIDE_HEIGHT = 7.5
FOOTER_ZONE_TOP = 6.75  # Cards must not extend past this


class TestDashboardCardBounds:
    """Dashboard KPI cards must stay above the footer zone."""

    def _generate_dashboard(
        self, output_dir: Path, n_kpis: int, *, callout: CalloutBox | None = None
    ) -> Presentation:
        kpis = [
            KpiCard(value=f"${i * 100}K", label=f"Metric {i}", change=f"+{i}%", trend="up")
            for i in range(1, n_kpis + 1)
        ]
        defn = _make_definition([
            SlideDefinition(
                slide_type=SlideType.DASHBOARD,
                title="Dashboard",
                kpis=kpis,
                callout=callout,
            ),
        ])
        gen = DeckGenerator(brand=_brand())
        path = gen.generate(defn, output_dir / f"dash_{n_kpis}.pptx")
        return Presentation(str(path))

    def test_3_kpis_within_bounds(self, output_dir: Path) -> None:
        prs = self._generate_dashboard(output_dir, 3)
        slide = prs.slides[0]
        for shape in slide.shapes:
            bottom = (shape.top + shape.height) / 914400
            assert bottom <= SLIDE_HEIGHT, f"Shape '{shape.name}' bottom={bottom:.2f}\" exceeds slide"

    def test_6_kpis_within_bounds(self, output_dir: Path) -> None:
        prs = self._generate_dashboard(output_dir, 6)
        slide = prs.slides[0]
        for shape in slide.shapes:
            bottom = (shape.top + shape.height) / 914400
            assert bottom <= SLIDE_HEIGHT, f"Shape '{shape.name}' bottom={bottom:.2f}\" exceeds slide"

    def test_6_kpis_cards_above_footer(self, output_dir: Path) -> None:
        """Card containers should not extend into footer zone."""
        prs = self._generate_dashboard(output_dir, 6)
        slide = prs.slides[0]
        for shape in slide.shapes:
            if shape.shape_type is not None and "ROUNDED" in str(shape.shape_type):
                bottom = (shape.top + shape.height) / 914400
                assert bottom <= FOOTER_ZONE_TOP + 0.1, (
                    f"Card '{shape.name}' bottom={bottom:.2f}\" in footer zone"
                )

    def test_12_kpis_within_bounds(self, output_dir: Path) -> None:
        prs = self._generate_dashboard(output_dir, 12)
        slide = prs.slides[0]
        for shape in slide.shapes:
            bottom = (shape.top + shape.height) / 914400
            assert bottom <= SLIDE_HEIGHT, f"Shape '{shape.name}' bottom={bottom:.2f}\" exceeds slide"

    def test_callout_aware_spacing(self, output_dir: Path) -> None:
        """With a callout, cards should leave room above the callout box."""
        callout = CalloutBox(body="Important note", variant="info")
        prs = self._generate_dashboard(output_dir, 6, callout=callout)
        slide = prs.slides[0]
        # Callout box is placed at y=6.1, cards must end before that
        card_bottoms = []
        for shape in slide.shapes:
            if shape.shape_type is not None and "ROUNDED" in str(shape.shape_type):
                bottom = (shape.top + shape.height) / 914400
                # Exclude the callout box itself (at y~6.1)
                top = shape.top / 914400
                if top < 5.0:
                    card_bottoms.append(bottom)
        if card_bottoms:
            max_bottom = max(card_bottoms)
            assert max_bottom <= 6.2, (
                f"Cards extend to {max_bottom:.2f}\" — overlaps callout at 6.1\""
            )


# ---------------------------------------------------------------------------
# Card grid bounds
# ---------------------------------------------------------------------------


class TestCardGridBounds:
    """Card grid cards must stay above the footer zone."""

    def _generate_card_grid(
        self, output_dir: Path, n_cards: int, *, callout: CalloutBox | None = None
    ) -> Presentation:
        cards = [
            CardDefinition(title=f"Card {i}", body=f"Description for card {i}")
            for i in range(1, n_cards + 1)
        ]
        defn = _make_definition([
            SlideDefinition(
                slide_type=SlideType.CARD_GRID,
                title="Cards",
                cards=cards,
                callout=callout,
            ),
        ])
        gen = DeckGenerator(brand=_brand())
        path = gen.generate(defn, output_dir / f"cards_{n_cards}.pptx")
        return Presentation(str(path))

    def test_3_cards_within_bounds(self, output_dir: Path) -> None:
        prs = self._generate_card_grid(output_dir, 3)
        slide = prs.slides[0]
        for shape in slide.shapes:
            bottom = (shape.top + shape.height) / 914400
            assert bottom <= SLIDE_HEIGHT, f"Shape '{shape.name}' bottom={bottom:.2f}\" exceeds slide"

    def test_6_cards_within_bounds(self, output_dir: Path) -> None:
        prs = self._generate_card_grid(output_dir, 6)
        slide = prs.slides[0]
        for shape in slide.shapes:
            bottom = (shape.top + shape.height) / 914400
            assert bottom <= SLIDE_HEIGHT, f"Shape '{shape.name}' bottom={bottom:.2f}\" exceeds slide"

    def test_callout_aware_spacing(self, output_dir: Path) -> None:
        """With a callout, cards should leave room above the callout box."""
        callout = CalloutBox(body="Note: Review required", variant="warning")
        prs = self._generate_card_grid(output_dir, 6, callout=callout)
        slide = prs.slides[0]
        card_bottoms = []
        for shape in slide.shapes:
            if shape.shape_type is not None and "ROUNDED" in str(shape.shape_type):
                bottom = (shape.top + shape.height) / 914400
                top = shape.top / 914400
                if top < 5.0:
                    card_bottoms.append(bottom)
        if card_bottoms:
            max_bottom = max(card_bottoms)
            assert max_bottom <= 6.2, (
                f"Cards extend to {max_bottom:.2f}\" — overlaps callout at 6.1\""
            )


# ---------------------------------------------------------------------------
# Section slide body/bullets rendering
# ---------------------------------------------------------------------------


class TestSectionSlideContent:
    """SECTION slides should render body and bullets when present."""

    def _generate_section(
        self,
        output_dir: Path,
        *,
        body: str | None = None,
        bullets: list[str] | None = None,
        subtitle: str | None = None,
    ) -> Presentation:
        defn = _make_definition([
            SlideDefinition(
                slide_type=SlideType.SECTION,
                title="Section Title",
                subtitle=subtitle,
                body=body,
                bullets=bullets or [],
            ),
        ])
        gen = DeckGenerator(brand=_brand())
        path = gen.generate(defn, output_dir / "section.pptx")
        return Presentation(str(path))

    def _text_shapes(self, slide) -> list[str]:
        """Collect all text from shapes on a slide."""
        texts = []
        for shape in slide.shapes:
            if shape.has_text_frame:
                for p in shape.text_frame.paragraphs:
                    t = p.text.strip()
                    if t:
                        texts.append(t)
        return texts

    def test_section_with_body_renders_body(self, output_dir: Path) -> None:
        prs = self._generate_section(output_dir, body="This is the body text.")
        texts = self._text_shapes(prs.slides[0])
        assert any("body text" in t.lower() for t in texts)

    def test_section_with_bullets_renders_bullets(self, output_dir: Path) -> None:
        prs = self._generate_section(
            output_dir, bullets=["First point", "Second point", "Third point"]
        )
        texts = self._text_shapes(prs.slides[0])
        assert any("First point" in t for t in texts)
        assert any("Third point" in t for t in texts)

    def test_section_with_subtitle_and_bullets(self, output_dir: Path) -> None:
        prs = self._generate_section(
            output_dir,
            subtitle="Key Findings",
            bullets=["Finding A", "Finding B"],
        )
        texts = self._text_shapes(prs.slides[0])
        assert any("Key Findings" in t for t in texts)
        assert any("Finding A" in t for t in texts)

    def test_section_minimal_still_works(self, output_dir: Path) -> None:
        """Section with only title still renders cleanly."""
        prs = self._generate_section(output_dir)
        slide = prs.slides[0]
        # Title + accent bar = at least 2 shapes
        assert len(slide.shapes) >= 2

    def test_section_body_within_bounds(self, output_dir: Path) -> None:
        prs = self._generate_section(
            output_dir,
            subtitle="Overview",
            body="Detailed body content here.",
            bullets=["Bullet 1", "Bullet 2"],
        )
        slide = prs.slides[0]
        for shape in slide.shapes:
            bottom = (shape.top + shape.height) / 914400
            assert bottom <= 7.5, f"Shape '{shape.name}' bottom={bottom:.2f}\" exceeds slide"


# ---------------------------------------------------------------------------
# KpiCard model constraint
# ---------------------------------------------------------------------------


class TestKpiCardChangeLength:
    """KpiCard.change field should accept longer strings."""

    def test_long_change_text(self) -> None:
        kpi = KpiCard(
            value="47%",
            label="Automation Rate",
            change="Year-over-year improvement of 15 percentage points driven by RPA",
        )
        assert len(kpi.change) > 50

    def test_short_change_still_works(self) -> None:
        kpi = KpiCard(value="$1.2M", label="Revenue", change="+15%")
        assert kpi.change == "+15%"

    def test_max_length_is_100(self) -> None:
        with pytest.raises(Exception):
            KpiCard(value="X", label="Y", change="A" * 101)


# ---------------------------------------------------------------------------
# Max cell height cap
# ---------------------------------------------------------------------------


class TestMaxCellHeight:
    """auto_layout max_cell_height prevents overly tall cards."""

    def test_single_row_capped(self) -> None:
        grid = LayoutGrid(
            slide_width=13.333, slide_height=7.5,
            margin=Margin(left=0.75, right=0.75, top=1.6, bottom=0.75),
        )
        cells = grid.auto_layout(3, max_cols=4, max_cell_height=2.0)
        for c in cells:
            assert c.height <= 2.0

    def test_multi_row_capped(self) -> None:
        grid = LayoutGrid(
            slide_width=13.333, slide_height=7.5,
            margin=Margin(left=0.75, right=0.75, top=1.6, bottom=0.75),
        )
        cells = grid.auto_layout(8, max_cols=4, max_cell_height=2.5)
        assert len(cells) == 8
        for c in cells:
            assert c.height <= 2.5

    def test_width_unchanged_by_cap(self) -> None:
        grid = LayoutGrid(
            slide_width=13.333, slide_height=7.5,
            margin=Margin(left=0.75, right=0.75, top=1.6, bottom=0.75),
        )
        uncapped = grid.auto_layout(6, max_cols=3)
        capped = grid.auto_layout(6, max_cols=3, max_cell_height=2.0)
        for u, c in zip(uncapped, capped):
            assert u.width == pytest.approx(c.width)

    def test_positions_preserved(self) -> None:
        """Left/top positions should remain the same regardless of height cap."""
        grid = LayoutGrid(
            slide_width=13.333, slide_height=7.5,
            margin=Margin(left=0.75, right=0.75, top=1.6, bottom=0.75),
        )
        uncapped = grid.auto_layout(6, max_cols=3)
        capped = grid.auto_layout(6, max_cols=3, max_cell_height=2.0)
        for u, c in zip(uncapped, capped):
            assert u.left == pytest.approx(c.left)
            assert u.top == pytest.approx(c.top)
