"""Tests for the Teach feature — importing existing scripts as skills."""

import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from alive.core.teach import (
    TeachResult,
    _adapt_with_llm,
    _extract_imports,
    _fetch_source,
    _has_main_function,
    _wrap_raw,
    display_teach_result,
    teach_skill,
)
from alive.core.skill_registry import SkillInfo, SkillRegistry
from alive.sandbox.runner import SkillResult


# ── Fixtures ────────────────────────────────────────────────────────────────


@pytest.fixture
def tmp_script(tmp_path):
    """Create a temporary Python script."""
    script = tmp_path / "scraper.py"
    script.write_text(
        'import os\n'
        'import httpx\n'
        'from bs4 import BeautifulSoup\n'
        '\n'
        'def main(url: str = "https://example.com") -> dict:\n'
        '    resp = httpx.get(url)\n'
        '    soup = BeautifulSoup(resp.text, "html.parser")\n'
        '    return {"title": soup.title.string, "status": resp.status_code}\n'
    )
    return str(script)


@pytest.fixture
def simple_script(tmp_path):
    """Create a simple script without main()."""
    script = tmp_path / "simple.py"
    script.write_text(
        'import json\n'
        '\n'
        'data = {"message": "hello world"}\n'
        'print(json.dumps(data))\n'
    )
    return str(script)


@pytest.fixture
def tmp_skills_dir():
    """Create a temporary skills directory."""
    with tempfile.TemporaryDirectory() as d:
        skills_dir = Path(d)

        # Create a minimal existing skill for conflict tests
        existing = skills_dir / "existing_skill"
        existing.mkdir()
        (existing / "skill.py").write_text('def run() -> dict:\n    return {"success": True}\n')
        (existing / "metadata.json").write_text(json.dumps({
            "name": "existing_skill",
            "description": "An existing skill",
            "times_used": 0,
            "success_count": 0,
            "fail_count": 0,
            "confidence_score": 0.5,
            "dependencies": [],
            "input_schema": {},
            "output_schema": {},
            "sample_input": {},
        }))

        yield skills_dir


@pytest.fixture
def registry(tmp_skills_dir):
    """Create a SkillRegistry with the tmp_skills_dir."""
    return SkillRegistry(skills_dir=tmp_skills_dir)


# ── _fetch_source tests ───────────────────────────────────────────────────


class TestFetchSource:
    def test_local_file(self, tmp_script):
        code, path = _fetch_source(tmp_script)
        assert "httpx" in code
        assert "BeautifulSoup" in code
        assert path == str(Path(tmp_script).resolve())

    def test_file_not_found(self):
        with pytest.raises(FileNotFoundError, match="Script not found"):
            _fetch_source("/nonexistent/path/script.py")

    def test_not_a_file(self):
        with tempfile.TemporaryDirectory() as d:
            with pytest.raises(FileNotFoundError, match="Not a file"):
                _fetch_source(d)

    def test_url_fetch(self):
        mock_response = MagicMock()
        mock_response.read.return_value = b'print("hello")\n'
        mock_response.__enter__ = lambda s: s
        mock_response.__exit__ = lambda s, *a: None

        with patch("alive.core.teach.urllib.request.urlopen", return_value=mock_response):
            code, url = _fetch_source("https://example.com/script.py")

        assert code == 'print("hello")\n'
        assert url == "https://example.com/script.py"

    def test_url_fetch_failure(self):
        import urllib.error

        with patch(
            "alive.core.teach.urllib.request.urlopen",
            side_effect=urllib.error.URLError("Connection refused"),
        ):
            with pytest.raises(ConnectionError, match="Failed to fetch"):
                _fetch_source("https://example.com/script.py")


# ── _extract_imports tests ──────────────────────────────────────────────────


class TestExtractImports:
    def test_basic_imports(self):
        code = 'import httpx\nfrom bs4 import BeautifulSoup\n'
        result = _extract_imports(code)
        assert "httpx" in result
        assert "bs4" in result

    def test_filters_stdlib(self):
        code = 'import os\nimport json\nimport sys\nimport httpx\n'
        result = _extract_imports(code)
        assert "httpx" in result
        assert "os" not in result
        assert "json" not in result
        assert "sys" not in result

    def test_subpackage_imports(self):
        code = 'from xml.etree import ElementTree\nimport requests\n'
        result = _extract_imports(code)
        # xml is stdlib, should be filtered
        assert "xml" not in result
        assert "requests" in result

    def test_typing_filtered(self):
        code = 'from typing import Any\nfrom __future__ import annotations\nimport pandas\n'
        result = _extract_imports(code)
        assert "typing" not in result
        assert "__future__" not in result
        assert "pandas" in result

    def test_empty_code(self):
        assert _extract_imports("") == []

    def test_no_imports(self):
        assert _extract_imports('x = 1\nprint(x)\n') == []

    def test_deduplicated_and_sorted(self):
        code = 'import httpx\nfrom httpx import Client\nimport aiohttp\n'
        result = _extract_imports(code)
        assert result == ["aiohttp", "httpx"]


