"""Tests for sample project tools."""

from __future__ import annotations

import io
import json
import zipfile
from unittest.mock import AsyncMock, patch

import pytest

from gfp_mcp.samples import (
    GENERAL_PDK_PROJECTS,
    SAMPLE_EXTENSIONS,
    SampleFile,
    SampleProject,
    _extract_file_content,
    _extract_sample_files,
    _get_mime_type,
    get_sample_file_content,
    list_sample_projects,
)
from gfp_mcp.tools import TOOL_HANDLERS, get_all_tools, get_handler
from gfp_mcp.tools.samples import GetSampleFileHandler, ListSamplesHandler


def _make_zip(files: dict[str, str]) -> bytes:
    """Create an in-memory ZIP with the given files."""
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for name, content in files.items():
            zf.writestr(name, content)
    return buf.getvalue()


# ---------------------------------------------------------------------------
# Unit tests for gfp_mcp/samples.py
# ---------------------------------------------------------------------------


class TestExtractSampleFiles:
    """Tests for _extract_sample_files."""

    def test_finds_py_files_in_samples_dir(self) -> None:
        zip_data = _make_zip(
            {
                "project/samples/demo.py": "print('hello')",
                "project/samples/config.yml": "key: value",
            }
        )
        result = _extract_sample_files(zip_data, "test-project")
        assert "project/samples/demo.py" in result
        assert "project/samples/config.yml" in result

    def test_excludes_non_sample_extensions(self) -> None:
        zip_data = _make_zip(
            {
                "project/samples/image.png": b"fake".decode(),
                "project/samples/data.json": "{}",
                "project/samples/script.py": "pass",
            }
        )
        result = _extract_sample_files(zip_data, "test-project")
        assert "project/samples/script.py" in result
        assert "project/samples/image.png" not in result
        assert "project/samples/data.json" not in result

    def test_excludes_init_and_pycache(self) -> None:
        zip_data = _make_zip(
            {
                "project/samples/__init__.py": "",
                "project/samples/__pycache__/module.py": "",
                "project/samples/real.py": "code",
            }
        )
        result = _extract_sample_files(zip_data, "test-project")
        assert len(result) == 1
        assert "project/samples/real.py" in result

    def test_excludes_files_outside_samples(self) -> None:
        zip_data = _make_zip(
            {
                "project/src/main.py": "code",
                "project/samples/demo.py": "code",
            }
        )
        result = _extract_sample_files(zip_data, "test-project")
        assert len(result) == 1
        assert "project/samples/demo.py" in result

    def test_returns_sorted_paths(self) -> None:
        zip_data = _make_zip(
            {
                "project/samples/z_last.py": "",
                "project/samples/a_first.py": "",
                "project/samples/m_middle.py": "",
            }
        )
        result = _extract_sample_files(zip_data, "test-project")
        assert result == sorted(result)

    def test_empty_zip(self) -> None:
        zip_data = _make_zip({})
        result = _extract_sample_files(zip_data, "test-project")
        assert result == []

    def test_yaml_extensions(self) -> None:
        zip_data = _make_zip(
            {
                "project/samples/config.yml": "a: 1",
                "project/samples/spec.yaml": "b: 2",
            }
        )
        result = _extract_sample_files(zip_data, "test-project")
        assert len(result) == 2

    def test_nested_samples_dir(self) -> None:
        zip_data = _make_zip(
            {
                "project/sub/samples/nested.py": "code",
            }
        )
        result = _extract_sample_files(zip_data, "test-project")
        assert "project/sub/samples/nested.py" in result


class TestExtractFileContent:
    """Tests for _extract_file_content."""

    def test_extracts_utf8_content(self) -> None:
        zip_data = _make_zip({"samples/demo.py": "print('hello')"})
        content = _extract_file_content(zip_data, "samples/demo.py")
        assert content == "print('hello')"

    def test_raises_key_error_for_missing_file(self) -> None:
        zip_data = _make_zip({"samples/demo.py": "code"})
        with pytest.raises(KeyError):
            _extract_file_content(zip_data, "nonexistent.py")


class TestGetMimeType:
    """Tests for _get_mime_type."""

    def test_python_file(self) -> None:
        mime = _get_mime_type("demo.py")
        assert "python" in mime or mime == "text/x-python"

    def test_yaml_file(self) -> None:
        mime = _get_mime_type("config.yaml")
        assert mime is not None

    def test_unknown_extension_defaults_to_text(self) -> None:
        mime = _get_mime_type("file_without_extension")
        assert mime == "text/plain"


class TestConstants:
    """Tests for module constants."""

    def test_general_pdk_projects(self) -> None:
        assert len(GENERAL_PDK_PROJECTS) == 2
        assert "photonics--basic--public--pdk" in GENERAL_PDK_PROJECTS
        assert "photonics--full--public--pdk" in GENERAL_PDK_PROJECTS

    def test_sample_extensions(self) -> None:
        assert ".py" in SAMPLE_EXTENSIONS
        assert ".yml" in SAMPLE_EXTENSIONS
        assert ".yaml" in SAMPLE_EXTENSIONS


