"""Tests for ScryMeta and ScryClass."""

from unittest.mock import Mock, patch, MagicMock
import json

import pytest

from scry_run.meta import ScryMeta, ScryClass
from scry_run.cache import ScryCache
from scry_run.generator import GeneratedCode


class TestScryMeta:
    """Tests for ScryMeta metaclass."""

    def test_class_with_meta_has_correct_type(self):
        """Test that class with ScryMeta has correct type."""
        class MyClass(metaclass=ScryMeta):
            pass
        
        assert type(MyClass) is ScryMeta

    def test_llm_config_attrs_raise_attribute_error(self):
        """Test that _llm_* config attributes raise AttributeError."""
        class MyClass(metaclass=ScryMeta):
            pass

        with pytest.raises(AttributeError):
            _ = MyClass._llm_nonexistent

    def test_magic_attrs_raise_attribute_error(self):
        """Test that magic attributes raise AttributeError."""
        class MyClass(metaclass=ScryMeta):
            pass
        
        with pytest.raises(AttributeError):
            _ = MyClass.__nonexistent__


class TestScryClass:
    """Tests for ScryClass base class."""

    def test_inherits_from_llm_class(self):
        """Test that subclass inherits from ScryClass."""
        class MyApp(ScryClass):
            pass
        
        assert issubclass(MyApp, ScryClass)

    def test_instance_has_llm_meta(self):
        """Test that instance's class has ScryMeta."""
        class MyApp(ScryClass):
            _llm_enabled = False  # Disable to prevent __init__ generation

        app = MyApp()

        assert type(type(app)) is ScryMeta

    def test_existing_method_not_intercepted(self):
        """Test that existing methods are not intercepted."""
        class MyApp(ScryClass):
            _llm_enabled = False  # Disable to prevent __init__ generation

            def existing_method(self):
                return 42

        app = MyApp()

        assert app.existing_method() == 42

    def test_existing_class_method_not_intercepted(self):
        """Test that existing class methods are not intercepted."""
        class MyApp(ScryClass):
            @classmethod
            def class_method(cls):
                return "hello"
        
        assert MyApp.class_method() == "hello"

    def test_llm_config_attrs_raise_attribute_error(self):
        """Test that _llm_* config attributes raise AttributeError on instances."""
        class MyApp(ScryClass):
            _llm_enabled = False  # Disable to prevent __init__ generation

        app = MyApp()

        with pytest.raises(AttributeError):
            _ = app._llm_nonexistent

    def test_llm_disabled_raises_attribute_error(self):
        """Test that disabled LLM raises AttributeError on missing attr."""
        class MyApp(ScryClass):
            _llm_enabled = False
        
        app = MyApp()
        
        with pytest.raises(AttributeError):
            _ = app.nonexistent_method

    def test_scry_disable_and_enable(self):
        """Test llm_disable and llm_enable class methods."""
        class MyApp(ScryClass):
            pass
        
        assert MyApp._llm_enabled is True
        
        MyApp.scry_disable()
        assert MyApp._llm_enabled is False
        
        MyApp.scry_enable()
        assert MyApp._llm_enabled is True

    def test_get_cache_creates_instance(self):
        """Test _get_cache creates cache instance."""
        class MyApp(ScryClass):
            pass
        
        # Reset cache
        MyApp._llm_cache = None
        
        cache = MyApp._get_cache()
        
        assert isinstance(cache, ScryCache)
        assert MyApp._llm_cache is cache

    def test_get_cache_returns_same_instance(self):
        """Test _get_cache returns same instance on subsequent calls."""
        class MyApp(ScryClass):
            pass
        
        MyApp._llm_cache = None
        
        cache1 = MyApp._get_cache()
        cache2 = MyApp._get_cache()
        
        assert cache1 is cache2

    def test_get_class_source(self):
        """Test _get_class_source returns class source."""
        class MyApp(ScryClass):
            """My app docstring."""
            def my_method(self):
                return 42
        
        source = MyApp._get_class_source()
        
        assert "class MyApp" in source
        assert "My app docstring" in source
        assert "my_method" in source

    def test_execute_code_simple_function(self):
        """Test _execute_code with simple function."""
        class MyApp(ScryClass):
            pass
        
        code = "def test_func(self):\n    return 42"
        
        result = MyApp._execute_code(code, "test_func", [])
        
        assert callable(result)

    def test_execute_code_with_dependencies(self):
        """Test _execute_code with import dependencies."""
        class MyApp(ScryClass):
            pass
        
        code = "def get_path(self):\n    return Path('.')"
        dependencies = ["from pathlib import Path"]
        
        result = MyApp._execute_code(code, "get_path", dependencies)
        
        assert callable(result)

    @patch.object(ScryClass, "_get_generator")
    @patch.object(ScryClass, "_get_cache")
    def test_llm_generate_uses_cache(self, mock_get_cache, mock_get_generator):
        """Test that _llm_generate checks cache first."""
        from scry_run.cache import CacheEntry
        
        mock_cache = Mock()
        cached_entry = CacheEntry.create(
            class_name="MyApp",
            attr_name="cached_method",
            code="def cached_method(self): return 'from cache'",
        )
        mock_cache.get.return_value = cached_entry
        mock_get_cache.return_value = mock_cache
        
        class MyApp(ScryClass):
            pass
        
        result = MyApp._llm_generate("cached_method")
        
        mock_cache.get.assert_called_once_with("MyApp", "cached_method")
        # Generator should not be called if cache hit
        mock_get_generator.assert_not_called()

    @patch.object(ScryClass, "_get_generator")
    @patch.object(ScryClass, "_get_cache")
    def test_llm_generate_calls_generator_on_miss(self, mock_get_cache, mock_get_generator):
        """Test that _llm_generate calls generator on cache miss."""
        mock_cache = Mock()
        mock_cache.get.return_value = None
        mock_get_cache.return_value = mock_cache
        
        mock_generator = Mock()
        mock_generator.generate.return_value = GeneratedCode(
            code="def new_method(self): return 'generated'",
            code_type="method",
            docstring="A new method",
            dependencies=[],
        )
        mock_get_generator.return_value = mock_generator
        
        class MyApp(ScryClass):
            """A test app."""
            pass
        
        result = MyApp._llm_generate("new_method")
        
        mock_generator.generate.assert_called_once()
        mock_cache.set.assert_called_once()

    @patch.object(ScryClass, "_get_generator")
    @patch.object(ScryClass, "_get_cache")
    def test_instance_getattr_triggers_generation(self, mock_get_cache, mock_get_generator):
        """Test that accessing missing instance attr triggers generation."""
        mock_cache = Mock()
        mock_cache.get.return_value = None
        mock_get_cache.return_value = mock_cache

        def generate_side_effect(context, class_name, attr_name, is_classmethod=False, installed_packages=None):
            if attr_name == "__init__":
                return GeneratedCode(
                    code="def __init__(self): pass",
                    code_type="method",
                    docstring="Constructor",
                    dependencies=[],
                )
            return GeneratedCode(
                code="def my_method(self): return 'generated'",
                code_type="method",
                docstring="Generated method",
                dependencies=[],
            )

        mock_generator = Mock()
        mock_generator.generate.side_effect = generate_side_effect
        mock_get_generator.return_value = mock_generator

        class MyApp(ScryClass):
            """Test app."""
            pass

        app = MyApp()
        result = app.my_method()

        assert result == "generated"

    @patch.object(ScryClass, "_get_generator")
    @patch.object(ScryClass, "_get_cache")
    def test_method_bound_to_instance(self, mock_get_cache, mock_get_generator):
        """Test that generated method is bound to instance."""
        mock_cache = Mock()
        mock_cache.get.return_value = None
        mock_get_cache.return_value = mock_cache

        def generate_side_effect(context, class_name, attr_name, is_classmethod=False, installed_packages=None):
            if attr_name == "__init__":
                return GeneratedCode(
                    code="def __init__(self): pass",
                    code_type="method",
                    docstring="Constructor",
                    dependencies=[],
                )
            return GeneratedCode(
                code="def get_self(self): return self",
                code_type="method",
                docstring="Returns self",
                dependencies=[],
            )

        mock_generator = Mock()
        mock_generator.generate.side_effect = generate_side_effect
        mock_get_generator.return_value = mock_generator

        class MyApp(ScryClass):
            pass

        app = MyApp()
        result = app.get_self()

        assert result is app

    def test_scry_export_cache(self, tmp_path):
        """Test llm_export_cache exports to file."""
        class MyApp(ScryClass):
            pass
        
        # Set up a custom cache
        cache = ScryCache(tmp_path / "cache")
        cache.set("MyApp", "test_method", "def test_method(self): pass")
        MyApp._llm_cache = cache
        
        output_file = tmp_path / "exported.py"
        MyApp.scry_export_cache(str(output_file))
        
        assert output_file.exists()

    def test_scry_prune_cache(self, tmp_path):
        """Test llm_prune_cache removes entries."""
        class MyApp(ScryClass):
            pass
        
        cache = ScryCache(tmp_path / "cache")
        cache.set("MyApp", "method1", "def method1(self): pass")
        cache.set("MyApp", "method2", "def method2(self): pass")
        MyApp._llm_cache = cache
        
        pruned = MyApp.scry_prune_cache("method1")
        
        assert pruned == 1
        assert cache.get("MyApp", "method1") is None
        assert cache.get("MyApp", "method2") is not None


