"""Ejecuta todas las celdas de codigo de un notebook en orden (Run All).

Equivalente al boton "Run All" del UI de Jupyter Lab, pero invocable desde
CLI/MCP/agente. Opcionalmente reinicia el kernel antes de empezar para
garantizar un estado limpio.

Depende de jupyter_run_cells cuando esta disponible; si no, ejecuta la logica
de batch internamente reutilizando los helpers de jupyter_exec.
"""

from __future__ import annotations

import json
import time
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from jupyter_kernel_client import KernelClient


# ---------------------------------------------------------------------------
# Helpers REST (minimos, alineados con jupyter_exec)
# ---------------------------------------------------------------------------


def _auth_headers(token: str, content_type: bool = False) -> dict[str, str]:
    headers = {"Accept": "application/json"}
    if content_type:
        headers["Content-Type"] = "application/json"
    if token:
        headers["Authorization"] = f"token {token}"
    return headers


def _api_get(url: str, token: str = "") -> dict | list | None:
    try:
        req = Request(url, headers=_auth_headers(token))
        with urlopen(req, timeout=10) as resp:
            return json.loads(resp.read())
    except (URLError, OSError, json.JSONDecodeError):
        return None


def _api_post(url: str, token: str = "", body: dict | None = None) -> dict | None:
    data = json.dumps(body or {}).encode("utf-8")
    req = Request(url, data=data, headers=_auth_headers(token, content_type=True), method="POST")
    try:
        with urlopen(req, timeout=30) as resp:
            raw = resp.read()
            return json.loads(raw) if raw else {}
    except (URLError, OSError, json.JSONDecodeError):
        return None


def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str | None:
    """Busca el kernel_id activo para el notebook via /api/sessions."""
    sessions = _api_get(f"{server_url}/api/sessions", token) or []
    for session in sessions:
        nb = session.get("notebook", session.get("path", {}))
        nb_path = nb.get("path", nb) if isinstance(nb, dict) else str(nb)
        if nb_path == notebook_path:
            kernel = session.get("kernel", {})
            return kernel.get("id")
    return None


def _get_notebook_content(notebook_path: str, server_url: str, token: str) -> dict:
    """Lee el notebook completo via /api/contents."""
    url = f"{server_url}/api/contents/{notebook_path}?content=1&type=notebook"
    req = Request(url, headers=_auth_headers(token))
    with urlopen(req, timeout=15) as resp:
        return json.loads(resp.read())


def _put_notebook_content(notebook_path: str, server_url: str, token: str, content: dict) -> None:
    """Persiste el notebook via PUT /api/contents."""
    body = json.dumps({"type": "notebook", "format": "json", "content": content}).encode("utf-8")
    url = f"{server_url}/api/contents/{notebook_path}"
    req = Request(url, data=body, headers=_auth_headers(token, content_type=True), method="PUT")
    with urlopen(req, timeout=15) as resp:
        resp.read()


def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
    """Convierte outputs nbformat a strings legibles."""
    result: list[str] = []
    for output in raw_outputs:
        output_type = output.get("output_type", "")
        if output_type == "stream":
            text = output.get("text", "")
            if isinstance(text, list):
                text = "".join(text)
            result.append(text.rstrip("\n"))
        elif output_type in ("display_data", "execute_result"):
            data = output.get("data", {})
            text = data.get("text/plain", "")
            if isinstance(text, list):
                text = "".join(text)
            result.append(text.rstrip("\n"))
        elif output_type == "error":
            traceback = output.get("traceback", [])
            result.append("\n".join(traceback))
    return result


def _kernel_outputs_to_nbformat(outputs: list[dict]) -> list[dict]:
    return [dict(o) for o in outputs]


def _restart_kernel_and_wait(server_url: str, token: str, kernel_id: str, poll_timeout_s: int = 30) -> None:
    """Reinicia el kernel y espera hasta que vuelva a estado idle."""
    url = f"{server_url}/api/kernels/{kernel_id}/restart"
    _api_post(url, token)

    deadline = time.monotonic() + poll_timeout_s
    while time.monotonic() < deadline:
        kernels = _api_get(f"{server_url}/api/kernels", token) or []
        for k in kernels:
            if k.get("id") == kernel_id:
                state = k.get("execution_state", "")
                if state == "idle":
                    return
                break
        time.sleep(0.5)


# ---------------------------------------------------------------------------
# API publica
# ---------------------------------------------------------------------------


