"""Tests para jupyter_run_cells.

Cubre:
- Validacion de indices (rango y tipo de celda).
- Comportamiento stop_on_error (True/False).
- E2E contra un Jupyter Lab vivo si esta disponible (skip si no).
"""

from __future__ import annotations

import json
import os
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from unittest.mock import MagicMock, call, patch

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))

from python.functions.notebook import jupyter_exec as jx
from python.functions.notebook import jupyter_run_cells as jrc


# ---------------------------------------------------------------------------
# Helpers para mocks
# ---------------------------------------------------------------------------


def _make_nb(cells: list[dict]) -> dict:
    """Construye un dict de notebook minimo con las celdas dadas."""
    return {
        "nbformat": 4,
        "nbformat_minor": 5,
        "metadata": {},
        "cells": cells,
    }


def _code_cell(source: str) -> dict:
    return {
        "cell_type": "code",
        "source": source,
        "outputs": [],
        "execution_count": None,
        "metadata": {},
        "id": "test-id",
    }


def _markdown_cell(source: str) -> dict:
    return {
        "cell_type": "markdown",
        "source": source,
        "metadata": {},
        "id": "md-id",
    }


def _kernel_result(outputs: list[dict], execution_count: int = 1) -> dict:
    return {"outputs": outputs, "execution_count": execution_count, "status": "ok"}


def _stream_output(text: str) -> dict:
    return {"output_type": "stream", "name": "stdout", "text": text}


def _error_output(ename: str = "ValueError", traceback: list[str] | None = None) -> dict:
    return {
        "output_type": "error",
        "ename": ename,
        "evalue": "bad value",
        "traceback": traceback or [f"{ename}: bad value"],
    }


class _FakeKernel:
    """Simula KernelClient con una lista de resultados predefinidos."""

    def __init__(self, results: list[dict]):
        self._results = iter(results)

    def execute(self, source: str, **kwargs) -> dict:
        return next(self._results)

    def __enter__(self):
        return self

    def __exit__(self, *args):
        pass


# ---------------------------------------------------------------------------
# Tests unitarios
# ---------------------------------------------------------------------------


def _patch_infra(nb: dict, kernel: _FakeKernel):
    """Context managers que parchean _ensure_session, _get/put_notebook y KernelClient."""
    file_node = {"content": nb}
    return [
        patch.object(jrc, "_ensure_session", return_value="kernel-abc"),
        patch.object(jrc, "_get_notebook_content", return_value=file_node),
        patch.object(jrc, "_put_notebook_content"),
        patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=kernel),
    ]


def test_run_cells_single_cell_returns_output():
    nb = _make_nb([_code_cell("print(42)")])
    fake_kernel = _FakeKernel([_kernel_result([_stream_output("42\n")], execution_count=1)])

    with (
        patch.object(jrc, "_ensure_session", return_value="k1"),
        patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
        patch.object(jrc, "_put_notebook_content") as mock_put,
        patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
    ):
        result = jrc.jupyter_run_cells(
            "nb.ipynb", [0], server_url="http://localhost:8888", token=""
        )

    assert result["kernel_id"] == "k1"
    assert result["stopped_at"] is None
    assert len(result["executed"]) == 1
    entry = result["executed"][0]
    assert entry["cell_index"] == 0
    assert entry["execution_count"] == 1
    assert entry["outputs"] == ["42"]
    assert entry["error"] is None
    assert entry["duration_s"] >= 0
    mock_put.assert_called_once()


def test_run_cells_stops_on_error_by_default():
    nb = _make_nb([_code_cell("x=1"), _code_cell("bad"), _code_cell("ok")])
    fake_kernel = _FakeKernel([
        _kernel_result([_stream_output("1")], 1),
        _kernel_result([_error_output("RuntimeError", ["RuntimeError: bad"])], 2),
        # la celda 2 nunca deberia ejecutarse
    ])

    with (
        patch.object(jrc, "_ensure_session", return_value="k2"),
        patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
        patch.object(jrc, "_put_notebook_content"),
        patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
    ):
        result = jrc.jupyter_run_cells("nb.ipynb", [0, 1, 2], stop_on_error=True)

    assert result["stopped_at"] == 1
    assert len(result["executed"]) == 2
    assert result["executed"][1]["error"] is not None
    assert "RuntimeError" in result["executed"][1]["error"]


def test_run_cells_no_stop_on_error_continues():
    nb = _make_nb([_code_cell("bad"), _code_cell("print('after')")])
    fake_kernel = _FakeKernel([
        _kernel_result([_error_output()], 1),
        _kernel_result([_stream_output("after")], 2),
    ])

    with (
        patch.object(jrc, "_ensure_session", return_value="k3"),
        patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
        patch.object(jrc, "_put_notebook_content"),
        patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
    ):
        result = jrc.jupyter_run_cells("nb.ipynb", [0, 1], stop_on_error=False)

    assert result["stopped_at"] is None
    assert len(result["executed"]) == 2
    assert result["executed"][0]["error"] is not None
    assert result["executed"][1]["outputs"] == ["after"]