# ── _has_main_function tests ────────────────────────────────────────────────


class TestHasMainFunction:
    def test_with_main(self):
        assert _has_main_function('def main():\n    pass\n') is True

    def test_with_main_args(self):
        assert _has_main_function('def main(url: str) -> dict:\n    pass\n') is True

    def test_without_main(self):
        assert _has_main_function('def run():\n    pass\n') is False

    def test_main_in_comment(self):
        # main in a comment shouldn't count
        assert _has_main_function('# def main():\nprint("hello")\n') is False


# ── _wrap_raw tests ─────────────────────────────────────────────────────────


class TestWrapRaw:
    def test_basic(self, tmp_script):
        code = Path(tmp_script).read_text()
        result = _wrap_raw(code, "my_skill", tmp_script)

        assert result.skill_name == "my_skill"
        assert "def run(" in result.adapted_code
        assert result.original_code == code
        assert result.source_lines == len(code.splitlines())

    def test_preserves_code(self, tmp_script):
        code = Path(tmp_script).read_text()
        result = _wrap_raw(code, "my_skill", tmp_script)
        # Original code should be in the adapted version
        assert "httpx" in result.adapted_code
        assert "BeautifulSoup" in result.adapted_code

    def test_with_main_function(self, tmp_script):
        code = Path(tmp_script).read_text()
        result = _wrap_raw(code, "my_skill", tmp_script)
        # Script has main(), wrapper should call it
        assert "main(**kwargs)" in result.adapted_code
        assert "run() delegates to existing main() function" in result.changes

    def test_without_main(self, simple_script):
        code = Path(simple_script).read_text()
        result = _wrap_raw(code, "simple", simple_script)
        # No main(), wrapper returns generic success
        assert "main(" not in result.adapted_code.split("def run(")[1]
        assert "Script executed successfully" in result.adapted_code

    def test_extracts_dependencies(self, tmp_script):
        code = Path(tmp_script).read_text()
        result = _wrap_raw(code, "my_skill", tmp_script)
        assert "httpx" in result.dependencies
        assert "bs4" in result.dependencies
        # os is stdlib, should not be in deps
        assert "os" not in result.dependencies

    def test_changes_list(self, tmp_script):
        code = Path(tmp_script).read_text()
        result = _wrap_raw(code, "my_skill", tmp_script)
        assert any("--raw mode" in c for c in result.changes)

    def test_generates_test_code(self, tmp_script):
        code = Path(tmp_script).read_text()
        result = _wrap_raw(code, "my_skill", tmp_script)
        assert "from skill import run" in result.test_code
        assert "def test_" in result.test_code


# ── _adapt_with_llm tests ──────────────────────────────────────────────────


class TestAdaptWithLLM:
    @pytest.mark.asyncio
    async def test_basic_adaptation(self):
        from alive.llm.base import LLMProvider

        llm_response = (
            "```description\nFetch and parse web pages.\n```\n\n"
            "```dependencies\nhttpx\nbs4\n```\n\n"
            "```input_schema\n{\"url\": \"str\"}\n```\n\n"
            "```output_schema\n{\"result\": \"str\", \"success\": \"bool\"}\n```\n\n"
            '```python skill\n'
            'import httpx\n'
            'from bs4 import BeautifulSoup\n\n'
            'def run(url: str = "https://example.com") -> dict:\n'
            '    try:\n'
            '        resp = httpx.get(url)\n'
            '        soup = BeautifulSoup(resp.text, "html.parser")\n'
            '        return {"result": soup.title.string, "success": True}\n'
            '    except Exception as e:\n'
            '        return {"result": None, "success": False, "error": str(e)}\n'
            '```\n\n'
            '```python test\n'
            'from skill import run\n\n'
            'def test_run():\n'
            '    result = run()\n'
            '    assert isinstance(result, dict)\n'
            '```\n\n'
            '```python sample_input\n{"url": "https://example.com"}\n```\n\n'
            '```changes\n'
            '+ Wrapped logic in run() function\n'
            '+ Added error handling with try/except\n'
            '+ Added success/result keys to return dict\n'
            '```\n'
        )

        class MockProvider(LLMProvider):
            name = "mock"

            async def complete(self, system_prompt: str, user_prompt: str) -> str:
                return llm_response

        result = await _adapt_with_llm(
            'import httpx\nprint("hello")\n',
            "web_fetcher",
            "/tmp/script.py",
            provider=MockProvider(),
        )

        assert result.skill_name == "web_fetcher"
        assert result.description == "Fetch and parse web pages."
        assert "httpx" in result.dependencies
        assert "bs4" in result.dependencies
        assert "def run(" in result.adapted_code
        assert len(result.changes) == 3
        assert result.source_path == "/tmp/script.py"

    @pytest.mark.asyncio
    async def test_missing_skill_block_raises(self):
        from alive.llm.base import LLMProvider

        class MockProvider(LLMProvider):
            name = "mock"

            async def complete(self, system_prompt: str, user_prompt: str) -> str:
                return "Sorry, I don't understand."

        with pytest.raises(ValueError, match="LLM failed to produce"):
            await _adapt_with_llm(
                'print("hi")\n',
                "bad_skill",
                "/tmp/bad.py",
                provider=MockProvider(),
            )


