# test_stac_hooks.py
"""Tests for STAC fetch hooks module."""

from __future__ import annotations

import json
import os
import tempfile
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from earthcatalog.stac_hooks import (
    CallableSTACHook,
    DefaultSTACHook,
    ModuleSTACHook,
    ScriptSTACHook,
    _normalize_stac_result,
    _normalize_stac_results,
    get_hook,
    parse_hook_config,
    serialize_hook,
)

# =============================================================================
# Sample STAC Item for Testing
# =============================================================================


def sample_stac_item(item_id: str = "test-item") -> dict[str, Any]:
    """Create a sample STAC item for testing."""
    return {
        "type": "Feature",
        "stac_version": "1.0.0",
        "id": item_id,
        "geometry": {
            "type": "Polygon",
            "coordinates": [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]],
        },
        "bbox": [-180, -90, 180, 90],
        "properties": {
            "datetime": "2024-01-15T12:00:00Z",
            "mission": "test-mission",
        },
        "links": [],
        "assets": {},
    }


# =============================================================================
# DefaultSTACHook Tests
# =============================================================================


class TestDefaultSTACHook:
    """Tests for DefaultSTACHook."""

    def test_to_config(self):
        """Test config string generation."""
        hook = DefaultSTACHook()
        assert hook.to_config() == "default"

    @patch("requests.get")
    def test_fetch_success(self, mock_get):
        """Test successful fetch of STAC item."""
        mock_response = MagicMock()
        mock_response.json.return_value = sample_stac_item()
        mock_response.raise_for_status = MagicMock()
        mock_get.return_value = mock_response

        hook = DefaultSTACHook()
        result = hook.fetch("https://example.com/item.json")

        assert result is not None
        assert result["id"] == "test-item"
        mock_get.assert_called_once()

    @patch("requests.get")
    def test_fetch_failure_with_retry(self, mock_get):
        """Test fetch with retry on failure."""
        from requests.exceptions import RequestException

        mock_get.side_effect = RequestException("Connection error")

        hook = DefaultSTACHook()
        result = hook.fetch("https://example.com/item.json", retry_attempts=2, timeout=1)

        assert result is None
        assert mock_get.call_count == 2

    @patch("requests.get")
    def test_fetch_batch_sequential(self, mock_get):
        """Test batch fetch processes sequentially."""
        mock_response = MagicMock()
        mock_response.json.side_effect = [
            sample_stac_item("item-1"),
            sample_stac_item("item-2"),
        ]
        mock_response.raise_for_status = MagicMock()
        mock_get.return_value = mock_response

        hook = DefaultSTACHook()
        results = hook.fetch_batch(["url1", "url2"])

        assert len(results) == 2
        assert results[0]["id"] == "item-1"
        assert results[1]["id"] == "item-2"


# =============================================================================
# CallableSTACHook Tests
# =============================================================================