class TestDataClasses:
    """Tests for SampleProject and SampleFile data classes."""

    def test_sample_project(self) -> None:
        project = SampleProject(name="test", files=["a.py", "b.py"])
        assert project.name == "test"
        assert len(project.files) == 2

    def test_sample_project_default_files(self) -> None:
        project = SampleProject(name="test")
        assert project.files == []

    def test_sample_file(self) -> None:
        sf = SampleFile(
            project="proj", path="demo.py", content="code", mime_type="text/x-python"
        )
        assert sf.project == "proj"
        assert sf.content == "code"


# ---------------------------------------------------------------------------
# Async tests for download/list functions
# ---------------------------------------------------------------------------


class TestListSampleProjects:
    """Tests for list_sample_projects."""

    @pytest.mark.anyio
    async def test_success(self) -> None:
        zip_data = _make_zip({"project/samples/demo.py": "print('hi')"})

        with patch(
            "gfp_mcp.samples._download_project_zip",
            new_callable=AsyncMock,
            return_value=zip_data,
        ):
            projects = await list_sample_projects("test-key")

        assert len(projects) == 2
        for p in projects:
            assert isinstance(p, SampleProject)
            assert len(p.files) == 1

    @pytest.mark.anyio
    async def test_handles_download_failure(self) -> None:
        with patch(
            "gfp_mcp.samples._download_project_zip",
            new_callable=AsyncMock,
            side_effect=Exception("network error"),
        ):
            projects = await list_sample_projects("test-key")

        assert len(projects) == 2
        for p in projects:
            assert p.files == []


class TestGetSampleFileContent:
    """Tests for get_sample_file_content."""

    @pytest.mark.anyio
    async def test_success(self) -> None:
        zip_data = _make_zip({"samples/demo.py": "print('hello')"})

        with patch(
            "gfp_mcp.samples._download_project_zip",
            new_callable=AsyncMock,
            return_value=zip_data,
        ):
            result = await get_sample_file_content(
                "test-key", "photonics--basic--public--pdk", "samples/demo.py"
            )

        assert isinstance(result, SampleFile)
        assert result.content == "print('hello')"
        assert result.project == "photonics--basic--public--pdk"

    @pytest.mark.anyio
    async def test_invalid_project_raises_value_error(self) -> None:
        with pytest.raises(ValueError, match="Unknown sample project"):
            await get_sample_file_content("test-key", "invalid-project", "demo.py")

    @pytest.mark.anyio
    async def test_missing_file_raises_key_error(self) -> None:
        zip_data = _make_zip({"samples/other.py": "code"})

        with patch(
            "gfp_mcp.samples._download_project_zip",
            new_callable=AsyncMock,
            return_value=zip_data,
        ):
            with pytest.raises(KeyError):
                await get_sample_file_content(
                    "test-key",
                    "photonics--basic--public--pdk",
                    "samples/nonexistent.py",
                )


# ---------------------------------------------------------------------------
# Handler tests
# ---------------------------------------------------------------------------


class TestListSamplesHandler:
    """Tests for ListSamplesHandler."""

    def test_handler_properties(self) -> None:
        handler = ListSamplesHandler()
        assert handler.name == "list_samples"
        assert handler.mapping is None
        assert handler.definition.name == "list_samples"

    @pytest.mark.anyio
    async def test_no_api_key(self) -> None:
        handler = ListSamplesHandler()
        mock_client = AsyncMock()

        with patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value=None):
            result = await handler.handle({}, mock_client)

        assert len(result) == 1
        data = json.loads(result[0].text)
        assert "error" in data
        assert "API key" in data["error"]

    @pytest.mark.anyio
    async def test_success(self) -> None:
        handler = ListSamplesHandler()
        mock_client = AsyncMock()

        mock_projects = [
            SampleProject(name="proj-a", files=["samples/a.py"]),
            SampleProject(name="proj-b", files=["samples/b.py"]),
        ]

        with (
            patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value="test-key"),
            patch(
                "gfp_mcp.tools.samples.list_sample_projects",
                new_callable=AsyncMock,
                return_value=mock_projects,
            ),
        ):
            result = await handler.handle({}, mock_client)

        data = json.loads(result[0].text)
        assert "projects" in data
        assert len(data["projects"]) == 2
        assert data["projects"][0]["name"] == "proj-a"
        assert data["projects"][0]["files"] == ["samples/a.py"]

    @pytest.mark.anyio
    async def test_network_error(self) -> None:
        handler = ListSamplesHandler()
        mock_client = AsyncMock()

        with (
            patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value="test-key"),
            patch(
                "gfp_mcp.tools.samples.list_sample_projects",
                new_callable=AsyncMock,
                side_effect=Exception("connection failed"),
            ),
        ):
            result = await handler.handle({}, mock_client)

        data = json.loads(result[0].text)
        assert "error" in data
        assert "Failed to fetch samples" in data["error"]


