"""Tests for the GEOSPATIAL chapter — DoD: golden + edges + anti-cut.

Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. The raw coordinate arrays are passed through ``ctx`` exactly
as the chapter's contract documents (``ctx['geo_points']`` / ``ctx['raw_numeric']``).

Verifies that the chapter detects the lat/lon pair, draws the geographic scatter
figure, analyses the zone/country (bounding box + per-region counts), returns
None when there are no coordinates, degrades honestly when the raw points are
absent, and that a profile with long column names + many points + several
regions renders to PDF and PPTX without cutting any text (long content wraps, it
is never truncated).
"""

import os
import re
import tempfile

from pypdf import PdfReader
from pptx import Presentation

from datascience.automatic_eda.chapters.geospatial import (
    build_geospatial,
    CHAPTER_VERSION,
)
from datascience.automatic_eda import build_document, render_pdf, render_pptx


# --------------------------------------------------------------------------- #
# Synthetic data helpers
# --------------------------------------------------------------------------- #
def _grid(lat0: float, lon0: float, n: int, spread: float = 1.0):
    """A small deterministic cloud of n points around (lat0, lon0)."""
    lats, lons = [], []
    for i in range(n):
        # deterministic pseudo-spread, no randomness.
        f = (i % 11) / 11.0 - 0.5
        g = (i % 7) / 7.0 - 0.5
        lats.append(lat0 + f * spread)
        lons.append(lon0 + g * spread)
    return lats, lons


def _profile_with_coords(lat_name="lat", lon_name="lon", lats=None, lons=None):
    """A profile carrying a lat/lon column pair with valid ranges."""
    lats = lats if lats is not None else [40.4, 41.0, 39.8, 40.1]
    lons = lons if lons is not None else [-3.7, -3.6, -4.0, -3.9]
    return {
        "table": "lugares",
        "columns": [
            {"name": lat_name, "inferred_type": "numeric",
             "numeric": {"min": min(lats), "max": max(lats),
                         "mean": sum(lats) / len(lats)}},
            {"name": lon_name, "inferred_type": "numeric",
             "numeric": {"min": min(lons), "max": max(lons),
                         "mean": sum(lons) / len(lons)}},
            {"name": "valor", "inferred_type": "numeric",
             "numeric": {"min": 0, "max": 100, "mean": 50}},
        ],
    }


def _ctx_points(lats, lons):
    return {"geo_points": {"lats": lats, "lons": lons}}


def _kinds(chapter):
    return [getattr(b, "kind", None) for b in chapter.blocks]


def _tables(chapter):
    return [b for b in chapter.blocks if getattr(b, "kind", None) == "data_table"]


def _figures(chapter):
    return [b for b in chapter.blocks if getattr(b, "kind", None) == "figure"]


# --------------------------------------------------------------------------- #
# Golden
# --------------------------------------------------------------------------- #
def test_golden_estructura_y_version():
    lats, lons = [40.4, 41.0, 39.8, 40.1], [-3.7, -3.6, -4.0, -3.9]
    ch = build_geospatial(_profile_with_coords(lats=lats, lons=lons),
                          _ctx_points(lats, lons))
    assert ch is not None
    assert ch.id == "geospatial"
    assert ch.version == CHAPTER_VERSION
    kinds = _kinds(ch)
    # intro heading + markdown + scatter figure + extent kv + per-region table.
    assert "heading" in kinds
    assert "markdown" in kinds
    assert "figure" in kinds, "falta el scatter geográfico"
    assert "kv_table" in kinds, "falta la tabla de extensión"


def test_golden_detecta_columnas_y_nombra_ejes():
    lats, lons = _grid(40.4, -3.7, 30, spread=0.8)
    prof = _profile_with_coords("latitude", "longitude", lats, lons)
    ch = build_geospatial(prof, _ctx_points(lats, lons))
    intro = [b for b in ch.blocks if b.kind == "markdown"][0].text
    assert "latitude" in intro and "longitude" in intro


def test_golden_figura_es_perezosa_y_dibujable():
    lats, lons = _grid(40.4, -3.7, 50, spread=0.6)
    ch = build_geospatial(_profile_with_coords(lats=lats, lons=lons),
                          _ctx_points(lats, lons))
    fig_block = _figures(ch)[0]
    assert fig_block.make is not None and fig_block.fig is None  # lazy
    fig = fig_block.make()  # must draw without raising
    assert fig is not None
    import matplotlib.pyplot as plt
    plt.close(fig)


def test_golden_analisis_por_zona_espana():
    lats, lons = _grid(40.4, -3.7, 40, spread=0.5)  # Madrid area
    ch = build_geospatial(_profile_with_coords(lats=lats, lons=lons),
                          _ctx_points(lats, lons))
    tables = _tables(ch)
    region_tbl = [t for t in tables if "zona" in (t.title or "").lower()]
    assert region_tbl, "falta la tabla por zona/país"
    flat = " ".join(" ".join(str(c) for c in r) for r in region_tbl[0].rows)
    # Spain-area points must resolve to a Spain/European region, not empty.
    assert region_tbl[0].rows
    assert any(c for c in (region_tbl[0].rows[0]))