class TestCallableSTACHook:
    """Tests for CallableSTACHook."""

    def test_fetch_with_callable(self):
        """Test fetch using a callable function."""

        def my_hook(url: str, **kwargs) -> dict[str, Any]:
            return sample_stac_item(f"item-from-{url}")

        hook = CallableSTACHook(my_hook)
        result = hook.fetch("test-url")

        assert result is not None
        assert result["id"] == "item-from-test-url"

    def test_fetch_returns_none_on_exception(self):
        """Test that exceptions in callable return None."""

        def failing_hook(url: str, **kwargs):
            raise ValueError("Hook failed")

        hook = CallableSTACHook(failing_hook)
        result = hook.fetch("test-url")

        assert result is None

    def test_fetch_batch_with_batch_function(self):
        """Test batch fetch with dedicated batch function."""

        def single_hook(url: str, **kwargs) -> dict[str, Any]:
            return sample_stac_item(f"single-{url}")

        def batch_hook(urls: list[str], **kwargs) -> list[dict[str, Any]]:
            return [sample_stac_item(f"batch-{url}") for url in urls]

        hook = CallableSTACHook(single_hook, batch_func=batch_hook)
        results = hook.fetch_batch(["url1", "url2"])

        assert len(results) == 2
        assert results[0]["id"] == "batch-url1"
        assert results[1]["id"] == "batch-url2"

    def test_fetch_batch_fallback_to_sequential(self):
        """Test batch fetch falls back to sequential when no batch func."""

        def single_hook(url: str, **kwargs) -> dict[str, Any]:
            return sample_stac_item(f"single-{url}")

        hook = CallableSTACHook(single_hook)
        results = hook.fetch_batch(["url1", "url2"])

        assert len(results) == 2
        assert results[0]["id"] == "single-url1"
        assert results[1]["id"] == "single-url2"

    def test_to_config(self):
        """Test config string for callable hook."""

        def named_func(url: str, **kwargs):
            return sample_stac_item()

        hook = CallableSTACHook(named_func)
        config = hook.to_config()

        assert "callable:" in config
        assert "named_func" in config


# =============================================================================
# ModuleSTACHook Tests
# =============================================================================


class TestModuleSTACHook:
    """Tests for ModuleSTACHook."""

    def test_to_config(self):
        """Test config string generation."""
        hook = ModuleSTACHook("mypackage.hooks:generate_item")
        assert hook.to_config() == "module:mypackage.hooks:generate_item"

    def test_import_error_on_invalid_path(self):
        """Test ImportError raised for invalid module path."""
        hook = ModuleSTACHook("nonexistent.module:function")

        with pytest.raises(ImportError):
            hook.fetch("test-url")

    def test_import_error_on_missing_function(self):
        """Test ImportError raised for missing function."""
        # Use a real module but non-existent function
        hook = ModuleSTACHook("json:nonexistent_function")

        with pytest.raises(ImportError):
            hook.fetch("test-url")

    def test_fetch_with_valid_module(self):
        """Test fetch with a real importable function."""
        # Create a hook that uses json.loads which takes a string
        # We'll mock the behavior we expect
        with patch.object(ModuleSTACHook, "_load_function") as mock_load:
            mock_func = MagicMock(return_value=sample_stac_item())
            mock_load.return_value = mock_func

            hook = ModuleSTACHook("some.module:func")
            result = hook.fetch("test-url")

            assert result is not None
            assert result["id"] == "test-item"


# =============================================================================
# ScriptSTACHook Tests
# =============================================================================


class TestScriptSTACHook:
    """Tests for ScriptSTACHook."""

    def test_to_config_without_interpreter(self):
        """Test config string without interpreter."""
        hook = ScriptSTACHook("/path/to/script.py")
        assert hook.to_config() == "script:/path/to/script.py"

    def test_to_config_with_interpreter(self):
        """Test config string with interpreter."""
        hook = ScriptSTACHook("/path/to/script.py", interpreter="python3")
        assert hook.to_config() == "script:python3:/path/to/script.py"

    def test_fetch_success(self):
        """Test successful script execution."""
        # Create a temporary script that outputs valid STAC JSON
        with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
            f.write("""#!/usr/bin/env python3
import json
import sys
item = {
    "type": "Feature",
    "id": f"item-{sys.argv[1]}",
    "geometry": None,
    "bbox": None,
    "properties": {"datetime": "2024-01-01T00:00:00Z"},
    "links": [],
    "assets": {}
}
print(json.dumps(item))
""")
            script_path = f.name

        try:
            os.chmod(script_path, 0o755)
            hook = ScriptSTACHook(script_path, interpreter="python3")
            result = hook.fetch("test-url", timeout=10)

            assert result is not None
            assert result["id"] == "item-test-url"
        finally:
            os.unlink(script_path)

    def test_fetch_script_timeout(self):
        """Test script timeout handling."""
        # Create a script that sleeps forever
        with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
            f.write("""#!/usr/bin/env python3
import time
time.sleep(100)
""")
            script_path = f.name

        try:
            os.chmod(script_path, 0o755)
            hook = ScriptSTACHook(script_path, interpreter="python3")
            result = hook.fetch("test-url", timeout=1)

            assert result is None
        finally:
            os.unlink(script_path)

    def test_fetch_script_invalid_json(self):
        """Test script that outputs invalid JSON."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
            f.write("""#!/usr/bin/env python3
