from __future__ import annotations

import re
from dataclasses import dataclass

from .deck_models import (
    KPI_CARD_DETAIL_MAX_BEATS,
    KPI_CARD_DETAIL_MAX_CHARS,
    KPI_CARD_LABEL_MAX_CHARS,
    KPI_CARD_LABEL_MAX_WORDS,
    KPI_CARD_VALUE_MAX_CHARS,
    KPI_CARD_VALUE_MAX_WORDS,
    TABLE_CARD_MAX_COLUMNS,
    TABLE_CARD_MAX_ROWS,
    DeckSpec,
    KpiMetric,
    SlideSpec,
    TableSpec,
)


@dataclass(frozen=True)
class DeckQualityIssue:
    """Describe one content-quality problem with enough context to drive a focused retry."""

    category: str
    message: str
    repair_action: str
    slide_index: int | None = None
    slide_type: str | None = None
    slide_name: str | None = None

    def location_label(self) -> str:
        """Return a short human-readable location label for logs and retry prompts."""

        if self.slide_index is None:
            return "deck-level"
        slide_name = self.slide_name or "untitled"
        if self.slide_type:
            return f"slide {self.slide_index} ({self.slide_type}, {slide_name})"
        return f"slide {self.slide_index} ({slide_name})"


class DeckQualityFailure(ValueError):
    """Raised when a structurally valid deck fails the content-density quality gate."""

    def __init__(self, issues: list[DeckQualityIssue]):
        self.issues = issues
        super().__init__(format_deck_quality_failure(issues))


@dataclass
class DeckQualityStats:
    """Running counters used by the deck-level quality gate heuristics."""

    informative_slide_count: int = 0
    total_content_beats: int = 0


def collect_deck_quality_issues(spec: DeckSpec) -> list[DeckQualityIssue]:
    """Return structured quality issues for a valid deck before rendering."""

    issues: list[DeckQualityIssue] = []
    stats = DeckQualityStats()

    for slide_index, slide in enumerate(spec.slides, start=1):
        issues.extend(_collect_slide_quality_issues(slide, slide_index, stats))

    if _count_sparse_non_title_slides(spec) > 1:
        issues.append(
            DeckQualityIssue(
                category="deck_sparse_non_title_slides",
                message="Too many non-title slides are visually empty or structurally thin",
                repair_action=(
                    "Enrich multiple non-title slides so they each teach or explain something concrete; "
                    "do not rely on empty dividers or decorative slides."
                ),
            )
        )
    if _is_globally_thin(stats):
        issues.append(
            DeckQualityIssue(
                category="deck_globally_thin",
                message="Deck content is globally too thin for the number of slides",
                repair_action=(
                    "Add substance across several informative slides, not just one; increase the number of "
                    "concrete steps, examples, takeaways, constraints, or implications."
                ),
            )
        )

    return issues


def format_deck_quality_failure(issues: list[DeckQualityIssue]) -> str:
    """Render quality issues into the compact failure summary used for the final exception."""

    return "Deck quality is too sparse:\n- " + "\n- ".join(issue.message for issue in issues)


def format_quality_gate_findings(issues: list[DeckQualityIssue]) -> str:
    """Render structured quality findings for retry prompts and logs."""

    lines: list[str] = []
    for issue in issues:
        lines.append(f"- category: {issue.category}")
        lines.append(f"  location: {issue.location_label()}")
        lines.append(f"  issue: {issue.message}")
        lines.append(f"  repair: {issue.repair_action}")
    return "\n".join(lines)


