"""Check planned text/table/chart edits and preserved SmartArt source risks."""

from __future__ import annotations

import unicodedata
from typing import Any

from .edit_safety import _is_verified_category_capability
from .selectors import (
    _chart_selectors,
    _replacement_selectors,
    _replacement_text,
    _table_selectors,
)


def _slot_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
    lookup: dict[tuple[int, str], dict[str, Any]] = {}
    for slide in library.get("slides", []):
        slide_index = int(slide.get("slide_index", 0))
        for slot in slide.get("slots", []):
            if slot.get("slot_id"):
                lookup[(slide_index, f"slot_id:{slot['slot_id']}")] = slot
            if slot.get("shape_id"):
                lookup[(slide_index, f"shape_id:{slot['shape_id']}")] = slot
            if slot.get("shape_name"):
                lookup[(slide_index, f"shape_name:{slot['shape_name']}")] = slot
    return lookup


def _table_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
    lookup: dict[tuple[int, str], dict[str, Any]] = {}
    for slide in library.get("slides", []):
        slide_index = int(slide.get("slide_index", 0))
        for table in slide.get("tables", []):
            if table.get("table_id"):
                lookup[(slide_index, f"table_id:{table['table_id']}")] = table
            if table.get("shape_id"):
                lookup[(slide_index, f"shape_id:{table['shape_id']}")] = table
            if table.get("shape_name"):
                lookup[(slide_index, f"shape_name:{table['shape_name']}")] = table
    return lookup


def _chart_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
    lookup: dict[tuple[int, str], dict[str, Any]] = {}
    for slide in library.get("slides", []):
        slide_index = int(slide.get("slide_index", 0))
        for chart in slide.get("charts", []):
            if chart.get("chart_id"):
                lookup[(slide_index, f"chart_id:{chart['chart_id']}")] = chart
            if chart.get("shape_id"):
                lookup[(slide_index, f"shape_id:{chart['shape_id']}")] = chart
            if chart.get("shape_name"):
                lookup[(slide_index, f"shape_name:{chart['shape_name']}")] = chart
    return lookup


def _table_cell_lookup(table: dict[str, Any]) -> dict[tuple[int, int], dict[str, Any]]:
    lookup: dict[tuple[int, int], dict[str, Any]] = {}
    for row in table.get("rows", []):
        for cell in row.get("cells", []):
            if not isinstance(cell, dict):
                continue
            try:
                key = (int(cell.get("row", -1)), int(cell.get("col", -1)))
            except (TypeError, ValueError):
                continue
            lookup[key] = cell
    return lookup


def _table_merge_slave_lookup(
    table: dict[str, Any],
) -> dict[tuple[int, int], dict[str, int] | None]:
    lookup: dict[tuple[int, int], dict[str, int] | None] = {}
    topology = table.get("merge_topology")
    if isinstance(topology, dict):
        for slave in topology.get("slave_cells", []):
            if not isinstance(slave, dict):
                continue
            try:
                key = (int(slave.get("row", -1)), int(slave.get("col", -1)))
            except (TypeError, ValueError):
                continue
            anchor = slave.get("anchor")
            lookup[key] = anchor if isinstance(anchor, dict) else None

    for key, cell in _table_cell_lookup(table).items():
        if cell.get("is_merge_slave") is True or cell.get("merge_role") == "slave":
            anchor = cell.get("merge_anchor")
            lookup.setdefault(key, anchor if isinstance(anchor, dict) else None)
    return lookup


