"""Tests for notebook_handler module."""

import json
import pytest
from pathlib import Path

from skop_runner.notebook_handler import (
    read_notebook,
    write_notebook,
    validate_notebook,
    create_empty_notebook,
    update_cell,
    add_cell,
    delete_cell,
)


class TestReadNotebook:
    def test_reads_valid_v4_notebook(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        nb_path.write_text(json.dumps({
            "nbformat": 4,
            "nbformat_minor": 5,
            "metadata": {},
            "cells": [
                {
                    "id": "abc",
                    "cell_type": "code",
                    "source": "x = 1",
                    "execution_count": None,
                    "outputs": [],
                    "metadata": {},
                }
            ]
        }))

        result = read_notebook(nb_path)

        assert result["nbformat"] == 4
        assert len(result["cells"]) == 1
        assert result["cells"][0]["source"] == "x = 1"

    def test_upgrades_v3_notebook(self, tmp_path: Path):
        # nbformat v3 structure
        nb_path = tmp_path / "old.ipynb"
        nb_path.write_text(json.dumps({
            "nbformat": 3,
            "nbformat_minor": 0,
            "metadata": {},
            "worksheets": [{
                "cells": [
                    {
                        "cell_type": "code",
                        "input": "print(1)",
                        "language": "python",
                        "outputs": [],
                    }
                ]
            }]
        }))

        result = read_notebook(nb_path)

        # Should be upgraded to v4
        assert result["nbformat"] == 4
        assert "cells" in result
        assert "worksheets" not in result

    def test_assigns_missing_cell_ids(self, tmp_path: Path):
        nb_path = tmp_path / "no_ids.ipynb"
        nb_path.write_text(json.dumps({
            "nbformat": 4,
            "nbformat_minor": 4,  # Before cell IDs were required
            "metadata": {},
            "cells": [
                {
                    "cell_type": "code",
                    "source": "",
                    "execution_count": None,
                    "outputs": [],
                    "metadata": {},
                }
            ]
        }))

        result = read_notebook(nb_path)

        assert result["cells"][0].get("id") is not None
        assert len(result["cells"][0]["id"]) > 0

    def test_raises_on_file_not_found(self, tmp_path: Path):
        nb_path = tmp_path / "nonexistent.ipynb"

        with pytest.raises(FileNotFoundError):
            read_notebook(nb_path)

    def test_raises_on_invalid_json(self, tmp_path: Path):
        nb_path = tmp_path / "invalid.ipynb"
        nb_path.write_text("not json")

        with pytest.raises(Exception):
            read_notebook(nb_path)


class TestWriteNotebook:
    def test_writes_valid_notebook(self, tmp_path: Path):
        nb_path = tmp_path / "out.ipynb"
        notebook = create_empty_notebook()
        notebook["cells"] = [
            {
                "id": "1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            }
        ]

        write_notebook(nb_path, notebook)

        # Read back and verify
        content = json.loads(nb_path.read_text())
        assert content["nbformat"] == 4
        assert len(content["cells"]) == 1

    def test_creates_parent_directories(self, tmp_path: Path):
        nb_path = tmp_path / "subdir" / "nested" / "out.ipynb"
        notebook = create_empty_notebook()

        write_notebook(nb_path, notebook)

        assert nb_path.exists()

    def test_rejects_invalid_notebook(self, tmp_path: Path):
        nb_path = tmp_path / "bad.ipynb"
        invalid = {"not": "a notebook"}

        with pytest.raises(Exception):
            write_notebook(nb_path, invalid)


class TestValidateNotebook:
    def test_valid_notebook_returns_empty_errors(self):
        notebook = create_empty_notebook()
        errors = validate_notebook(notebook)
        assert errors == []

    def test_notebook_with_cells_is_valid(self):
        notebook = create_empty_notebook()
        notebook["cells"] = [
            {
                "id": "test-cell",
                "cell_type": "code",
                "source": "print('hello')",
                "execution_count": 1,
                "outputs": [],
                "metadata": {},
            }
        ]
        errors = validate_notebook(notebook)
        assert errors == []

    def test_invalid_notebook_returns_errors(self):
        errors = validate_notebook({"bad": "notebook"})
        assert len(errors) > 0


class TestCreateEmptyNotebook:
    def test_creates_valid_structure(self):
        notebook = create_empty_notebook()

        assert notebook["nbformat"] == 4
        assert notebook["cells"] == []
        assert notebook["metadata"]["kernelspec"]["name"] == "python3"

    def test_has_python_kernel_metadata(self):
        notebook = create_empty_notebook()

        assert notebook["metadata"]["kernelspec"]["display_name"] == "Python 3"
        assert notebook["metadata"]["kernelspec"]["language"] == "python"

    def test_has_language_info(self):
        notebook = create_empty_notebook()

        assert notebook["metadata"]["language_info"]["name"] == "python"
        assert notebook["metadata"]["language_info"]["file_extension"] == ".py"

    def test_is_immediately_valid(self):
        notebook = create_empty_notebook()
        errors = validate_notebook(notebook)
        assert errors == []


class TestRoundTrip:
    def test_write_and_read_preserves_content(self, tmp_path: Path):
        nb_path = tmp_path / "roundtrip.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 42\nprint(x)",
                "execution_count": 1,
                "outputs": [
                    {
                        "output_type": "stream",
                        "name": "stdout",
                        "text": "42\n",
                    }
                ],
                "metadata": {},
            },
            {
                "id": "cell-2",
                "cell_type": "markdown",
                "source": "# Hello World",
                "metadata": {},
            },
        ]

        write_notebook(nb_path, original)
        loaded = read_notebook(nb_path)

        assert loaded["cells"][0]["source"] == "x = 42\nprint(x)"
        assert loaded["cells"][0]["execution_count"] == 1
        assert loaded["cells"][1]["source"] == "# Hello World"

    def test_preserves_unicode(self, tmp_path: Path):
        nb_path = tmp_path / "unicode.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "1",
                "cell_type": "code",
                "source": "print('Hello 世界 🎉')",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            }
        ]

        write_notebook(nb_path, original)
        loaded = read_notebook(nb_path)

        assert loaded["cells"][0]["source"] == "print('Hello 世界 🎉')"