print("not valid json")
""")
            script_path = f.name

        try:
            os.chmod(script_path, 0o755)
            hook = ScriptSTACHook(script_path, interpreter="python3")
            result = hook.fetch("test-url", timeout=10)

            assert result is None
        finally:
            os.unlink(script_path)

    def test_fetch_script_failure(self):
        """Test script that exits with error."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
            f.write("""#!/usr/bin/env python3
import sys
sys.exit(1)
""")
            script_path = f.name

        try:
            os.chmod(script_path, 0o755)
            hook = ScriptSTACHook(script_path, interpreter="python3")
            result = hook.fetch("test-url", timeout=10)

            assert result is None
        finally:
            os.unlink(script_path)


# =============================================================================
# parse_hook_config Tests
# =============================================================================


class TestParseHookConfig:
    """Tests for parse_hook_config function."""

    def test_none_returns_default(self):
        """Test None config returns DefaultSTACHook."""
        hook = parse_hook_config(None)
        assert isinstance(hook, DefaultSTACHook)

    def test_default_string_returns_default(self):
        """Test 'default' string returns DefaultSTACHook."""
        hook = parse_hook_config("default")
        assert isinstance(hook, DefaultSTACHook)

    def test_module_string_returns_module_hook(self):
        """Test module: prefix returns ModuleSTACHook."""
        hook = parse_hook_config("module:mypackage.hooks:generate_item")
        assert isinstance(hook, ModuleSTACHook)
        assert hook.module_path == "mypackage.hooks:generate_item"

    def test_script_string_returns_script_hook(self):
        """Test script: prefix returns ScriptSTACHook."""
        hook = parse_hook_config("script:/path/to/script.py")
        assert isinstance(hook, ScriptSTACHook)
        assert hook.script_path == "/path/to/script.py"

    def test_script_with_interpreter(self):
        """Test script with interpreter prefix."""
        hook = parse_hook_config("script:python3:/path/to/script.py")
        assert isinstance(hook, ScriptSTACHook)
        assert hook.script_path == "/path/to/script.py"
        assert hook.interpreter == "python3"

    def test_callable_returns_callable_hook(self):
        """Test callable returns CallableSTACHook."""

        def my_func(url: str, **kwargs):
            return {"id": url}

        hook = parse_hook_config(my_func)
        assert isinstance(hook, CallableSTACHook)

    def test_existing_hook_returned_as_is(self):
        """Test existing hook instance returned unchanged."""
        original = DefaultSTACHook()
        hook = parse_hook_config(original)
        assert hook is original

    def test_invalid_string_raises_error(self):
        """Test invalid config string raises ValueError."""
        with pytest.raises(ValueError):
            parse_hook_config("invalid:config")

    def test_invalid_type_raises_error(self):
        """Test invalid config type raises ValueError."""
        with pytest.raises(ValueError):
            parse_hook_config(12345)  # type: ignore


# =============================================================================
# get_hook Tests
# =============================================================================


class TestGetHook:
    """Tests for get_hook function (alias for parse_hook_config)."""

    def test_get_hook_default(self):
        """Test get_hook with default config."""
        hook = get_hook()
        assert isinstance(hook, DefaultSTACHook)

    def test_get_hook_with_string(self):
        """Test get_hook with string config."""
        hook = get_hook("default")
        assert isinstance(hook, DefaultSTACHook)


# =============================================================================
# serialize_hook Tests
# =============================================================================