# ── teach_skill integration tests ──────────────────────────────────────────


class TestTeachSkill:
    @pytest.mark.asyncio
    async def test_raw_mode(self, tmp_skills_dir, registry):
        # Write a simple script that will pass sandbox testing
        script = tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        )
        script.write(
            'def main() -> dict:\n'
            '    return {"result": "hello", "success": True}\n'
        )
        script.flush()

        from rich.console import Console

        mock_sr = SkillResult(
            success=True,
            output={"result": "hello", "success": True},
            error=None,
            duration_ms=50,
        )

        with patch("alive.core.teach.run_skill_code", return_value=mock_sr):
            with patch("alive.core.teach.get_registry", return_value=registry):
                info = await teach_skill(
                    script.name,
                    "test_raw_skill",
                    raw=True,
                    console=Console(quiet=True),
                    registry=registry,
                )

        assert info.name == "test_raw_skill"
        assert (tmp_skills_dir / "test_raw_skill" / "skill.py").exists()
        assert (tmp_skills_dir / "test_raw_skill" / "metadata.json").exists()

    @pytest.mark.asyncio
    async def test_llm_mode(self, tmp_skills_dir, registry):
        from alive.llm.base import LLMProvider
        from rich.console import Console

        llm_response = (
            "```description\nA test skill.\n```\n\n"
            "```dependencies\n```\n\n"
            "```input_schema\n{}\n```\n\n"
            "```output_schema\n{\"result\": \"str\", \"success\": \"bool\"}\n```\n\n"
            '```python skill\n'
            'def run() -> dict:\n'
            '    return {"result": "adapted", "success": True}\n'
            '```\n\n'
            '```python test\n'
            'from skill import run\n\n'
            'def test_run():\n'
            '    assert run()["success"]\n'
            '```\n\n'
            '```python sample_input\n{}\n```\n\n'
            '```changes\n+ Adapted to skill format\n```\n'
        )

        class MockProvider(LLMProvider):
            name = "mock"

            async def complete(self, system_prompt: str, user_prompt: str) -> str:
                return llm_response

        script = tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        )
        script.write('print("hello world")\n')
        script.flush()

        mock_sr = SkillResult(
            success=True,
            output={"result": "adapted", "success": True},
            error=None,
            duration_ms=30,
        )

        with patch("alive.core.teach.run_skill_code", return_value=mock_sr):
            with patch("alive.core.teach.get_dependency_manager"):
                info = await teach_skill(
                    script.name,
                    "test_llm_skill",
                    raw=False,
                    provider=MockProvider(),
                    console=Console(quiet=True),
                    registry=registry,
                )

        assert info.name == "test_llm_skill"
        assert info.description == "A test skill."

    @pytest.mark.asyncio
    async def test_test_failure_raises(self, tmp_skills_dir, registry):
        from rich.console import Console

        script = tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        )
        script.write('x = 1 / 0\n')
        script.flush()

        mock_sr = SkillResult(
            success=False,
            output=None,
            error="ZeroDivisionError: division by zero",
            duration_ms=10,
        )

        with patch("alive.core.teach.run_skill_code", return_value=mock_sr):
            with pytest.raises(RuntimeError, match="failed testing"):
                await teach_skill(
                    script.name,
                    "bad_skill",
                    raw=True,
                    console=Console(quiet=True),
                    registry=registry,
                )

    @pytest.mark.asyncio
    async def test_name_conflict(self, tmp_skills_dir, registry):
        from rich.console import Console

        script = tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        )
        script.write('def main():\n    return {"result": "ok", "success": True}\n')
        script.flush()

        mock_sr = SkillResult(
            success=True,
            output={"result": "ok", "success": True},
            error=None,
            duration_ms=20,
        )

        with patch("alive.core.teach.run_skill_code", return_value=mock_sr):
            with pytest.raises(FileExistsError, match="already exists"):
                await teach_skill(
                    script.name,
                    "existing_skill",
                    raw=True,
                    console=Console(quiet=True),
                    registry=registry,
                )

    @pytest.mark.asyncio
    async def test_url_source(self, tmp_skills_dir, registry):
        from rich.console import Console

        mock_response = MagicMock()
        mock_response.read.return_value = (
            b'def main():\n    return {"result": "from url", "success": True}\n'
        )
        mock_response.__enter__ = lambda s: s
        mock_response.__exit__ = lambda s, *a: None

        mock_sr = SkillResult(
            success=True,
            output={"result": "from url", "success": True},
            error=None,
            duration_ms=30,
        )

        with patch("alive.core.teach.urllib.request.urlopen", return_value=mock_response):
            with patch("alive.core.teach.run_skill_code", return_value=mock_sr):
                info = await teach_skill(
                    "https://gist.github.com/user/abc123/raw/script.py",
                    "url_skill",
                    raw=True,
                    console=Console(quiet=True),
                    registry=registry,
                )

        assert info.name == "url_skill"
        assert (tmp_skills_dir / "url_skill" / "skill.py").exists()