def jupyter_run_all(
    notebook_path: str,
    server_url: str = "http://localhost:8888",
    token: str = "",
    restart_kernel: bool = True,
    stop_on_error: bool = True,
    timeout_per_cell_s: int = 600,
) -> dict[str, Any]:
    """Ejecuta todas las celdas de codigo del notebook en orden.

    Equivalente al boton "Run All" del UI de Jupyter Lab. Si restart_kernel
    es True, reinicia el kernel del notebook ANTES de empezar para garantizar
    un estado completamente limpio (sin variables residuales de ejecuciones
    anteriores).

    Args:
        notebook_path: Ruta relativa al notebook desde la raiz del servidor
                       (ej: "notebooks/analisis.ipynb").
        server_url: URL base del servidor Jupyter (default http://localhost:8888).
        token: Token de autenticacion. Vacio si el servidor no requiere auth.
        restart_kernel: Si True, reinicia el kernel antes de empezar.
                        Garantiza estado limpio. Default True.
        stop_on_error: Si True, detiene la ejecucion cuando una celda produce
                       un error (output_type == "error"). Default True.
        timeout_per_cell_s: Timeout en segundos por celda. Default 600 (10 min).

    Returns:
        {
          "notebook": str,            # ruta del notebook
          "code_cell_indices": [int], # indices de celdas de codigo ejecutadas
          "executed": [               # resultado de cada celda ejecutada
            {
              "cell_index": int,
              "execution_count": int | None,
              "outputs": [str | dict],
              "error": str | None,    # mensaje del error si hubo, sino None
              "duration_s": float,
            }
          ],
          "stopped_at": int | None,  # cell_index donde se detuvo por error
          "kernel_id": str,
          "total_duration_s": float,
        }

    Raises:
        RuntimeError: Si no existe sesion activa para el notebook (el kernel
                      no esta corriendo). Usar jupyter_exec para crear sesion.
        HTTPError: Si el servidor Jupyter devuelve un error HTTP.
        URLError: Si no se puede conectar al servidor.
    """
    t0 = time.monotonic()

    # 1. Obtener kernel_id del notebook
    kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
    if not kernel_id:
        raise RuntimeError(
            f"No hay sesion activa para '{notebook_path}'. "
            "Abre el notebook en Jupyter Lab o usa jupyter_exec para crear sesion."
        )

    # 2. Reiniciar kernel si se solicita
    if restart_kernel:
        _restart_kernel_and_wait(server_url, token, kernel_id)

    # 3. Leer notebook y filtrar indices de celdas de codigo
    file_node = _get_notebook_content(notebook_path, server_url, token)
    nb = file_node["content"]
    cells = nb.get("cells", [])
    code_cell_indices = [
        i for i, cell in enumerate(cells)
        if cell.get("cell_type") == "code"
    ]

    # 4. Ejecutar cada celda en orden
    executed: list[dict] = []
    stopped_at: int | None = None

    with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
        for cell_index in code_cell_indices:
            cell = cells[cell_index]
            source = cell.get("source", "")
            if isinstance(source, list):
                source = "".join(source)

            # Saltar celdas vacias
            if not source.strip():
                executed.append({
                    "cell_index": cell_index,
                    "execution_count": None,
                    "outputs": [],
                    "error": None,
                    "duration_s": 0.0,
                })
                continue

            cell_t0 = time.monotonic()
            result = kernel.execute(source)
            cell_duration = time.monotonic() - cell_t0

            raw_outputs = result.get("outputs", [])
            cell["outputs"] = _kernel_outputs_to_nbformat(raw_outputs)
            cell["execution_count"] = result.get("execution_count")

            # Detectar error en outputs
            error_msg: str | None = None
            for out in raw_outputs:
                if out.get("output_type") == "error":
                    ename = out.get("ename", "Error")
                    evalue = out.get("evalue", "")
                    error_msg = f"{ename}: {evalue}"
                    break

            executed.append({
                "cell_index": cell_index,
                "execution_count": result.get("execution_count"),
                "outputs": _extract_outputs(raw_outputs),
                "error": error_msg,
                "duration_s": round(cell_duration, 3),
            })

            if error_msg and stop_on_error:
                stopped_at = cell_index
                break

    # 5. Persistir notebook con outputs actualizados
    _put_notebook_content(notebook_path, server_url, token, nb)

    total_duration = time.monotonic() - t0
    return {
        "notebook": notebook_path,
        "code_cell_indices": code_cell_indices,
        "executed": executed,
        "stopped_at": stopped_at,
        "kernel_id": kernel_id,
        "total_duration_s": round(total_duration, 3),
    }


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import argparse
    import sys

    parser = argparse.ArgumentParser(
        description="Ejecuta todas las celdas de codigo de un notebook en orden (Run All)"
    )
    parser.add_argument("notebook", help="Ruta del notebook relativa al servidor Jupyter")
    parser.add_argument("--server", default="http://localhost:8888", help="URL del servidor Jupyter")
    parser.add_argument("--token", default="", help="Token de autenticacion")
    parser.add_argument(
        "--no-restart",
        action="store_true",
        help="No reiniciar el kernel antes de ejecutar",
    )
    parser.add_argument(
        "--continue-on-error",
        action="store_true",
        help="Continuar ejecucion aunque una celda produzca error",
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=600,
        help="Timeout en segundos por celda (default: 600)",
    )
    args = parser.parse_args()

    try:
        result = jupyter_run_all(
            notebook_path=args.notebook,
            server_url=args.server,
            token=args.token,
            restart_kernel=not args.no_restart,
            stop_on_error=not args.continue_on_error,
            timeout_per_cell_s=args.timeout,
        )
        print(json.dumps(result, ensure_ascii=False, indent=2))
    except Exception as exc:
        print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
