"""Cover chapter (PORTADA) — the reference chapter for AutomaticEDA.

Builds the document cover from a TableProfile plus an optional ``ctx`` of
presentation metadata. Reads everything defensively (``.get``) and degrades
honestly.

The dataset size (N rows x M columns) is always shown big, as a heading right
under the dataset name (kept together in a ``Group``), not buried in the
metadata table. The Description and Granularity are resolved through a cascade
so they are never empty: an explicit ``ctx`` value wins; otherwise the LLM block
(``profile['llm']`` from ``eda_llm_insights``) provides ``summary`` /
``row_meaning``; otherwise a short summary is derived from the profile itself
(shape, column-type mix, quality score) and a "Cada fila es…" sentence from the
key-candidate columns or the table shape. Nothing is invented: the derived
fallbacks state that they come from the profile.

Contract for chapter authors (see ``docs/capabilities/automatic_eda.md``):
    build_<id>(profile: dict, ctx: dict) -> Chapter | None
    CHAPTER_VERSION = "x.y.z"
"""

from __future__ import annotations

import os
from datetime import datetime, timezone

from .. import model

CHAPTER_VERSION = "1.4.0"
CHAPTER_ID = "portada"
CHAPTER_TITLE = "Portada"

# Key under which eda_llm_insights stores its interpretive block in the profile.
# The cover reads ``summary`` (what the table is) and ``row_meaning`` (what one
# row represents) from it when the LLM layer ran (``run_llm``).
_LLM_KEY = "llm"

# Font size (pt) for the dataset name on the PPTX cover slide — notably larger
# than the default H1 so the dataset name stands out (shown underlined too).
_PPTX_TITLE_PT = 44.0


def _storage_from_source(source: str) -> str:
    """Infer the storage technology the dataset currently lives in.

    Heuristic on the profile ``source`` string (a path, DSN or backend name).
    Returns a human label; falls back to the raw source when unknown.
    """
    s = (source or "").strip().lower()
    if not s:
        return "—"
    if s.endswith(".csv") or s.endswith(".tsv"):
        return "CSV"
    if s.endswith(".parquet") or s.endswith(".pq"):
        return "Parquet"
    if s.endswith(".json") or s.endswith(".ndjson"):
        return "JSON"
    if s.endswith(".xlsx") or s.endswith(".xls"):
        return "Excel"
    if s.endswith((".duckdb", ".ddb")) or s == "duckdb" or s.endswith(".db"):
        return "DuckDB"
    if s.startswith(("postgres://", "postgresql://")) or "postgres" in s:
        return "PostgreSQL"
    if s.startswith("bigquery") or "bigquery" in s or s.count(".") == 2 and " " not in s:
        return "BigQuery"
    if "sqlite" in s:
        return "SQLite"
    # Unknown: show the raw source so nothing is hidden.
    return source


def _fmt_int(v) -> str:
    if v is None:
        return "—"
    try:
        return f"{int(v):,}".replace(",", ".")
    except (TypeError, ValueError):
        return str(v)


def _fmt_pct(value) -> str:
    """Format a percentage that may arrive as a 0–1 fraction or a 0–100 number."""
    if value is None:
        return "—"
    try:
        v = float(value)
    except (TypeError, ValueError):
        return str(value)
    if 0 < v <= 1.0:
        v *= 100.0
    return f"{v:.1f}%"