def _chart_edit_capability_review(
    chart: dict[str, Any],
) -> tuple[tuple[str, str] | None, list[tuple[str, str]]]:
    capability = chart.get("edit_capability")
    if not isinstance(capability, dict):
        return None, [
            (
                "chart_edit_capability_unknown",
                "chart edit capability is missing; runtime will inspect the actual "
                "chart XML before mutation, and re-running template analysis is recommended",
            )
        ]
    if capability.get("supported") is not True:
        return (
            str(capability.get("code") or "chart_edit_capability_unknown"),
            str(capability.get("message") or "chart edit capability is unsupported"),
        ), []
    if not _is_verified_category_capability(capability):
        return (
            "chart_edit_capability_unknown",
            "chart edit capability is not the verified single-plot c:cat/c:val model",
        ), []

    capability_warnings: list[tuple[str, str]] = []
    raw_warnings = capability.get("warnings")
    if isinstance(raw_warnings, list):
        for item in raw_warnings:
            if not isinstance(item, dict):
                continue
            code = str(item.get("code") or "chart_edit_category_flattened")
            message = str(item.get("message") or "chart categories will be flattened")
            capability_warnings.append((code, message))
    return None, capability_warnings


def _visual_width(text: str) -> float:
    """Estimate rendered text width in Latin-character units.

    ``len(text)`` is too crude for mixed CJK / Latin decks: Chinese characters
    generally consume about twice the horizontal space of ASCII letters, while
    punctuation and digits are narrower. The checker only needs a conservative
    fit signal, so use Unicode East Asian Width instead of a font-specific
    renderer.
    """
    width = 0.0
    for char in "".join(text.split()):
        east_asian_width = unicodedata.east_asian_width(char)
        if east_asian_width in {"F", "W"}:
            width += 2.0
        elif east_asian_width == "A":
            width += 1.5
        else:
            width += 1.0
    return width


def _display_width(value: float) -> int | float:
    return int(value) if value.is_integer() else round(value, 1)


def _fallback_font_size_px(role: str, geometry: dict[str, Any], old_paragraphs: int) -> float:
    height = geometry.get("height")
    if isinstance(height, int) and old_paragraphs > 0:
        inferred = height / max(old_paragraphs, 1) / 1.25
        if 8 <= inferred <= 56:
            return inferred
    if role == "title_candidate":
        return 28.0
    if role == "body_candidate":
        return 16.0
    return 14.0


def _geometry_capacity_width(
    *,
    role: str,
    old_paragraphs: int,
    new_paragraphs: int,
    geometry: dict[str, Any],
    text_metrics: dict[str, Any],
) -> float | None:
    width = geometry.get("width")
    height = geometry.get("height")
    if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0:
        return None

    font_size_px = text_metrics.get("font_size_px")
    if not isinstance(font_size_px, (int, float)) or font_size_px <= 0:
        font_size_px = _fallback_font_size_px(role, geometry, old_paragraphs)

    line_height = max(font_size_px * 1.25, 1.0)
    max_lines = max(int(height / line_height), old_paragraphs, new_paragraphs, 1)
    horizontal_padding = 24 if width >= 180 else 12
    usable_width = max(width - horizontal_padding, width * 0.72, 1)
    latin_units_per_line = usable_width / max(font_size_px * 0.52, 1)
    capacity = latin_units_per_line * max_lines

    if role == "label_candidate":
        return capacity * 0.7
    if role == "title_candidate":
        return capacity * 0.85
    return capacity


