"""Tests for the OVERVIEW chapter — DoD: golden + edges + degradation.

Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. Verifies that ``build_overview`` renders the raw first rows
(``df.head``) as a DataTable when ``head_rows`` is present — both when it arrives
via ``profile['head_rows']`` (populated by ``profile_table``) and via
``ctx['head_rows']`` (populated by ``build_eda_render_ctx``) — that the chapter
also renders the column dictionary and the numeric describe, that the full
document renders to PDF and PPTX showing the head values, and that a profile with
NO head data degrades to an honest note instead of raising or inventing rows.
"""

import os
import re
import tempfile

from pypdf import PdfReader
from pptx import Presentation

from datascience.automatic_eda.model import DataTable, Note
from datascience.automatic_eda.chapters.overview import (
    CHAPTER_ID, CHAPTER_VERSION, build_overview,
)
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx


def _columns() -> list:
    return [
        {"name": "PassengerId", "inferred_type": "numeric", "null_pct": 0.0,
         "null_count": 0, "numeric": {"mean": 2.0, "median": 2.0, "min": 1.0,
                                      "max": 3.0, "std": 1.0}},
        {"name": "Survived", "inferred_type": "numeric", "null_pct": 0.0,
         "null_count": 0, "numeric": {"mean": 0.33, "median": 0.0, "min": 0.0,
                                      "max": 1.0, "std": 0.58}},
        {"name": "Pclass", "inferred_type": "numeric", "null_pct": 0.0,
         "null_count": 0, "numeric": {"mean": 2.33, "median": 3.0, "min": 1.0,
                                      "max": 3.0, "std": 1.15}},
        {"name": "Name", "inferred_type": "categorical", "null_pct": 0.0,
         "null_count": 0, "distinct_count": 3},
        {"name": "Sex", "inferred_type": "categorical", "null_pct": 0.0,
         "null_count": 0, "distinct_count": 2,
         "categorical": {"top": [{"value": "male", "count": 2},
                                 {"value": "female", "count": 1}]}},
    ]


def _head_rows() -> list:
    return [
        {"PassengerId": 1, "Survived": 0, "Pclass": 3,
         "Name": "Braund Owen", "Sex": "male"},
        {"PassengerId": 2, "Survived": 1, "Pclass": 1,
         "Name": "Cumings Florence", "Sex": "female"},
        {"PassengerId": 3, "Survived": 1, "Pclass": 3,
         "Name": "Heikkinen Laina", "Sex": "female"},
    ]


def _llm() -> dict:
    """Interpretive block as eda_llm_insights stores it under profile['llm']."""
    return {
        "summary": "Pasajeros del Titanic.",
        "dictionary": [
            {"column": "PassengerId", "description": "Identificador del pasajero",
             "business_meaning": "Clave única de cada pasajero", "unit": "id"},
            {"column": "Pclass", "description": "Clase del billete",
             "business_meaning": "Clase socioeconómica", "unit": "clase (1-3)"},
            # No entry for Survived/Name/Sex on purpose -> they degrade to "—".
        ],
    }


def _profile(with_head: bool = True, with_llm: bool = False) -> dict:
    prof = {
        "table": "titanic",
        "source": "/data/titanic.csv",
        "profiled_at": "2026-06-30T10:00:00+00:00",
        "n_rows": 891,
        "n_cols": 5,
        "quality_score": 88.0,
        "columns": _columns(),
    }
    if with_head:
        prof["head_rows"] = _head_rows()
    if with_llm:
        prof["llm"] = _llm()
    return prof


def _pdf_text(path: str) -> str:
    txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
    return re.sub(r"\s+", " ", txt)


def _pptx_text(path: str) -> str:
    prs = Presentation(path)
    parts = []
    for sl in prs.slides:
        for sh in sl.shapes:
            if sh.has_text_frame:
                parts.append(sh.text_frame.text)
            if sh.has_table:
                tb = sh.table
                for r in range(len(tb.rows)):
                    for c in range(len(tb.columns)):
                        parts.append(tb.cell(r, c).text)
    return re.sub(r"\s+", " ", " ".join(parts))


def _flatten(blocks):
    """Recursively flatten Group blocks into a flat list (none here today)."""
    out = []
    for b in blocks:
        inner = getattr(b, "blocks", None)
        if inner is not None and getattr(b, "kind", None) == "group":
            out.extend(_flatten(inner))
        else:
            out.append(b)
    return out


def test_golden_build_overview_muestra_head_desde_profile():
    ch = build_overview(_profile(), {})
    assert ch is not None
    assert ch.id == CHAPTER_ID
    assert ch.version == CHAPTER_VERSION
    blocks = _flatten(ch.blocks)
    # The first DataTable is df.head: its header is the column names and the
    # real first rows are present (not a placeholder note).
    tables = [b for b in blocks if isinstance(b, DataTable)]
    assert tables, "overview must emit at least the df.head DataTable"
    head_tbl = tables[0]
    assert head_tbl.header == ["PassengerId", "Survived", "Pclass",
                               "Name", "Sex"]
    assert len(head_tbl.rows) == 3
    flat = [str(c) for row in head_tbl.rows for c in row]
    assert "Braund Owen" in flat and "Cumings Florence" in flat
    # Honest note carries how many rows shown out of the dataset total.
    assert head_tbl.note is not None
    assert "primeras 3 filas" in head_tbl.note and "891" in head_tbl.note
    # No "df.head no disponible" placeholder when head_rows is present.
    assert not any(isinstance(b, Note) and "no disponible" in b.text
                   for b in blocks)