# ── TeachResult dataclass tests ────────────────────────────────────────────


class TestTeachResultDataclass:
    def test_defaults(self):
        r = TeachResult(
            skill_name="test",
            description="A test",
            original_code="print(1)",
            adapted_code="def run(): return {}",
            test_code="from skill import run",
        )
        assert r.dependencies == []
        assert r.input_schema == {}
        assert r.output_schema == {}
        assert r.sample_input == {}
        assert r.changes == []
        assert r.source_path == ""
        assert r.source_lines == 0

    def test_with_all_fields(self):
        r = TeachResult(
            skill_name="web_scraper",
            description="Scrape web pages",
            original_code="import httpx",
            adapted_code="def run(): ...",
            test_code="from skill import run",
            dependencies=["httpx"],
            input_schema={"url": "str"},
            output_schema={"result": "str", "success": "bool"},
            sample_input={"url": "https://example.com"},
            changes=["+ Added run()", "+ Added error handling"],
            source_path="/tmp/scraper.py",
            source_lines=42,
        )
        assert r.skill_name == "web_scraper"
        assert len(r.changes) == 2
        assert r.source_lines == 42


# ── Display tests ──────────────────────────────────────────────────────────


class TestDisplay:
    def test_display_teach_result_passed(self):
        from rich.console import Console

        result = TeachResult(
            skill_name="my_skill",
            description="Does stuff",
            original_code="x = 1",
            adapted_code="def run(): ...",
            test_code="from skill import run",
            changes=[
                "+ Wrapped main logic in run()",
                "+ Added return dict with result/success",
            ],
            source_path="./my_script.py",
            source_lines=42,
        )
        console = Console(file=open("/dev/null", "w"), force_terminal=False)
        display_teach_result(result, test_passed=True, console=console)

    def test_display_teach_result_failed(self):
        from rich.console import Console

        result = TeachResult(
            skill_name="bad_skill",
            description="Broken",
            original_code="x = 1/0",
            adapted_code="def run(): ...",
            test_code="from skill import run",
            source_path="./broken.py",
            source_lines=1,
        )
        console = Console(file=open("/dev/null", "w"), force_terminal=False)
        display_teach_result(result, test_passed=False, console=console)

    def test_display_with_no_changes(self):
        from rich.console import Console

        result = TeachResult(
            skill_name="clean",
            description="Already clean",
            original_code="def run(): ...",
            adapted_code="def run(): ...",
            test_code="from skill import run",
            changes=[],
            source_path="./clean.py",
            source_lines=1,
        )
        console = Console(file=open("/dev/null", "w"), force_terminal=False)
        # Should not crash even with empty changes
        display_teach_result(result, test_passed=True, console=console)

    def test_display_changes_get_prefix(self):
        from rich.console import Console
        from io import StringIO

        result = TeachResult(
            skill_name="prefixed",
            description="Test",
            original_code="x = 1",
            adapted_code="def run(): ...",
            test_code="from skill import run",
            changes=["Added run() function", "+ Already prefixed"],
            source_path="./test.py",
            source_lines=1,
        )
        output = StringIO()
        console = Console(file=output, force_terminal=False)
        display_teach_result(result, test_passed=True, console=console)
        text = output.getvalue()
        # "Added run() function" should get a "+ " prefix in display
        assert "Added run() function" in text