def test_golden_raw_numeric_source():
    """Coordinates can also come from ctx['raw_numeric'] keyed by detected cols."""
    lats, lons = _grid(48.85, 2.35, 25, spread=0.4)  # Paris area
    prof = _profile_with_coords("lat", "lon", lats, lons)
    ctx = {"raw_numeric": {"lat": lats, "lon": lons}}
    ch = build_geospatial(prof, ctx)
    assert ch is not None
    assert _figures(ch), "el scatter debe construirse desde raw_numeric"


# --------------------------------------------------------------------------- #
# Edges
# --------------------------------------------------------------------------- #
def test_edge_sin_coordenadas_devuelve_none():
    prof = {
        "table": "ventas",
        "columns": [
            {"name": "precio", "inferred_type": "numeric",
             "numeric": {"min": 0, "max": 1000}},
            {"name": "categoria", "inferred_type": "text"},
        ],
    }
    assert build_geospatial(prof, {}) is None


def test_edge_none_y_vacio_no_rompen():
    assert build_geospatial(None, None) is None
    assert build_geospatial({}, {}) is None
    assert build_geospatial({"columns": []}, {}) is None
    assert build_geospatial("not a dict", {}) is None


def test_edge_nombre_lat_pero_rango_invalido_no_aplica():
    """A column named 'lat' whose values are out of [-90,90] is NOT a coordinate."""
    prof = {
        "table": "x",
        "columns": [
            {"name": "lat", "inferred_type": "numeric",
             "numeric": {"min": 1000, "max": 9999}},
            {"name": "lon", "inferred_type": "numeric",
             "numeric": {"min": 1000, "max": 9999}},
        ],
    }
    assert build_geospatial(prof, {}) is None


def test_edge_columnas_detectadas_sin_puntos_degrada():
    """Detected lat/lon but no raw arrays -> honest note + approx bbox, no crash."""
    prof = _profile_with_coords(lats=[40.0, 41.0], lons=[-3.0, -4.0])
    ch = build_geospatial(prof, {})  # no geo_points / raw_numeric
    assert ch is not None
    assert not _figures(ch), "sin puntos no debe dibujarse el scatter"
    notes = [b for b in ch.blocks if b.kind == "note"]
    assert notes and "coordenadas crudas" in notes[0].text


def test_edge_coordenadas_con_nan_se_filtran():
    lats = [40.4, float("nan"), 41.0, None, 39.8]
    lons = [-3.7, -3.6, float("nan"), -3.9, -4.0]
    ch = build_geospatial(_profile_with_coords(lats=[39.8, 41.0],
                                               lons=[-4.0, -3.6]),
                          _ctx_points(lats, lons))
    assert ch is not None  # must not raise on NaN/None


# --------------------------------------------------------------------------- #
# Anti-cut: long names + many points + several regions render without truncation
# --------------------------------------------------------------------------- #
def _multiregion_points(per: int = 700):
    """Points spread across Spain, France and the USA to fill the region table."""
    lats, lons = [], []
    for (la, lo) in ((40.4, -3.7), (48.85, 2.35), (39.0, -98.0)):
        gl, gn = _grid(la, lo, per, spread=2.0)
        lats += gl
        lons += gn
    return lats, lons


def test_anticut_pdf_y_pptx_no_truncan():
    lat_name = "latitud_geografica_del_punto_de_observacion_registrado"
    lon_name = "longitud_geografica_del_punto_de_observacion_registrado"
    lats, lons = _multiregion_points(700)
    prof = _profile_with_coords(lat_name, lon_name, lats, lons)
    ctx = {"geo_points": {"lats": lats, "lons": lons}}

    full = build_document(prof, ctx)
    assert any(c.id == "geospatial" for c in full)
    chapters = [c for c in full if c.id == "geospatial"]

    with tempfile.TemporaryDirectory() as d:
        pdf = os.path.join(d, "g.pdf")
        pptx = os.path.join(d, "g.pptx")
        rp = render_pdf(chapters, pdf, {"title": "EDA"})
        rx = render_pptx(chapters, pptx, {"title": "EDA"})
        assert os.path.exists(pdf) and os.path.exists(pptx)
        assert (rp or {}).get("n_pages", 0) >= 1

        # PDF: the long lat column name survives whole (wraps, not cut) and there
        # is no truncation marker in this chapter.
        pdf_txt = "".join((pg.extract_text() or "") for pg in PdfReader(pdf).pages)
        assert "…" not in pdf_txt and "..." not in pdf_txt
        norm = re.sub(r"\s+", "", pdf_txt)
        assert lat_name in norm, "el nombre largo de la columna se cortó en el PDF"

        # PPTX: long name present in some shape/cell, untruncated.
        allt = []
        for s in Presentation(pptx).slides:
            for sh in s.shapes:
                if sh.has_text_frame:
                    allt.append(sh.text_frame.text)
                if sh.has_table:
                    for row in sh.table.rows:
                        for c in row.cells:
                            allt.append(c.text)
        joined = re.sub(r"\s+", "", "\n".join(allt))
        assert lat_name in joined, "el nombre largo de la columna se cortó en el PPTX"