def test_run_cells_invalid_index_raises():
    nb = _make_nb([_code_cell("x=1")])

    with (
        patch.object(jrc, "_ensure_session", return_value="k4"),
        patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
        patch.object(jrc, "_put_notebook_content"),
        patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
    ):
        with pytest.raises(IndexError, match="fuera de rango"):
            jrc.jupyter_run_cells("nb.ipynb", [5])


def test_run_cells_non_code_cell_raises():
    nb = _make_nb([_markdown_cell("# titulo")])

    with (
        patch.object(jrc, "_ensure_session", return_value="k5"),
        patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
        patch.object(jrc, "_put_notebook_content"),
        patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
    ):
        with pytest.raises(ValueError, match="no es de codigo"):
            jrc.jupyter_run_cells("nb.ipynb", [0])


# ---------------------------------------------------------------------------
# E2E (requiere Jupyter Lab corriendo)
# ---------------------------------------------------------------------------


JUPYTER_VENV_BIN = Path(__file__).resolve().parents[4] / "analysis" / "pruebas_jupyter" / ".venv" / "bin"


def _free_port() -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def _wait_http(url: str, timeout: float = 10.0) -> bool:
    end = time.time() + timeout
    while time.time() < end:
        try:
            with urllib.request.urlopen(url, timeout=1):
                return True
        except OSError:
            time.sleep(0.3)
    return False


@pytest.fixture(scope="module")
def jupyter_server(tmp_path_factory):
    """Arranca un Jupyter Lab en puerto libre. Skip si las deps no estan."""
    if not (JUPYTER_VENV_BIN / "jupyter-lab").exists():
        pytest.skip("Jupyter Lab no disponible en pruebas_jupyter venv")

    workdir = tmp_path_factory.mktemp("jupyter_run_cells_e2e")
    (workdir / "notebooks").mkdir()
    port = _free_port()

    proc = subprocess.Popen(
        [
            str(JUPYTER_VENV_BIN / "jupyter-lab"),
            f"--port={port}",
            "--no-browser",
            "--ServerApp.token=",
            "--ServerApp.password=",
            "--ServerApp.disable_check_xsrf=True",
            f"--ServerApp.root_dir={workdir}",
            "--collaborative",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )

    server_url = f"http://localhost:{port}"
    if not _wait_http(f"{server_url}/api"):
        proc.terminate()
        pytest.skip("Jupyter Lab no levanto a tiempo")

    yield server_url, workdir

    proc.terminate()
    try:
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        proc.kill()


def test_e2e_run_cells_batch(jupyter_server):
    """Ejecuta 3 celdas en lote y verifica outputs y persistence."""
    server_url, workdir = jupyter_server

    # Prepara el notebook con 3 celdas via jupyter_exec
    jx.jupyter_append_execute("notebooks/batch.ipynb", "x = 10", server_url=server_url)
    jx.jupyter_append_execute("notebooks/batch.ipynb", "y = x * 3", server_url=server_url)
    jx.jupyter_append_execute("notebooks/batch.ipynb", "print(x + y)", server_url=server_url)

    # Ejecuta el lote
    result = jrc.jupyter_run_cells(
        "notebooks/batch.ipynb",
        [0, 1, 2],
        server_url=server_url,
    )

    assert result["stopped_at"] is None
    assert len(result["executed"]) == 3
    assert result["executed"][2]["outputs"] == ["40"]
    assert result["total_duration_s"] > 0
    assert result["kernel_id"] != ""

    # Verifica persistencia en disco
    nb = json.loads((workdir / "notebooks" / "batch.ipynb").read_text())
    assert nb["cells"][2]["execution_count"] is not None


def test_e2e_run_cells_stop_on_error(jupyter_server):
    """Verifica que stop_on_error detiene el lote en la celda con error."""
    server_url, workdir = jupyter_server

    jx.jupyter_append_execute("notebooks/stopper.ipynb", "a = 1", server_url=server_url)
    jx.jupyter_append_execute("notebooks/stopper.ipynb", "raise ValueError('boom')", server_url=server_url)
    jx.jupyter_append_execute("notebooks/stopper.ipynb", "print('no llego')", server_url=server_url)

    result = jrc.jupyter_run_cells(
        "notebooks/stopper.ipynb",
        [0, 1, 2],
        server_url=server_url,
        stop_on_error=True,
    )

    assert result["stopped_at"] == 1
    assert len(result["executed"]) == 2
    assert result["executed"][1]["error"] is not None
    assert "ValueError" in result["executed"][1]["error"]
    # La celda 2 no debe aparecer en executed
    assert all(e["cell_index"] != 2 for e in result["executed"])