@pytest.mark.asyncio
class TestUpdateCell:
    async def test_updates_cell_source(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            }
        ]
        write_notebook(nb_path, original)

        await update_cell(nb_path, "cell-1", "x = 42")

        # Verify it was written to disk
        loaded = read_notebook(nb_path)
        assert loaded["cells"][0]["source"] == "x = 42"

    async def test_preserves_other_cells(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            },
            {
                "id": "cell-2",
                "cell_type": "code",
                "source": "y = 2",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            },
        ]
        write_notebook(nb_path, original)

        await update_cell(nb_path, "cell-1", "x = 100")

        loaded = read_notebook(nb_path)
        assert loaded["cells"][0]["source"] == "x = 100"
        assert loaded["cells"][1]["source"] == "y = 2"

    async def test_raises_on_cell_not_found(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            }
        ]
        write_notebook(nb_path, original)

        with pytest.raises(ValueError, match="Cell not found"):
            await update_cell(nb_path, "nonexistent", "new code")

    async def test_raises_on_file_not_found(self, tmp_path: Path):
        nb_path = tmp_path / "nonexistent.ipynb"

        with pytest.raises(FileNotFoundError):
            await update_cell(nb_path, "cell-1", "new code")

    async def test_preserves_cell_metadata(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": 5,
                "outputs": [{"output_type": "stream", "name": "stdout", "text": "1\n"}],
                "metadata": {"custom": "value"},
            }
        ]
        write_notebook(nb_path, original)

        await update_cell(nb_path, "cell-1", "x = 42")

        # Source changed, but metadata/outputs/execution_count preserved
        loaded = read_notebook(nb_path)
        assert loaded["cells"][0]["source"] == "x = 42"
        assert loaded["cells"][0]["execution_count"] == 5
        assert loaded["cells"][0]["metadata"]["custom"] == "value"
        assert len(loaded["cells"][0]["outputs"]) == 1

    async def test_concurrent_updates_are_serialized(self, tmp_path: Path):
        """Verify that concurrent update_cell calls are properly serialized."""
        import asyncio

        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 0",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            }
        ]
        write_notebook(nb_path, original)

        # Run concurrent updates - with locking, they should be serialized
        # and the final value should be the last one to complete
        async def update_to(value: str):
            await update_cell(nb_path, "cell-1", f"x = {value}")

        await asyncio.gather(
            update_to("1"),
            update_to("2"),
            update_to("3"),
        )

        # All updates should have completed without data loss
        # (exact final value depends on timing, but file should be valid)
        loaded = read_notebook(nb_path)
        assert loaded["cells"][0]["source"] in ["x = 1", "x = 2", "x = 3"]