def test_golden_head_desde_ctx_tambien_funciona():
    # head_rows absent in profile but present in ctx (build_eda_render_ctx path).
    prof = _profile(with_head=False)
    ch = build_overview(prof, {"head_rows": _head_rows()})
    assert ch is not None
    tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
    flat = [str(c) for row in tables[0].rows for c in row]
    assert "Braund Owen" in flat


def test_golden_render_pdf_muestra_head():
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "eda.pdf")
        res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA"})
        assert res["path"] == out and os.path.exists(out)
        assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
        txt = _pdf_text(out)
        assert "Braund" in txt and "male" in txt
        assert "primeras" in txt          # head note rendered.
        assert "df.head" in txt           # chapter heading rendered.
        assert "no disponible" not in txt  # placeholder NOT shown.


def test_golden_render_pptx_muestra_head():
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "eda.pptx")
        res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
        assert res["path"] == out and os.path.exists(out)
        assert CHAPTER_ID in [c["id"] for c in res["chapters"]]
        txt = _pptx_text(out)
        assert "Braund" in txt and "Cumings" in txt


def test_edge_sin_head_rows_degrada_a_nota_honesta():
    # No head data anywhere: chapter still builds (columns exist), shows the
    # honest placeholder note, and never invents rows nor raises.
    prof = _profile(with_head=False)
    ch = build_overview(prof, {})
    assert ch is not None
    blocks = _flatten(ch.blocks)
    assert any(isinstance(b, Note) and "no disponible" in b.text
               for b in blocks)
    # The first DataTable now is the column dictionary, not df.head rows.
    tables = [b for b in blocks if isinstance(b, DataTable)]
    assert all("Braund" not in str(c)
               for tbl in tables for row in tbl.rows for c in row)


def test_edge_none_y_vacio_no_rompen():
    # Nothing to render at all -> None, no raise.
    assert build_overview(None, None) is None
    assert build_overview({}, {}) is None
    assert build_overview({"columns": []}, {}) is None
    # Only head_rows (no columns) still yields a chapter with the head table.
    ch = build_overview({"columns": []}, {"head_rows": _head_rows()})
    assert ch is not None
    tables = [b for b in _flatten(ch.blocks) if isinstance(b, DataTable)]
    assert tables and len(tables[0].rows) == 3


def _table_by_header(blocks, marker: str):
    """Return the first DataTable whose header contains ``marker``."""
    for b in _flatten(blocks):
        if isinstance(b, DataTable) and marker in b.header:
            return b
    return None


def test_golden_diccionario_lleva_descripcion_y_unidad_del_llm():
    # With run_llm: the column dictionary gains "Descripción" and "Unidad"
    # columns populated from profile['llm']['dictionary'], matched by name.
    ch = build_overview(_profile(with_llm=True), {})
    assert ch is not None
    dic = _table_by_header(ch.blocks, "Descripción")
    assert dic is not None
    assert dic.header == ["Columna", "Tipo", "Nulos", "Ejemplos (no nulos)",
                          "Descripción", "Unidad"]
    by_name = {row[0]: row for row in dic.rows}
    # PassengerId has an LLM entry -> description + unit populated.
    assert by_name["PassengerId"][4] == "Identificador del pasajero"
    assert by_name["PassengerId"][5] == "id"
    assert by_name["Pclass"][5] == "clase (1-3)"
    # Columns with no LLM entry degrade to "—" without breaking the row.
    assert by_name["Survived"][4] == "—" and by_name["Survived"][5] == "—"


def test_golden_describe_lleva_unidad_del_llm():
    ch = build_overview(_profile(with_llm=True), {})
    desc = _table_by_header(ch.blocks, "std")
    assert desc is not None
    assert desc.header[-1] == "Unidad"
    by_name = {row[0]: row for row in desc.rows}
    assert by_name["PassengerId"][-1] == "id"
    assert by_name["Pclass"][-1] == "clase (1-3)"
    # Numeric column with no LLM unit still renders, unit "—".
    assert by_name["Survived"][-1] == "—"


def test_edge_sin_llm_descripcion_unidad_son_guion():
    # No profile['llm'] at all: the new cells degrade to "—" and nothing breaks.
    ch = build_overview(_profile(), {})
    assert ch is not None
    dic = _table_by_header(ch.blocks, "Unidad")
    assert dic is not None
    for row in dic.rows:
        assert row[4] == "—" and row[5] == "—"
    desc = _table_by_header(ch.blocks, "std")
    assert all(row[-1] == "—" for row in desc.rows)


def test_golden_llm_via_ctx_tambien_funciona():
    # LLM block arriving through ctx['llm'] (fallback path) is consumed too.
    ch = build_overview(_profile(with_llm=False), {"llm": _llm()})
    dic = _table_by_header(ch.blocks, "Descripción")
    by_name = {row[0]: row for row in dic.rows}
    assert by_name["PassengerId"][5] == "id"


def test_golden_render_pdf_muestra_descripcion_y_unidad():
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "eda.pdf")
        render_automatic_eda_pdf(_profile(with_llm=True), out, {"title": "EDA"})
        txt = _pdf_text(out)
        assert "Descripción" in txt and "Unidad" in txt
        assert "Identificador del pasajero" in txt