def _collect_slide_quality_issues(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Dispatch one slide to the quality rule set that matches its slide type."""

    if slide.type == "section":
        return _check_section_slide(slide, slide_index, stats)
    if slide.type == "columns":
        return _check_columns_slide(slide, slide_index, stats)
    if slide.type == "comparison":
        return _check_comparison_slide(slide, slide_index, stats)
    if slide.type == "timeline":
        return _check_timeline_slide(slide, slide_index, stats)
    if slide.type == "image_text":
        return _check_image_text_slide(slide, slide_index, stats)
    if slide.type == "image":
        return _check_image_slide(slide, slide_index, stats)
    if slide.type == "kpi_grid":
        return _check_kpi_grid_slide(slide, slide_index, stats)
    if slide.type == "quote":
        return _check_quote_slide(slide, slide_index, stats)
    if slide.type == "custom":
        stats.informative_slide_count += 1
    return []


def _check_section_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Reject section slides that are empty or fail to set up the next part of the deck clearly."""

    slide_name = _resolve_slide_name(slide)
    if not (slide.subtitle or slide.body):
        return [
            _build_slide_issue(
                slide,
                slide_index,
                category="section_title_only",
                message=f"Section slide '{slide_name}' is title-only and feels empty",
                repair_action=(
                    "Add a concrete subtitle or short setup that explains what the next part of the deck "
                    "will cover and why it matters."
                ),
                slide_name=slide_name,
            )
        ]

    stats.informative_slide_count += 1
    content_beats = len(_split_content_beats(slide.body or slide.subtitle))
    stats.total_content_beats += content_beats

    if content_beats < 2:
        return [
            _build_slide_issue(
                slide,
                slide_index,
                category="section_too_thin",
                message=f"Section slide '{slide_name}' needs a clearer setup or takeaway",
                repair_action=(
                    "Add at least two short content beats that frame the section with a concrete setup, "
                    "takeaway, or transition."
                ),
                slide_name=slide_name,
            )
        ]
    return []


def _check_columns_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Ensure each column contributes enough substance, whether through text or a compact table."""

    stats.informative_slide_count += 1
    issues: list[DeckQualityIssue] = []

    for column in slide.columns:
        if column.table is not None:
            table_issue = _check_card_table(
                slide=slide,
                slide_index=slide_index,
                title=column.title,
                table=column.table,
            )
            if table_issue is not None:
                issues.append(table_issue)
            stats.total_content_beats += _count_table_content_beats(column.table)

        if column.body and column.body.strip():
            content_beats = len(_split_content_beats(column.body))
            stats.total_content_beats += content_beats
        else:
            content_beats = 0

        if column.table is None and content_beats < 3:
            issues.append(
                _build_slide_issue(
                    slide,
                    slide_index,
                    category="column_too_thin",
                    message=f"Column '{column.title}' needs at least three short content lines",
                    repair_action=(
                        f"Expand column '{column.title}' into at least three short lines with concrete points "
                        "instead of a single sentence."
                    ),
                )
            )

    return issues


def _check_comparison_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Ensure both sides of a comparison carry enough substance to justify the side-by-side layout."""

    stats.informative_slide_count += 1
    issues: list[DeckQualityIssue] = []

    for side in (slide.left, slide.right):
        if side is None:
            continue
        issues.extend(_check_comparison_side(slide, slide_index, side, stats))

    return issues


def _check_comparison_side(
    slide: SlideSpec,
    slide_index: int,
    side,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    issues: list[DeckQualityIssue] = []
    if side.table is not None:
        table_issue = _check_card_table(
            slide=slide,
            slide_index=slide_index,
            title=side.title,
            table=side.table,
        )
        if table_issue is not None:
            issues.append(table_issue)
        stats.total_content_beats += _count_table_content_beats(side.table)

    content_beats = _count_optional_text_beats(side.body)
    stats.total_content_beats += content_beats

    if side.table is None and content_beats < 2:
        issues.append(
            _build_slide_issue(
                slide,
                slide_index,
                category="comparison_side_too_thin",
                message=f"Comparison side '{side.title}' needs richer content",
                repair_action=(
                    f"Add at least two distinct points to comparison side '{side.title}' so the "
                    "contrast is explicit and informative."
                ),
            )
        )

    return issues


def _check_timeline_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Ensure timeline steps explain the progression, not just the step labels."""

    stats.informative_slide_count += 1
    issues: list[DeckQualityIssue] = []

    for item in slide.items:
        content_beats = len(_split_content_beats(item.body))
        stats.total_content_beats += content_beats
        if content_beats < 2:
            issues.append(
                _build_slide_issue(
                    slide,
                    slide_index,
                    category="timeline_step_too_thin",
                    message=f"Timeline step '{item.title}' needs more explanatory detail",
                    repair_action=(
                        f"Expand timeline step '{item.title}' so it explains what happens, why it matters, "
                        "or what comes next."
                    ),
                )
            )

    return issues


def _check_image_text_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Ensure image-text slides carry enough text for their intended layout."""

    stats.informative_slide_count += 1
    if not slide.image_text:
        return []

    content_beats = len(_split_content_beats(slide.image_text.body))
    stats.total_content_beats += content_beats
    minimum_beats = 1 if slide.image_text.position == "bottom" else 3
    if content_beats < minimum_beats:
        slide_name = slide.title or slide.image_text.title or "untitled"
        if slide.image_text.position == "bottom":
            message = f"Image text slide '{slide_name}' needs at least a short explanatory caption"
            repair_action = (
                "Add at least one short explanatory beat under the visual so the slide still communicates "
                "a concrete takeaway."
            )
        else:
            message = f"Image text slide '{slide_name}' needs a denser explainer body"
            repair_action = (
                "Expand the text block into at least three short explanatory beats; if the slide is mostly "
                "verbal, consider converting it to a section or columns slide."
            )
        return [
            _build_slide_issue(
                slide,
                slide_index,
                category="image_text_too_thin",
                message=message,
                repair_action=repair_action,
                slide_name=slide_name,
            )
        ]
    return []


def _check_image_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Treat pure image slides as intentional visual content when they reference a real asset."""

    stats.informative_slide_count += 1
    stats.total_content_beats += 2
    if slide.image is None or not slide.image.asset_id.strip():
        slide_name = _resolve_slide_name(slide)
        return [
            _build_slide_issue(
                slide,
                slide_index,
                category="image_missing_asset",
                message=f"Image slide '{slide_name}' is missing its image asset reference",
                repair_action="Set image.asset_id to one of the available source image assets such as img_1.",
                slide_name=slide_name,
            )
        ]
    return []


def _check_kpi_grid_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Ensure KPI grids include enough metric detail to be informative instead of decorative."""

    stats.informative_slide_count += 1
    issues: list[DeckQualityIssue] = []

    metrics_with_detail = sum(1 for metric in slide.metrics if metric.detail and metric.detail.strip())
    stats.total_content_beats += sum(
        1 + (1 if metric.detail and metric.detail.strip() else 0) for metric in slide.metrics
    )
    non_metric_like_count = 0

    for metric in slide.metrics:
        metric_label = _resolve_metric_name(metric)
        metric_is_compact = True

        if not _is_compact_kpi_value(metric.value):
            metric_is_compact = False
            issues.append(
                _build_slide_issue(
                    slide,
                    slide_index,
                    category="kpi_value_too_verbose",
                    message=f"KPI metric '{metric_label}' has a value that is too verbose for a KPI card",
                    repair_action=(
                        f"Shorten the value for KPI metric '{metric_label}' to a compact single-line KPI, and move "
                        "narrative explanation into detail or convert the slide to columns if the card is really a takeaway."
                    ),
                )
            )

        if not _is_compact_kpi_label(metric.label):
            metric_is_compact = False
            issues.append(
                _build_slide_issue(
                    slide,
                    slide_index,
                    category="kpi_label_too_verbose",
                    message=f"KPI metric '{metric_label}' has a label that is too verbose for a KPI card",
                    repair_action=(
                        f"Shorten label '{metric.label}' to a compact KPI label, or convert the slide to columns "
                        "if the card is really carrying a narrative point."
                    ),
                )
            )

        if not _is_compact_kpi_detail(metric.detail):
            metric_is_compact = False
            issues.append(
                _build_slide_issue(
                    slide,
                    slide_index,
                    category="kpi_detail_too_verbose",
                    message=f"KPI metric '{metric_label}' has detail that is too long for a compact KPI card",
                    repair_action=(
                        f"Trim the detail for KPI metric '{metric_label}' to at most {KPI_CARD_DETAIL_MAX_BEATS} short beats, "
                        f"or convert the slide to columns if the card needs a fuller explanation."
                    ),
                )
            )

        if not metric_is_compact:
            non_metric_like_count += 1

    if metrics_with_detail < max(1, len(slide.metrics) - 1):
        issues.append(
            _build_slide_issue(
                slide,
                slide_index,
                category="kpi_missing_detail",
                message=f"KPI slide '{_resolve_slide_name(slide)}' needs details on most metrics",
                repair_action=(
                    "Add detail to most metrics so the KPI grid explains the meaning or implication of each "
                    "value instead of just listing numbers."
                ),
            )
        )

    if non_metric_like_count >= max(2, len(slide.metrics) // 2):
        issues.append(
            _build_slide_issue(
                slide,
                slide_index,
                category="kpi_grid_not_metric_like",
                message=f"KPI slide '{_resolve_slide_name(slide)}' is using KPI cards for narrative content",
                repair_action=(
                    "Convert this kpi_grid slide to columns or comparison if the cards are really takeaways, "
                    "actions, or mini-explainers instead of compact KPI readouts."
                ),
            )
        )

    return issues


def _check_quote_slide(
    slide: SlideSpec,
    slide_index: int,
    stats: DeckQualityStats,
) -> list[DeckQualityIssue]:
    """Ensure quote slides contain enough text to feel intentional and not like a placeholder."""

    stats.informative_slide_count += 1
    content_beats = len(_split_content_beats(slide.quote))
    stats.total_content_beats += content_beats

    if content_beats < 2:
        slide_name = _resolve_slide_name(slide)
        return [
            _build_slide_issue(
                slide,
                slide_index,
                category="quote_too_thin",
                message=f"Quote slide '{slide_name}' needs a more substantial quote",
                repair_action=(
                    "Expand the quote into at least two distinct ideas, or convert the slide to a section or "
                    "image_text slide if no genuine quote is available."
                ),
                slide_name=slide_name,
            )
        ]
    return []


def _count_sparse_non_title_slides(spec: DeckSpec) -> int:
    """Count visibly thin non-title slides for a deck-level emptiness heuristic."""

    return sum(
        1
        for slide in spec.slides
        if slide.type != "title"
        and (
            (slide.type == "section" and not (slide.subtitle or slide.body))
            or (slide.type == "quote" and len(_split_content_beats(slide.quote)) < 1)
        )
    )


def _is_globally_thin(stats: DeckQualityStats) -> bool:
    """Flag decks whose total amount of content is too low for their number of informative slides."""

    return stats.informative_slide_count >= 3 and stats.total_content_beats < stats.informative_slide_count * 2


def _resolve_slide_name(slide: SlideSpec) -> str:
    """Pick the most readable slide label available for user-facing quality messages."""

    return slide.title or slide.subtitle or "untitled"


def _build_slide_issue(
    slide: SlideSpec,
    slide_index: int,
    *,
    category: str,
    message: str,
    repair_action: str,
    slide_name: str | None = None,
) -> DeckQualityIssue:
    """Build one structured issue tied to a specific slide."""

    return DeckQualityIssue(
        category=category,
        message=message,
        repair_action=repair_action,
        slide_index=slide_index,
        slide_type=slide.type,
        slide_name=slide_name or _resolve_slide_name(slide),
    )


def _count_optional_text_beats(text: str | None) -> int:
    """Count content beats for optional text blocks, treating blank content as zero."""

    if not text or not text.strip():
        return 0
    return len(_split_content_beats(text))


def _check_card_table(
    *,
    slide: SlideSpec,
    slide_index: int,
    title: str,
    table: TableSpec,
) -> DeckQualityIssue | None:
    """Reject card tables that are too large to fit cleanly in one rendered PPTX card."""

    if len(table.columns) > TABLE_CARD_MAX_COLUMNS:
        return _build_slide_issue(
            slide,
            slide_index,
            category="card_table_too_wide",
            message=(f"Table '{title}' has {len(table.columns)} columns and will not fit cleanly in one card"),
            repair_action=(
                f"Replace table '{title}' with a concise summary or reduced excerpt that keeps at most "
                f"{TABLE_CARD_MAX_COLUMNS} columns."
            ),
        )
    if len(table.rows) > TABLE_CARD_MAX_ROWS:
        return _build_slide_issue(
            slide,
            slide_index,
            category="card_table_too_tall",
            message=(f"Table '{title}' has {len(table.rows)} rows and will not fit cleanly in one card"),
            repair_action=(
                f"Replace table '{title}' with a concise summary or reduced excerpt that keeps at most "
                f"{TABLE_CARD_MAX_ROWS} rows."
            ),
        )
    return None


def _count_table_content_beats(table: TableSpec) -> int:
    """Approximate content density for compact tables so deck-level quality checks treat them as substantive."""

    return max(2, min(len(table.rows), 4))


def _resolve_metric_name(metric: KpiMetric) -> str:
    label = metric.label.strip()
    if label:
        return label
    return metric.value.strip() or "untitled KPI"


def _count_words(text: str) -> int:
    return len([word for word in re.split(r"\s+", text.strip()) if word])


def _is_single_line(text: str) -> bool:
    return len(_split_content_beats(text)) <= 1


def _is_compact_kpi_value(value: str) -> bool:
    normalized = value.strip()
    return (
        bool(normalized)
        and _is_single_line(normalized)
        and len(normalized) <= KPI_CARD_VALUE_MAX_CHARS
        and _count_words(normalized) <= KPI_CARD_VALUE_MAX_WORDS
    )


def _is_compact_kpi_label(label: str) -> bool:
    normalized = label.strip()
    return (
        bool(normalized)
        and _is_single_line(normalized)
        and len(normalized) <= KPI_CARD_LABEL_MAX_CHARS
        and _count_words(normalized) <= KPI_CARD_LABEL_MAX_WORDS
    )


def _is_compact_kpi_detail(detail: str | None) -> bool:
    if detail is None or not detail.strip():
        return True
    normalized = detail.strip()
    return (
        len(normalized) <= KPI_CARD_DETAIL_MAX_CHARS
        and len(_split_content_beats(normalized)) <= KPI_CARD_DETAIL_MAX_BEATS
    )


def _split_content_beats(text: str | None) -> list[str]:
    """Split slide body text into short content beats using lines first, then sentence punctuation as fallback."""

    if not text:
        return []
    lines = [line.strip(" -\t").strip() for line in text.splitlines() if line.strip()]
    if len(lines) > 1:
        return lines
    return [chunk.strip() for chunk in re.split(r"(?<=[.!?;:])\s+", text.strip()) if chunk.strip()]