@pytest.mark.asyncio
class TestAddCell:
    async def test_adds_code_cell_to_end(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            }
        ]
        write_notebook(nb_path, original)

        cell_id = await add_cell(nb_path, "code", "y = 2")

        loaded = read_notebook(nb_path)
        assert len(loaded["cells"]) == 2
        assert loaded["cells"][1]["id"] == cell_id
        assert loaded["cells"][1]["source"] == "y = 2"
        assert loaded["cells"][1]["cell_type"] == "code"

    async def test_adds_markdown_cell(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        write_notebook(nb_path, original)

        cell_id = await add_cell(nb_path, "markdown", "# Title")

        loaded = read_notebook(nb_path)
        assert len(loaded["cells"]) == 1
        assert loaded["cells"][0]["id"] == cell_id
        assert loaded["cells"][0]["source"] == "# Title"
        assert loaded["cells"][0]["cell_type"] == "markdown"

    async def test_inserts_after_specific_cell(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            },
            {
                "id": "cell-3",
                "cell_type": "code",
                "source": "z = 3",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            },
        ]
        write_notebook(nb_path, original)

        cell_id = await add_cell(nb_path, "code", "y = 2", after_cell_id="cell-1")

        loaded = read_notebook(nb_path)
        assert len(loaded["cells"]) == 3
        assert loaded["cells"][0]["id"] == "cell-1"
        assert loaded["cells"][1]["id"] == cell_id
        assert loaded["cells"][1]["source"] == "y = 2"
        assert loaded["cells"][2]["id"] == "cell-3"

    async def test_raises_on_invalid_cell_type(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        write_notebook(nb_path, original)

        with pytest.raises(ValueError, match="Invalid cell_type"):
            await add_cell(nb_path, "invalid", "source")

    async def test_raises_on_after_cell_not_found(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        write_notebook(nb_path, original)

        with pytest.raises(ValueError, match="Cell not found"):
            await add_cell(nb_path, "code", "source", after_cell_id="nonexistent")

    async def test_raises_on_file_not_found(self, tmp_path: Path):
        nb_path = tmp_path / "nonexistent.ipynb"

        with pytest.raises(FileNotFoundError):
            await add_cell(nb_path, "code", "source")

    async def test_returns_valid_uuid(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        write_notebook(nb_path, original)

        cell_id = await add_cell(nb_path, "code", "x = 1")

        # UUID4 format: 8-4-4-4-12 hex chars
        assert len(cell_id) == 36
        assert cell_id.count("-") == 4


@pytest.mark.asyncio
class TestDeleteCell:
    async def test_deletes_cell(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            },
            {
                "id": "cell-2",
                "cell_type": "code",
                "source": "y = 2",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            },
        ]
        write_notebook(nb_path, original)

        await delete_cell(nb_path, "cell-1")

        loaded = read_notebook(nb_path)
        assert len(loaded["cells"]) == 1
        assert loaded["cells"][0]["id"] == "cell-2"

    async def test_deletes_last_cell(self, tmp_path: Path):
        """Deleting the last remaining cell should succeed (empty notebook)."""
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            }
        ]
        write_notebook(nb_path, original)

        await delete_cell(nb_path, "cell-1")

        result = read_notebook(nb_path)
        assert len(result["cells"]) == 0

    async def test_raises_on_cell_not_found(self, tmp_path: Path):
        nb_path = tmp_path / "test.ipynb"
        original = create_empty_notebook()
        original["cells"] = [
            {
                "id": "cell-1",
                "cell_type": "code",
                "source": "x = 1",
                "execution_count": None,
                "outputs": [],
                "metadata": {},
            },
        ]
        write_notebook(nb_path, original)

        with pytest.raises(ValueError, match="Cell not found"):
            await delete_cell(nb_path, "nonexistent")

    async def test_raises_on_file_not_found(self, tmp_path: Path):
        nb_path = tmp_path / "nonexistent.ipynb"

        with pytest.raises(FileNotFoundError):
            await delete_cell(nb_path, "cell-1")