class TestSerializeHook:
    """Tests for serialize_hook function."""

    def test_serialize_none_returns_default(self):
        """Test serializing None returns 'default'."""
        assert serialize_hook(None) == "default"

    def test_serialize_default_hook(self):
        """Test serializing DefaultSTACHook."""
        hook = DefaultSTACHook()
        assert serialize_hook(hook) == "default"

    def test_serialize_module_hook(self):
        """Test serializing ModuleSTACHook."""
        hook = ModuleSTACHook("mypackage.hooks:func")
        assert serialize_hook(hook) == "module:mypackage.hooks:func"

    def test_serialize_script_hook(self):
        """Test serializing ScriptSTACHook."""
        hook = ScriptSTACHook("/path/to/script")
        assert serialize_hook(hook) == "script:/path/to/script"

    def test_serialize_importable_callable(self):
        """Test serializing an importable callable."""
        # json.dumps is an importable function
        config = serialize_hook(json.dumps)
        assert config == "module:json:dumps"

    def test_serialize_anonymous_callable_raises_error(self):
        """Test serializing main-scoped callable raises error."""
        # Functions defined in __main__ cannot be serialized because they can't be imported
        # on remote workers. We need to use a function that's in __main__ scope.
        # Lambda functions have __name__ = "<lambda>" which can be detected

        def main_func(url):
            return {"id": url}

        # Manually set __module__ to __main__ to simulate a function in main
        main_func.__module__ = "__main__"

        with pytest.raises(ValueError, match="Cannot serialize callable"):
            serialize_hook(main_func)


# =============================================================================
# Integration Tests with workers.py
# =============================================================================


class TestWorkersIntegration:
    """Integration tests for STAC hooks with workers module."""

    def test_download_stac_item_with_default_hook(self):
        """Test _download_stac_item with default hook."""
        from earthcatalog.workers import _download_stac_item

        with patch("earthcatalog.stac_hooks.requests.get") as mock_get:
            mock_response = MagicMock()
            mock_response.json.return_value = sample_stac_item()
            mock_response.raise_for_status = MagicMock()
            mock_get.return_value = mock_response

            result = _download_stac_item(
                "https://example.com/item.json",
                hook_config="default",
            )

            assert result is not None
            assert result["id"] == "test-item"

    def test_download_stac_items_batch_with_default_hook(self):
        """Test _download_stac_items_batch with default hook."""
        from earthcatalog.workers import _download_stac_items_batch

        with patch("earthcatalog.stac_hooks.requests.get") as mock_get:
            mock_response = MagicMock()
            mock_response.json.side_effect = [
                sample_stac_item("item-1"),
                sample_stac_item("item-2"),
            ]
            mock_response.raise_for_status = MagicMock()
            mock_get.return_value = mock_response

            results = _download_stac_items_batch(
                ["url1", "url2"],
                hook_config="default",
            )

            assert len(results) == 2
            assert results[0]["id"] == "item-1"
            assert results[1]["id"] == "item-2"

    def test_get_stac_hook_caching(self):
        """Test that hooks are created correctly from config."""
        from earthcatalog.workers import _get_stac_hook

        hook1 = _get_stac_hook("default")
        hook2 = _get_stac_hook("default")

        # Both should be DefaultSTACHook instances
        assert isinstance(hook1, DefaultSTACHook)
        assert isinstance(hook2, DefaultSTACHook)


# =============================================================================
# ProcessingConfig Integration Tests
# =============================================================================