def _fit_status(
    *,
    role: str,
    old_width: float,
    new_width: float,
    old_paragraphs: int,
    new_paragraphs: int,
    geometry: dict[str, Any],
    text_metrics: dict[str, Any],
) -> tuple[str, str]:
    old_width = max(old_width, 1.0)
    ratio = new_width / old_width
    width = geometry.get("width")
    height = geometry.get("height")
    capacity_width = _geometry_capacity_width(
        role=role,
        old_paragraphs=old_paragraphs,
        new_paragraphs=new_paragraphs,
        geometry=geometry,
        text_metrics=text_metrics,
    )

    if role == "label_candidate" or (old_width <= 8 and old_paragraphs <= 1):
        if capacity_width is not None and new_width <= capacity_width and not (old_width <= 8):
            return "OK", "short label fits estimated text-box capacity"
        label_limit = old_width
        if isinstance(width, int) and width >= 220:
            label_limit = max(label_limit, old_width * 1.25)
        if new_width > label_limit:
            return "WARN", "short label exceeds original visual width; rewrite shorter"
        return "OK", "short label fits original visual width"

    if role == "title_candidate" and old_paragraphs <= 1:
        if capacity_width is not None and new_width <= capacity_width:
            return "OK", "title fits estimated text-box capacity"
        limit = 1.15 if old_width <= 12 else 1.35
        if ratio > limit:
            return "WARN", "title is too long for the original slot; rewrite first"
        return "OK", "title stays near original capacity"

    paragraph_limit = max(old_paragraphs + 2, old_paragraphs * 2, 2)
    if new_paragraphs > paragraph_limit:
        return "WARN", "body paragraph count changed too much; compress or split pages"

    if isinstance(width, int) and isinstance(height, int) and width * height < 30000 and ratio > 2.0:
        return "WARN", "small text box with much longer text; rewrite shorter"

    if capacity_width is not None and new_width > capacity_width:
        return "WARN", "text exceeds estimated text-box capacity; rewrite or split"

    # Body text reflows, so a moderate amount of extra length is fine; only flag
    # gross overflow. Labels / titles keep their tighter guards above.
    body_limit = 3.0 if role == "body_candidate" else 2.2
    if ratio > body_limit:
        return "WARN", "text is much longer than source slot; rewrite or choose another page"
    return "OK", "within estimated slot capacity"


def _capacity_for_report(
    *,
    role: str,
    old_width: float,
    old_paragraphs: int,
    new_paragraphs: int,
    geometry: dict[str, Any],
    text_metrics: dict[str, Any],
) -> float | None:
    capacity = _geometry_capacity_width(
        role=role,
        old_paragraphs=old_paragraphs,
        new_paragraphs=new_paragraphs,
        geometry=geometry,
        text_metrics=text_metrics,
    )
    if capacity is None:
        return None
    return _display_width(max(capacity, old_width))


def _library_slide_index(library: dict[str, Any]) -> dict[int, dict[str, Any]]:
    """Build a mapping from slide_index to slide dict for O(1) lookup."""
    return {int(s.get("slide_index", 0)): s for s in library.get("slides", [])}