def _summary_blocks(summary) -> list:
    """Mini-summary of the rest of the analysis, shown on the cover (mejora 5).

    The cover is built AFTER the body (``build_document`` passes the aggregated
    ``ctx['document_summary']``), so it can reflect what the analysis found:
    shape, column types, quality flags and which chapters were included. Returns
    an empty list when there is no summary (the cover degrades to its metadata
    table only)."""
    if not isinstance(summary, dict) or not summary:
        return []
    rows = []
    n_num = summary.get("n_numeric")
    n_cat = summary.get("n_categorical")
    if n_num is not None or n_cat is not None:
        rows.append(("Columnas numéricas / categóricas",
                     f"{_fmt_int(n_num)} / {_fmt_int(n_cat)}"))
    if summary.get("duplicate_pct") is not None:
        rows.append(("Filas duplicadas", _fmt_pct(summary.get("duplicate_pct"))))
    if summary.get("null_cell_pct") is not None:
        rows.append(("Celdas nulas", _fmt_pct(summary.get("null_cell_pct"))))
    titles = summary.get("chapter_titles") or []
    if titles:
        rows.append(("Capítulos del informe", _fmt_int(len(titles))))

    blocks = [model.Heading(text="Resumen del análisis", level=2)]
    if rows:
        # Values pinned to the right margin (numbers flush right, label left).
        blocks.append(model.KVTable(rows=rows, value_align="right"))
    if titles:
        # Clickable index ("Índice"): one TocEntry per chapter title. Each entry
        # becomes a real jump to that chapter's first page/slide once the document
        # is laid out (the renderers register every chapter start and wire the
        # links; ``target_id`` is matched against the chapter title). The cover only
        # knows chapter titles, so the title doubles as the link target.
        blocks.append(model.Heading(text="Índice", level=2))
        for t in titles:
            label = model._safe_str(t)
            if not label:
                continue
            blocks.append(model.TocEntry(label=label, target_id=label))
    return blocks