class TestGetSampleFileHandler:
    """Tests for GetSampleFileHandler."""

    def test_handler_properties(self) -> None:
        handler = GetSampleFileHandler()
        assert handler.name == "get_sample_file"
        assert handler.mapping is None
        assert handler.definition.name == "get_sample_file"
        assert "project" in handler.definition.inputSchema["required"]
        assert "path" in handler.definition.inputSchema["required"]

    @pytest.mark.anyio
    async def test_no_api_key(self) -> None:
        handler = GetSampleFileHandler()
        mock_client = AsyncMock()

        with patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value=None):
            result = await handler.handle(
                {"project": "proj", "path": "demo.py"}, mock_client
            )

        data = json.loads(result[0].text)
        assert "error" in data
        assert "API key" in data["error"]

    @pytest.mark.anyio
    async def test_missing_params(self) -> None:
        handler = GetSampleFileHandler()
        mock_client = AsyncMock()

        with patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value="test-key"):
            result = await handler.handle({}, mock_client)

        data = json.loads(result[0].text)
        assert "error" in data
        assert "required" in data["error"]

    @pytest.mark.anyio
    async def test_invalid_project(self) -> None:
        handler = GetSampleFileHandler()
        mock_client = AsyncMock()

        with (
            patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value="test-key"),
            patch(
                "gfp_mcp.tools.samples.get_sample_file_content",
                new_callable=AsyncMock,
                side_effect=ValueError("Unknown sample project"),
            ),
        ):
            result = await handler.handle(
                {"project": "invalid", "path": "demo.py"}, mock_client
            )

        data = json.loads(result[0].text)
        assert "error" in data
        assert "Unknown sample project" in data["error"]
        assert "available_projects" in data

    @pytest.mark.anyio
    async def test_file_not_found(self) -> None:
        handler = GetSampleFileHandler()
        mock_client = AsyncMock()

        with (
            patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value="test-key"),
            patch(
                "gfp_mcp.tools.samples.get_sample_file_content",
                new_callable=AsyncMock,
                side_effect=KeyError("missing.py"),
            ),
        ):
            result = await handler.handle(
                {"project": "photonics--basic--public--pdk", "path": "missing.py"},
                mock_client,
            )

        data = json.loads(result[0].text)
        assert "error" in data
        assert "File not found" in data["error"]
        assert "suggestion" in data

    @pytest.mark.anyio
    async def test_success(self) -> None:
        handler = GetSampleFileHandler()
        mock_client = AsyncMock()

        mock_file = SampleFile(
            project="photonics--basic--public--pdk",
            path="samples/demo.py",
            content="print('hello')",
            mime_type="text/x-python",
        )

        with (
            patch("gfp_mcp.tools.samples.get_gfp_api_key", return_value="test-key"),
            patch(
                "gfp_mcp.tools.samples.get_sample_file_content",
                new_callable=AsyncMock,
                return_value=mock_file,
            ),
        ):
            result = await handler.handle(
                {
                    "project": "photonics--basic--public--pdk",
                    "path": "samples/demo.py",
                },
                mock_client,
            )

        data = json.loads(result[0].text)
        assert data["project"] == "photonics--basic--public--pdk"
        assert data["path"] == "samples/demo.py"
        assert data["content"] == "print('hello')"
        assert data["metadata"]["mime_type"] == "text/x-python"


# ---------------------------------------------------------------------------
# Registration tests
# ---------------------------------------------------------------------------


class TestSamplesRegistration:
    """Verify handlers are registered in the tool registry."""

    def test_list_samples_in_handlers(self) -> None:
        assert "list_samples" in TOOL_HANDLERS
        assert isinstance(TOOL_HANDLERS["list_samples"], ListSamplesHandler)

    def test_get_sample_file_in_handlers(self) -> None:
        assert "get_sample_file" in TOOL_HANDLERS
        assert isinstance(TOOL_HANDLERS["get_sample_file"], GetSampleFileHandler)

    def test_handlers_in_get_handler(self) -> None:
        assert get_handler("list_samples") is not None
        assert get_handler("get_sample_file") is not None

    def test_handlers_in_get_all_tools(self) -> None:
        tools = get_all_tools()
        tool_names = [t.name for t in tools]
        assert "list_samples" in tool_names
        assert "get_sample_file" in tool_names

    def test_no_mapping_for_sample_tools(self) -> None:
        for name in ("list_samples", "get_sample_file"):
            handler = get_handler(name)
            assert handler is not None
            assert handler.mapping is None