class TestProcessingConfigHook:
    """Test STAC hook configuration in ProcessingConfig."""

    def test_default_stac_hook(self):
        """Test default stac_hook value."""
        from earthcatalog.ingestion_pipeline import ProcessingConfig

        with tempfile.NamedTemporaryFile(mode="w", suffix=".parquet", delete=False) as f:
            f.write("")  # Just need a file to exist
            temp_file = f.name

        try:
            config = ProcessingConfig(
                input_file=temp_file,
                output_catalog="/tmp/catalog",
                scratch_location="/tmp/scratch",
            )
            assert config.stac_hook == "passthrough"
        finally:
            os.unlink(temp_file)

    def test_stac_hook_serialization(self):
        """Test stac_hook is included in to_dict/from_dict."""
        from earthcatalog.ingestion_pipeline import ProcessingConfig

        with tempfile.NamedTemporaryFile(mode="w", suffix=".parquet", delete=False) as f:
            f.write("")
            temp_file = f.name

        try:
            config = ProcessingConfig(
                input_file=temp_file,
                output_catalog="/tmp/catalog",
                scratch_location="/tmp/scratch",
                stac_hook="module:mypackage:myfunc",
            )

            config_dict = config.to_dict()
            assert "stac_hook" in config_dict
            assert config_dict["stac_hook"] == "module:mypackage:myfunc"

            restored = ProcessingConfig.from_dict(config_dict)
            assert restored.stac_hook == "module:mypackage:myfunc"
        finally:
            os.unlink(temp_file)

    def test_stac_hook_validation(self):
        """Test stac_hook validation in ProcessingConfig."""
        from earthcatalog.ingestion_pipeline import ProcessingConfig

        with tempfile.NamedTemporaryFile(mode="w", suffix=".parquet", delete=False) as f:
            f.write("")
            temp_file = f.name

        try:
            # Valid hook configs should pass
            for hook in ["default", "module:pkg:func", "script:/path/to/script"]:
                config = ProcessingConfig(
                    input_file=temp_file,
                    output_catalog="/tmp/catalog",
                    scratch_location="/tmp/scratch",
                    stac_hook=hook,
                )
                config.validate()  # Should not raise

            # Invalid hook config should fail
            config = ProcessingConfig(
                input_file=temp_file,
                output_catalog="/tmp/catalog",
                scratch_location="/tmp/scratch",
                stac_hook="invalid:hook:config",
            )
            with pytest.raises(ValueError, match="stac_hook"):
                config.validate()
        finally:
            os.unlink(temp_file)


# =============================================================================
# pystac.Item Support Tests
# =============================================================================


class MockPystacItem:
    """Mock pystac.Item class for testing without pystac dependency."""

    def __init__(self, item_dict: dict[str, Any]):
        self._dict = item_dict

    def to_dict(self) -> dict[str, Any]:
        return self._dict


class TestNormalizeStacResult:
    """Tests for _normalize_stac_result helper function."""

    def test_normalize_dict_returns_as_is(self):
        """Test that dict is returned unchanged."""
        item = sample_stac_item()
        result = _normalize_stac_result(item)
        assert result == item

    def test_normalize_none_returns_none(self):
        """Test that None is returned unchanged."""
        result = _normalize_stac_result(None)
        assert result is None

    def test_normalize_pystac_item_converts_to_dict(self):
        """Test that object with to_dict method is converted."""
        original_dict = sample_stac_item("pystac-item")
        mock_item = MockPystacItem(original_dict)

        result = _normalize_stac_result(mock_item)

        assert result == original_dict
        assert result["id"] == "pystac-item"

    def test_normalize_invalid_type_returns_none(self):
        """Test that invalid types return None."""
        result = _normalize_stac_result("not a valid item")
        assert result is None

        result = _normalize_stac_result(12345)
        assert result is None

        result = _normalize_stac_result(["list", "of", "items"])
        assert result is None

    def test_normalize_to_dict_exception_returns_none(self):
        """Test that exception in to_dict returns None."""

        class BrokenItem:
            def to_dict(self):
                raise RuntimeError("Failed to convert")

        result = _normalize_stac_result(BrokenItem())
        assert result is None