class TestScryClassIntegration:
    """Integration tests for ScryClass."""

    def test_subclass_isolation(self):
        """Test that subclasses have isolated caches."""
        class App1(ScryClass):
            pass
        
        class App2(ScryClass):
            pass
        
        # They should have the same cache instance (shared)
        # but entries are isolated by class name
        cache = App1._get_cache()
        cache.set("App1", "method", "code1")
        cache.set("App2", "method", "code2")
        
        assert cache.get("App1", "method").code == "code1"
        assert cache.get("App2", "method").code == "code2"

    def test_docstring_used_in_context(self):
        """Test that class docstring is included in source."""
        class MyApp(ScryClass):
            """This is a todo application with task management."""
            pass

        source = MyApp._get_class_source()

        assert "todo application" in source


class TestScryClassCacheResolution:
    """Tests for cache resolution relative to source file."""

    def test_cache_uses_sibling_cache_json(self, tmp_path):
        """Test _get_cache finds cache.json next to source file."""
        # Simulate an app directory with app.py
        app_dir = tmp_path / "my-app"
        app_dir.mkdir()

        # Write a module that defines an ScryClass
        app_py = app_dir / "app.py"
        app_py.write_text('''
from scry_run import ScryClass

class TestApp(ScryClass):
    pass
''')

        # Write cache.json
        cache_json = app_dir / "cache.json"
        cache_json.write_text('{}')

        # Import the module
        import sys
        sys.path.insert(0, str(app_dir))
        try:
            from app import TestApp

            cache = TestApp._get_cache()

            # Cache should point to sibling cache.json
            assert cache.cache_path == cache_json
        finally:
            sys.path.remove(str(app_dir))
            if "app" in sys.modules:
                del sys.modules["app"]