def _fmt_date_eu(value) -> str:
    """Format a date/ISO string as European DD/MM/AAAA HH:mm (UI convention).

    Accepts a datetime, an ISO-8601 string (with or without microseconds/tz) or
    any other string. Non-parseable strings are returned verbatim so nothing is
    lost; None yields a placeholder.
    """
    if value is None:
        return "—"
    if isinstance(value, datetime):
        return value.strftime("%d/%m/%Y %H:%M")
    s = str(value).strip()
    if not s:
        return "—"
    try:
        dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
        return dt.strftime("%d/%m/%Y %H:%M")
    except (TypeError, ValueError):
        # Try a couple of common forms before giving up.
        for fmt in ("%Y-%m-%d %H:%M:%S UTC", "%Y-%m-%d %H:%M UTC",
                    "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
            try:
                return datetime.strptime(s, fmt).strftime("%d/%m/%Y %H:%M")
            except ValueError:
                continue
        return s


def _llm_block(profile: dict, ctx: dict) -> dict:
    """Return the interpretive LLM block (``eda_llm_insights`` output), or {}.

    It is stored under ``profile['llm']`` by ``profile_table(run_llm=True)`` and
    may also be forwarded in ``ctx['llm']``. Read defensively: anything that is
    not a dict degrades to an empty dict so the cover never raises.
    """
    block = profile.get(_LLM_KEY)
    if not isinstance(block, dict):
        block = ctx.get(_LLM_KEY)
    return block if isinstance(block, dict) else {}


def _count_column_types(profile: dict, ctx: dict):
    """Best-effort (n_numeric, n_categorical) for the dataset.

    Prefers the aggregated ``ctx['document_summary']`` (computed by the engine
    over the whole body); falls back to counting the profile columns directly so
    the cover still has the numbers when no summary was passed.
    """
    summary = ctx.get("document_summary")
    if isinstance(summary, dict):
        n_num = summary.get("n_numeric")
        n_cat = summary.get("n_categorical")
        if n_num is not None or n_cat is not None:
            return n_num, n_cat
    cols = profile.get("columns") or []
    n_num = sum(1 for c in cols if isinstance(c, dict)
                and c.get("inferred_type") == "numeric")
    n_cat = sum(1 for c in cols if isinstance(c, dict)
                and isinstance(c.get("categorical"), dict)
                and c.get("categorical", {}).get("top")
                and c.get("inferred_type") != "numeric")
    return n_num, n_cat


def _derive_description(profile: dict, ctx: dict) -> str:
    """A short, honest description of the dataset from the profile.

    Used only when no explicit ``ctx['description']`` and no LLM ``summary`` are
    available. Summarizes shape, column-type mix and quality score; never empty,
    never invents business meaning (it states the description was derived)."""
    n_rows = profile.get("n_rows")
    n_cols = profile.get("n_cols")
    n_num, n_cat = _count_column_types(profile, ctx)
    head = f"Conjunto de datos con {_fmt_int(n_rows)} filas y {_fmt_int(n_cols)} columnas"
    type_bits = []
    if n_num:
        type_bits.append(f"{_fmt_int(n_num)} numéricas")
    if n_cat:
        type_bits.append(f"{_fmt_int(n_cat)} categóricas")
    if type_bits:
        head += " (" + ", ".join(type_bits) + ")"
    parts = [head + "."]
    score = profile.get("quality_score")
    if score is not None:
        parts.append(f"Calidad media estimada: {score}/100.")
    parts.append("Resumen derivado del perfil.")
    return " ".join(parts)


def _derive_granularity(profile: dict, dataset_name: str) -> str:
    """A ``Cada fila es…`` granularity sentence from the profile.

    Prefers the key-candidate columns (a row is identified by them); when no key
    is detected, falls back to the table shape so the line is always meaningful
    and starts with ``Cada fila es`` as the user requested."""
    keys = profile.get("key_candidates") or []
    if keys:
        shown = ", ".join(str(k) for k in keys[:3])
        more = "" if len(keys) <= 3 else f" (y {len(keys) - 3} más)"
        return (f"Cada fila es un registro identificado por {shown}{more}, "
                "candidata(s) a clave por ser únicas y sin nulos.")
    n_rows = profile.get("n_rows")
    tail = f" El dataset tiene {_fmt_int(n_rows)} filas en total." if n_rows else ""
    return (f"Cada fila es un registro de «{dataset_name}». No se detectó una "
            "columna identificadora única, así que la granularidad se infiere "
            "de la forma de la tabla." + tail)


def build_portada(profile: dict, ctx: dict):
    """Build the cover Chapter, or None if there is truly nothing to show."""
    profile = profile or {}
    ctx = ctx or {}

    dataset_name = (ctx.get("dataset_name") or profile.get("table")
                    or "(dataset sin nombre)")
    source = profile.get("source") or ""
    # Where the dataset comes from (origin), distinct from where it is stored.
    source_origin = ctx.get("source_origin") or source or "—"
    storage = ctx.get("storage") or _storage_from_source(source)

    when = _fmt_date_eu(
        ctx.get("generated_at") or profile.get("profiled_at")
        or datetime.now(timezone.utc))

    n_rows = profile.get("n_rows")
    n_cols = profile.get("n_cols")
    shape = f"{_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas"

    score = profile.get("quality_score")
    quality_value = "—" if score is None else f"{score} / 100"

    llm = _llm_block(profile, ctx)

    # Granularity: explicit ctx wins; then the LLM "row_meaning"; then the key
    # candidates; finally a shape-based fallback. Always a real "Cada fila es…".
    granularity = ctx.get("granularity")
    if not granularity:
        granularity = (llm.get("row_meaning") or "").strip() or None
    if not granularity:
        granularity = _derive_granularity(profile, str(dataset_name))

    # Description: explicit ctx wins; then the LLM "summary"; finally a short
    # profile-derived summary. Never the old empty placeholder.
    description = ctx.get("description")
    if not description:
        description = (llm.get("summary") or "").strip() or None
    if not description:
        description = _derive_description(profile, ctx)

    # Title + dataset size shown together and BIG (Heading) at the top, kept on
    # the same page (Group). The size is no longer buried in the metadata table.
    # The dataset name is shown big and underlined on the PPTX cover slide
    # (size_pt/underline are honoured by the PPTX renderer; the PDF ignores them).
    cover = [
        model.Heading(text=str(dataset_name), level=1, underline=True,
                      size_pt=_PPTX_TITLE_PT),
        model.Markdown(text="**Automatic-EDA** · informe exploratorio automático"),
        model.Heading(text=shape, level=2),
    ]

    blocks = [
        model.Group(blocks=cover),
        model.KVTable(rows=[
            ("Fuente", source_origin),
            ("Almacenamiento", storage),
            ("Generado", when),
            ("Calidad", quality_value),
        ]),
        model.Heading(text="Descripción", level=2),
        model.Markdown(text=str(description)),
        model.Heading(text="Granularidad", level=2),
        model.Markdown(text=str(granularity)),
    ]

    # Mini-summary of the rest of the analysis (built last, shown on the cover).
    blocks.extend(_summary_blocks(ctx.get("document_summary")))

    return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
                         version=CHAPTER_VERSION, blocks=blocks)