class TestNormalizeStacResults:
    """Tests for _normalize_stac_results batch helper function."""

    def test_normalize_empty_list(self):
        """Test normalizing empty list."""
        result = _normalize_stac_results([])
        assert result == []

    def test_normalize_list_of_dicts(self):
        """Test normalizing list of dictionaries."""
        items = [sample_stac_item("item-1"), sample_stac_item("item-2")]
        result = _normalize_stac_results(items)

        assert len(result) == 2
        assert result[0]["id"] == "item-1"
        assert result[1]["id"] == "item-2"

    def test_normalize_list_with_pystac_items(self):
        """Test normalizing list containing pystac.Item objects."""
        items = [
            MockPystacItem(sample_stac_item("pystac-1")),
            sample_stac_item("dict-2"),
            MockPystacItem(sample_stac_item("pystac-3")),
        ]

        result = _normalize_stac_results(items)

        assert len(result) == 3
        assert result[0]["id"] == "pystac-1"
        assert result[1]["id"] == "dict-2"
        assert result[2]["id"] == "pystac-3"

    def test_normalize_list_with_none_values(self):
        """Test normalizing list with None values."""
        items = [sample_stac_item("item-1"), None, sample_stac_item("item-3")]

        result = _normalize_stac_results(items)

        assert len(result) == 3
        assert result[0]["id"] == "item-1"
        assert result[1] is None
        assert result[2]["id"] == "item-3"


class TestCallableSTACHookPystacSupport:
    """Tests for pystac.Item support in CallableSTACHook."""

    def test_fetch_returns_pystac_item(self):
        """Test that CallableSTACHook handles pystac.Item returns."""

        def hook_returning_pystac(url: str, **kwargs) -> MockPystacItem:
            return MockPystacItem(sample_stac_item(f"pystac-{url}"))

        hook = CallableSTACHook(hook_returning_pystac)
        result = hook.fetch("test-url")

        assert result is not None
        assert isinstance(result, dict)
        assert result["id"] == "pystac-test-url"

    def test_fetch_batch_returns_pystac_items(self):
        """Test batch fetch with pystac.Item returns."""

        def single_hook(url: str, **kwargs) -> MockPystacItem:
            return MockPystacItem(sample_stac_item(f"single-{url}"))

        def batch_hook(urls: list[str], **kwargs) -> list[MockPystacItem]:
            return [MockPystacItem(sample_stac_item(f"batch-{url}")) for url in urls]

        hook = CallableSTACHook(single_hook, batch_func=batch_hook)
        results = hook.fetch_batch(["url1", "url2"])

        assert len(results) == 2
        assert all(isinstance(r, dict) for r in results)
        assert results[0]["id"] == "batch-url1"
        assert results[1]["id"] == "batch-url2"

    def test_fetch_batch_mixed_returns(self):
        """Test batch fetch with mixed dict and pystac.Item returns."""

        def batch_hook(urls: list[str], **kwargs) -> list[Any]:
            return [
                MockPystacItem(sample_stac_item("pystac-item")),
                sample_stac_item("dict-item"),
                None,
            ]

        hook = CallableSTACHook(lambda url, **kwargs: None, batch_func=batch_hook)
        results = hook.fetch_batch(["url1", "url2", "url3"])

        assert len(results) == 3
        assert results[0]["id"] == "pystac-item"
        assert results[1]["id"] == "dict-item"
        assert results[2] is None


class TestModuleSTACHookPystacSupport:
    """Tests for pystac.Item support in ModuleSTACHook."""

    def test_fetch_with_pystac_return(self):
        """Test ModuleSTACHook handles pystac.Item returns."""
        with patch.object(ModuleSTACHook, "_load_function") as mock_load:
            # Simulate a function that returns a pystac.Item
            mock_func = MagicMock(return_value=MockPystacItem(sample_stac_item("pystac-module")))
            mock_load.return_value = mock_func

            hook = ModuleSTACHook("some.module:func")
            result = hook.fetch("test-url")

            assert result is not None
            assert isinstance(result, dict)
            assert result["id"] == "pystac-module"