def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
    """Compare fill replacements against source slot capacity."""
    lookup = _slot_lookup(library)
    table_lookup = _table_lookup(library)
    chart_lookup = _chart_lookup(library)
    results: list[dict[str, Any]] = []
    summary = {"ok": 0, "warn": 0, "error": 0}

    for slide_index, slide in enumerate(plan.get("slides", []), start=1):
        source_slide = int(slide.get("source_slide", 0))
        replacements = slide.get("replacements", [])
        if not isinstance(replacements, list):
            results.append(
                {
                    "status": "ERROR",
                    "code": "replacements_not_list",
                    "plan_slide": slide_index,
                    "source_slide": source_slide,
                    "message": "replacements must be a list",
                }
            )
            summary["error"] += 1
            continue

        for replacement in replacements:
            selectors = _replacement_selectors(replacement)
            slot = next((lookup.get((source_slide, selector)) for selector in selectors), None)
            text = _replacement_text(replacement)
            if slot is None:
                results.append(
                    {
                        "status": "ERROR",
                        "code": "replacement_target_not_found",
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "message": "replacement target not found in slide library",
                    }
                )
                summary["error"] += 1
                continue

            old_text = str(slot.get("text") or "")
            old_width = _visual_width(old_text)
            new_width = _visual_width(text)
            old_paragraphs = int(slot.get("paragraph_count") or 1)
            new_paragraphs = max(len([line for line in text.splitlines() if line.strip()]), 1)
            status, message = _fit_status(
                role=str(slot.get("role") or ""),
                old_width=old_width,
                new_width=new_width,
                old_paragraphs=old_paragraphs,
                new_paragraphs=new_paragraphs,
                geometry=slot.get("geometry") or {},
                text_metrics=slot.get("text_metrics") or {},
            )
            capacity_width = _capacity_for_report(
                role=str(slot.get("role") or ""),
                old_width=old_width,
                old_paragraphs=old_paragraphs,
                new_paragraphs=new_paragraphs,
                geometry=slot.get("geometry") or {},
                text_metrics=slot.get("text_metrics") or {},
            )
            summary["warn" if status == "WARN" else "ok"] += 1
            results.append(
                {
                    "status": status,
                    "code": "text_capacity" if status == "WARN" else "text_fit",
                    "plan_slide": slide_index,
                    "source_slide": source_slide,
                    "slot_id": slot.get("slot_id"),
                    "role": slot.get("role"),
                    "old_len": _display_width(old_width),
                    "new_len": _display_width(new_width),
                    "old_visual_width": _display_width(old_width),
                    "new_visual_width": _display_width(new_width),
                    "capacity_visual_width": capacity_width,
                    "ratio": round(new_width / max(old_width, 1.0), 2),
                    "old_paragraphs": old_paragraphs,
                    "new_paragraphs": new_paragraphs,
                    "message": message,
                    "old_text": old_text,
                    "new_text": text,
                }
            )
        table_edits = slide.get("table_edits", [])
        if not isinstance(table_edits, list):
            results.append(
                {
                    "status": "ERROR",
                    "code": "table_edits_not_list",
                    "plan_slide": slide_index,
                    "source_slide": source_slide,
                    "message": "table_edits must be a list",
                }
            )
            summary["error"] += 1
            continue
        for table_edit in table_edits:
            selectors = _table_selectors(table_edit)
            table = next((table_lookup.get((source_slide, selector)) for selector in selectors), None)
            if table is None:
                results.append(
                    {
                        "status": "ERROR",
                        "code": "table_target_not_found",
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "message": "table target not found in slide library",
                    }
                )
                summary["error"] += 1
                continue
            cells = table_edit.get("cells", [])
            if not isinstance(cells, list):
                results.append(
                    {
                        "status": "ERROR",
                        "code": "table_cells_not_list",
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "message": "table edit cells must be a list",
                    }
                )
                summary["error"] += 1
                continue
            row_count = int(table.get("row_count") or 0)
            column_count = int(table.get("column_count") or 0)
            table_cells = _table_cell_lookup(table)
            merge_slaves = _table_merge_slave_lookup(table)
            for cell in cells:
                row = int(cell.get("row", -1))
                col = int(cell.get("col", -1))
                if (
                    row < 0
                    or col < 0
                    or row >= row_count
                    or col >= column_count
                    or (table_cells and (row, col) not in table_cells)
                ):
                    results.append(
                        {
                            "status": "ERROR",
                            "code": "table_cell_out_of_bounds",
                            "plan_slide": slide_index,
                            "source_slide": source_slide,
                            "selector": selectors[0] if selectors else "",
                            "message": f"table cell out of bounds: row={row} col={col}",
                        }
                    )
                    summary["error"] += 1
                    continue
                if (row, col) in merge_slaves:
                    anchor = merge_slaves[(row, col)]
                    anchor_hint = ""
                    if anchor is not None:
                        anchor_hint = (
                            f"; edit merge anchor row={anchor.get('row')} "
                            f"col={anchor.get('col')} instead"
                        )
                    results.append(
                        {
                            "status": "ERROR",
                            "code": "table_cell_is_merge_slave",
                            "plan_slide": slide_index,
                            "source_slide": source_slide,
                            "selector": selectors[0] if selectors else "",
                            "table_id": table.get("table_id"),
                            "row": row,
                            "col": col,
                            "message": (
                                f"table cell row={row} col={col} is a merged-cell slave"
                                f"{anchor_hint}"
                            ),
                        }
                    )
                    summary["error"] += 1
                    continue
                summary["ok"] += 1
                results.append(
                    {
                        "status": "OK",
                        "code": "table_target_exists",
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "table_id": table.get("table_id"),
                        "row": row,
                        "col": col,
                        "message": "table cell target exists",
                    }
                )
        chart_edits = slide.get("chart_edits", [])
        if not isinstance(chart_edits, list):
            results.append(
                {
                    "status": "ERROR",
                    "code": "chart_edits_not_list",
                    "plan_slide": slide_index,
                    "source_slide": source_slide,
                    "message": "chart_edits must be a list",
                }
            )
            summary["error"] += 1
            continue
        for chart_edit in chart_edits:
            selectors = _chart_selectors(chart_edit)
            chart = next((chart_lookup.get((source_slide, selector)) for selector in selectors), None)
            if chart is None:
                results.append(
                    {
                        "status": "ERROR",
                        "code": "chart_target_not_found",
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "message": "chart target not found in slide library",
                    }
                )
                summary["error"] += 1
                continue
            capability_error, capability_warnings = _chart_edit_capability_review(chart)
            if capability_error is not None:
                error_code, error_message = capability_error
                results.append(
                    {
                        "status": "ERROR",
                        "code": error_code,
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "chart_id": chart.get("chart_id"),
                        "message": error_message,
                    }
                )
                summary["error"] += 1
                continue
            for warning_code, warning_message in capability_warnings:
                results.append(
                    {
                        "status": "WARN",
                        "code": warning_code,
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "chart_id": chart.get("chart_id"),
                        "message": warning_message,
                    }
                )
                summary["warn"] += 1
            categories = chart_edit.get("categories", [])
            series = chart_edit.get("series", [])
            if not isinstance(categories, list) or not isinstance(series, list) or not series:
                results.append(
                    {
                        "status": "ERROR",
                        "code": "chart_data_invalid",
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "message": "chart edit requires categories list and non-empty series list",
                    }
                )
                summary["error"] += 1
                continue
            bad_series = [
                item
                for item in series
                if not isinstance(item, dict)
                or not isinstance(item.get("values", []), list)
                or len(item.get("values", [])) != len(categories)
            ]
            if bad_series:
                results.append(
                    {
                        "status": "ERROR",
                        "code": "chart_series_length_mismatch",
                        "plan_slide": slide_index,
                        "source_slide": source_slide,
                        "selector": selectors[0] if selectors else "",
                        "message": "each chart series needs values matching categories length",
                    }
                )
                summary["error"] += 1
                continue
            summary["ok"] += 1
            results.append(
                {
                    "status": "OK",
                    "code": "chart_target_valid",
                    "plan_slide": slide_index,
                    "source_slide": source_slide,
                    "chart_id": chart.get("chart_id"),
                    "category_count": len(categories),
                    "series_count": len(series),
                    "message": "chart edit target and data shape are valid",
                }
            )
    # --- Guardrail 2: source slides with non-text content not covered by edits ---
    # Tables/charts may be covered by explicit edits. SmartArt is preserve-only,
    # so selecting a source slide that contains it always needs a content review.
    lib_slides = _library_slide_index(library)
    for plan_slide_index, slide in enumerate(plan.get("slides", []), start=1):
        source_slide = int(slide.get("source_slide", 0))
        lib_slide = lib_slides.get(source_slide)
        if lib_slide is None:
            continue
        lib_tables = lib_slide.get("tables", [])
        lib_charts = lib_slide.get("charts", [])
        lib_diagrams = lib_slide.get("diagrams", [])
        if not lib_tables and not lib_charts and not lib_diagrams:
            continue
        # Check whether the plan slide provides edits covering the non-text content.
        has_table_edits = bool(slide.get("table_edits"))
        has_chart_edits = bool(slide.get("chart_edits"))
        uncovered_kinds: list[str] = []
        if lib_tables and not has_table_edits:
            uncovered_kinds.append("table")
        if lib_charts and not has_chart_edits:
            uncovered_kinds.append("chart")
        if lib_diagrams:
            uncovered_kinds.append("smartart")
        if not uncovered_kinds:
            continue
        kind_str = "/".join(uncovered_kinds)
        guidance = "add table_edits/chart_edits, or pick another source slide"
        if lib_diagrams:
            guidance = "SmartArt remains unchanged, so pick another source slide or accept this warning"
            if any(kind in uncovered_kinds for kind in ("table", "chart")):
                guidance = f"add table/chart edits where supported; {guidance}"
        summary["warn"] += 1
        results.append(
            {
                "status": "WARN",
                "code": "non_text_content_unedited",
                "plan_slide": plan_slide_index,
                "source_slide": source_slide,
                "uncovered_kinds": uncovered_kinds,
                "diagram_ids": [
                    diagram.get("diagram_id")
                    for diagram in lib_diagrams
                    if diagram.get("diagram_id")
                ],
                "message": (
                    f"source slide {source_slide} has non-text content ({kind_str}) "
                    "outside the plan's supported edits; template-fill leaves it untouched "
                    f"and original template content may show through ({guidance})"
                ),
            }
        )

    # --- Guardrail 1: same source slide reused too many times while unused layouts exist ---
    # Use a relative condition rather than an absolute threshold: only warn when
    # (a) a source slide is reused >= REUSE_WARN_THRESHOLD times, AND
    # (b) there are library slides that the plan never uses at all.
    # Rationale: a small template where every layout is referenced is fine even at
    # high per-slide reuse; "15-page template where only 1 page is ever cloned and
    # the rest sit idle" is the real smell we want to surface.
    # Threshold of 3: any source appearing 3+ times in a plan is meaningful reuse
    # (cover / TOC / ending typically appear at most twice), so >= 3 is a practical
    # signal without being overly sensitive.
    REUSE_WARN_THRESHOLD = 3
    source_use_counts: dict[int, int] = {}
    for slide in plan.get("slides", []):
        src = int(slide.get("source_slide", 0))
        if src:
            source_use_counts[src] = source_use_counts.get(src, 0) + 1
    all_lib_indices = {int(s.get("slide_index", 0)) for s in library.get("slides", []) if s.get("slide_index")}
    used_lib_indices = set(source_use_counts.keys())
    unused_lib_indices = sorted(all_lib_indices - used_lib_indices)
    if unused_lib_indices:
        for src, count in sorted(source_use_counts.items()):
            if count >= REUSE_WARN_THRESHOLD:
                unused_list = ", ".join(str(i) for i in unused_lib_indices)
                summary["warn"] += 1
                results.append(
                    {
                        "status": "WARN",
                        "code": "source_reuse_concentration",
                        "source_slide": src,
                        "reuse_count": count,
                        "unused_source_slides": unused_lib_indices,
                        "message": (
                            f"source slide {src} is reused {count} times while "
                            f"{len(unused_lib_indices)} source layout(s) are never used "
                            f"(indices: {unused_list}); "
                            "consider using other layouts for more variety"
                        ),
                    }
                )

    return {"schema": "template_fill_pptx_check.v1", "summary": summary, "results": results}


def print_check_report(report: dict[str, Any]) -> None:
    summary = report["summary"]
    print(f"check-plan: ok={summary['ok']} warn={summary['warn']} error={summary['error']}")
    for item in report["results"]:
        if item["status"] == "OK":
            continue
        if "ratio" in item:
            line = (
                "{status} P{plan_slide:02d} source={source_slide} {slot_id} "
                "{role} old={old_len} new={new_len} ratio={ratio}: {message}".format(**item)
            )
        elif "plan_slide" in item:
            target = item.get("slot_id") or item.get("selector") or ""
            line = (
                f"{item['status']} P{item['plan_slide']:02d} "
                f"source={item['source_slide']} {target}: {item['message']}".strip()
            )
        else:
            # Guardrail 1 WARNs are source-level (no plan_slide); print source + message.
            line = f"{item['status']} source={item.get('source_slide', '?')}: {item['message']}"
        print(line)
