"""Tests for core.py gRPC integration: channel creation, metadata, validation helpers.

Covers AIAutoController static methods and upload/delete operations that use gRPC stubs.
All gRPC calls are mocked - no real server connection needed.
"""

from unittest.mock import MagicMock, patch

import grpc
import pytest

from aiauto.core import AIAutoController


class TestBuildGrpcMetadata:
    """Test AIAutoController._build_grpc_metadata static method."""

    def test_metadata_contains_authorization_bearer(self):
        metadata = AIAutoController._build_grpc_metadata("test-token-123")
        metadata_dict = dict(metadata)
        assert metadata_dict["authorization"] == "Bearer test-token-123"

    def test_metadata_contains_client_version(self):
        metadata = AIAutoController._build_grpc_metadata("any-token")
        metadata_dict = dict(metadata)
        assert "x-client-version" in metadata_dict
        assert metadata_dict["x-client-version"]  # non-empty

    def test_metadata_is_tuple_of_tuples(self):
        metadata = AIAutoController._build_grpc_metadata("token")
        assert isinstance(metadata, tuple)
        for item in metadata:
            assert isinstance(item, tuple)
            assert len(item) == 2

    def test_empty_token_still_produces_bearer(self):
        metadata = AIAutoController._build_grpc_metadata("")
        metadata_dict = dict(metadata)
        assert metadata_dict["authorization"] == "Bearer "


class TestCreateGrpcChannel:
    """Test AIAutoController._create_grpc_channel static method."""

    @patch("aiauto.core.grpc")
    def test_http_creates_insecure_channel(self, mock_grpc):
        mock_grpc.insecure_channel.return_value = MagicMock(name="insecure_ch")

        AIAutoController._create_grpc_channel("http://localhost:8080")

        mock_grpc.insecure_channel.assert_called_once_with("localhost:8080")
        mock_grpc.secure_channel.assert_not_called()

    @patch("aiauto.core.grpc")
    def test_https_creates_secure_channel(self, mock_grpc):
        mock_grpc.secure_channel.return_value = MagicMock(name="secure_ch")
        mock_grpc.ssl_channel_credentials.return_value = MagicMock(name="creds")

        with patch("aiauto.core.AIAUTO_INSECURE", False):
            AIAutoController._create_grpc_channel("https://aiauto.example.com:443")

        mock_grpc.ssl_channel_credentials.assert_called_once()
        mock_grpc.secure_channel.assert_called_once()

    @patch("aiauto.core.grpc")
    @patch(
        "aiauto.core._fetch_server_cert",
        return_value=b"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n",
    )
    def test_https_insecure_mode_uses_ssl_with_skip_verify(self, mock_fetch, mock_grpc):
        mock_grpc.secure_channel.return_value = MagicMock(name="secure_ch")
        mock_grpc.ssl_channel_credentials.return_value = MagicMock(name="creds")

        with patch("aiauto.core.AIAUTO_INSECURE", True):
            AIAutoController._create_grpc_channel("https://192.168.1.100:443")

        mock_fetch.assert_called_once_with("192.168.1.100:443")
        mock_grpc.ssl_channel_credentials.assert_called_once_with(
            root_certificates=mock_fetch.return_value
        )
        call_args = mock_grpc.secure_channel.call_args
        options = call_args[1].get("options") if call_args[1] else None
        if options is None and len(call_args[0]) > 2:
            options = call_args[0][2]
        assert options is not None

    @patch("aiauto.core.grpc")
    def test_no_interceptor_direct_channel(self, mock_grpc):
        """HTTPRoute PathPrefix matching, no path prefix interceptor needed."""
        mock_grpc.insecure_channel.return_value = MagicMock(name="raw_ch")

        channel = AIAutoController._create_grpc_channel("http://localhost:50051")

        mock_grpc.insecure_channel.assert_called_once_with("localhost:50051")
        # Channel returned directly without intercept_channel
        assert channel == mock_grpc.insecure_channel.return_value
        mock_grpc.intercept_channel.assert_not_called()


class TestCheckNoNullBytes:
    """Test AIAutoController._check_no_null_bytes."""

    def test_normal_string_passes(self):
        AIAutoController._check_no_null_bytes("normal/path/file.txt", "test")

    def test_null_byte_raises(self):
        with pytest.raises(ValueError, match="must not contain null bytes"):
            AIAutoController._check_no_null_bytes("path/with\x00null", "file path")

    def test_empty_string_passes(self):
        AIAutoController._check_no_null_bytes("", "test")


class TestValidateDestDir:
    """Test AIAutoController._validate_dest_dir."""

    def test_none_returns_empty(self):
        assert AIAutoController._validate_dest_dir(None) == ""

    def test_empty_string_returns_empty(self):
        assert AIAutoController._validate_dest_dir("") == ""

    def test_whitespace_only_returns_empty(self):
        assert AIAutoController._validate_dest_dir("   ") == ""

    def test_strips_leading_trailing_slashes(self):
        assert AIAutoController._validate_dest_dir("/data/models/") == "data/models"

    def test_normal_path_returned(self):
        assert AIAutoController._validate_dest_dir("data/models") == "data/models"

    def test_dotdot_raises(self):
        with pytest.raises(ValueError, match="must not contain '..'"):
            AIAutoController._validate_dest_dir("data/../secret")

    def test_null_byte_raises(self):
        with pytest.raises(ValueError, match="must not contain null bytes"):
            AIAutoController._validate_dest_dir("data/\x00evil")

    def test_single_dir(self):
        assert AIAutoController._validate_dest_dir("models") == "models"


class TestValidateFilename:
    """Test AIAutoController._validate_filename."""

    def test_normal_filename(self):
        assert AIAutoController._validate_filename("/path/to/file.txt") == "file.txt"

    def test_just_filename(self):
        assert AIAutoController._validate_filename("data.csv") == "data.csv"

    def test_empty_filename_raises(self):
        with pytest.raises(ValueError, match="file path has no filename"):
            AIAutoController._validate_filename("")

    def test_trailing_slash_raises(self):
        with pytest.raises(ValueError, match="file path has no filename"):
            AIAutoController._validate_filename("/path/to/")

    def test_filename_too_long_raises(self):
        long_name = "a" * 256
        with pytest.raises(ValueError, match="filename exceeds 255 bytes"):
            AIAutoController._validate_filename(long_name)

    def test_filename_exactly_255_bytes_passes(self):
        name_255 = "a" * 255
        assert AIAutoController._validate_filename(name_255) == name_255


def _make_controller_stub(tus_url="http://localhost:1080/files/"):
    """Create a minimal AIAutoController with mocked internals (no real __init__)."""
    with patch.object(AIAutoController, "__new__", lambda cls, *a, **kw: object.__new__(cls)):
        with patch.object(AIAutoController, "__init__", lambda self, *a, **kw: None):
            ctrl = object.__new__(AIAutoController)
            ctrl.stub = MagicMock()
            ctrl._metadata = (("authorization", "Bearer test"),)
            ctrl.token = "test"
            ctrl.tus_upload_url = tus_url
            return ctrl


class TestDeleteFromSharedCacheValidation:
    """Test delete_from_shared_cache input validation (without server).

    The method validates path before making the gRPC call. We test these
    validations by mocking the controller's stub so the gRPC call never fires.
    """

    def test_empty_path_raises(self):
        ctrl = _make_controller_stub()
        with pytest.raises(ValueError, match="path must not be empty"):
            ctrl.delete_from_shared_cache("")

    def test_absolute_path_raises(self):
        ctrl = _make_controller_stub()
        with pytest.raises(ValueError, match="path must be relative"):
            ctrl.delete_from_shared_cache("/etc/passwd")

    def test_null_byte_in_path_raises(self):
        ctrl = _make_controller_stub()
        with pytest.raises(ValueError, match="must not contain null bytes"):
            ctrl.delete_from_shared_cache("file\x00name.txt")

    def test_dotdot_in_path_raises(self):
        ctrl = _make_controller_stub()
        with pytest.raises(ValueError, match="must not contain '..'"):
            ctrl.delete_from_shared_cache("data/../secret.txt")


class TestUploadToSharedCacheValidation:
    """Test upload_to_shared_cache input validation (without server).

    Validates file_paths and dest_dir before the tus upload begins.
    """

    def test_empty_file_paths_raises(self):
        ctrl = _make_controller_stub()
        with pytest.raises(ValueError, match="file_paths must not be empty"):
            ctrl.upload_to_shared_cache([])

    def test_no_tus_url_raises(self):
        ctrl = _make_controller_stub(tus_url="")
        with pytest.raises(RuntimeError, match="tus_upload_url not available"):
            ctrl.upload_to_shared_cache("/tmp/test.txt")

    def test_null_byte_in_filepath_raises(self):
        ctrl = _make_controller_stub()
        with pytest.raises(ValueError, match="must not contain null bytes"):
            ctrl.upload_to_shared_cache("/tmp/file\x00.txt")

    def test_dotdot_in_dest_dir_raises(self):
        ctrl = _make_controller_stub()
        with pytest.raises(ValueError, match="must not contain '..'"):
            ctrl.upload_to_shared_cache("/tmp/test.txt", dest_dir="a/../b")

    def test_string_file_path_converted_to_list(self):
        """Verify single string is treated as list of one."""
        ctrl = _make_controller_stub()
        # This will fail at file existence check, but we verify it gets past
        # the "not file_paths" check (empty list would raise ValueError)
        with pytest.raises((FileNotFoundError, RuntimeError, OSError)):
            ctrl.upload_to_shared_cache("/nonexistent/file.txt")

    @patch("tusclient.client.TusClient")
    def test_successful_upload_returns_uploaded_files(self, mock_tus_cls, tmp_path):
        ctrl = _make_controller_stub()

        # Create a temporary file to upload
        test_file = tmp_path / "data.csv"
        test_file.write_text("a,b,c\n1,2,3\n")

        # Mock TusClient and uploader
        mock_tus = MagicMock()
        mock_tus_cls.return_value = mock_tus
        mock_uploader = MagicMock()
        mock_tus.uploader.return_value = mock_uploader

        result = ctrl.upload_to_shared_cache(str(test_file), dest_dir="subdir")

        assert result == {"uploaded_files": ["data.csv"]}
        mock_tus_cls.assert_called_once_with(
            "http://localhost:1080/files/",
            headers={"Authorization": "Bearer test"},
        )
        mock_uploader.upload.assert_called_once()
        # Verify metadata includes filename and dest_dir
        uploader_call = mock_tus.uploader.call_args
        assert uploader_call[1]["metadata"]["filename"] == "data.csv"
        assert uploader_call[1]["metadata"]["dest_dir"] == "subdir"

    @patch("tusclient.client.TusClient")
    def test_upload_multiple_files(self, mock_tus_cls, tmp_path):
        ctrl = _make_controller_stub()

        f1 = tmp_path / "a.txt"
        f1.write_text("aaa")
        f2 = tmp_path / "b.txt"
        f2.write_text("bbb")

        mock_tus = MagicMock()
        mock_tus_cls.return_value = mock_tus
        mock_uploader = MagicMock()
        mock_tus.uploader.return_value = mock_uploader

        result = ctrl.upload_to_shared_cache([str(f1), str(f2)])

        assert result == {"uploaded_files": ["a.txt", "b.txt"]}
        assert mock_tus.uploader.call_count == 2

    @patch("tusclient.client.TusClient")
    def test_upload_without_dest_dir_omits_metadata_key(self, mock_tus_cls, tmp_path):
        """When dest_dir is None, metadata should not include dest_dir key."""
        ctrl = _make_controller_stub()

        test_file = tmp_path / "model.pt"
        test_file.write_text("weights")

        mock_tus = MagicMock()
        mock_tus_cls.return_value = mock_tus
        mock_uploader = MagicMock()
        mock_tus.uploader.return_value = mock_uploader

        result = ctrl.upload_to_shared_cache(str(test_file))

        assert result == {"uploaded_files": ["model.pt"]}
        uploader_call = mock_tus.uploader.call_args
        assert "dest_dir" not in uploader_call[1]["metadata"]

    @patch("tusclient.client.TusClient")
    def test_upload_failure_raises_runtime_error(self, mock_tus_cls, tmp_path):
        ctrl = _make_controller_stub()

        test_file = tmp_path / "fail.bin"
        test_file.write_bytes(b"\x00" * 10)

        mock_tus = MagicMock()
        mock_tus_cls.return_value = mock_tus
        mock_uploader = MagicMock()
        mock_uploader.upload.side_effect = ConnectionError("network down")
        mock_tus.uploader.return_value = mock_uploader

        with pytest.raises(RuntimeError, match="tus upload failed"):
            ctrl.upload_to_shared_cache(str(test_file))


class TestListSharedCache:
    """Test list_shared_cache gRPC call (mocked)."""

    def test_list_returns_parsed_json(self):
        ctrl = _make_controller_stub()
        mock_response = MagicMock()
        mock_response.json_response = '{"files": ["a.txt", "b.txt"]}'
        ctrl.stub.ListSharedCache.return_value = mock_response

        with patch("aiauto.core.aiauto_pb2", create=True):
            result = ctrl.list_shared_cache()

        assert result == {"files": ["a.txt", "b.txt"]}

    def test_list_empty_response(self):
        ctrl = _make_controller_stub()
        mock_response = MagicMock()
        mock_response.json_response = ""
        ctrl.stub.ListSharedCache.return_value = mock_response

        with patch("aiauto.core.aiauto_pb2", create=True):
            result = ctrl.list_shared_cache()

        assert result == {}

    def test_list_grpc_error_raises_aiauto_rpc_error(self):
        ctrl = _make_controller_stub()

        class FakeRpcError(grpc.RpcError):
            def code(self):
                return grpc.StatusCode.PERMISSION_DENIED

            def details(self):
                return "forbidden"

        ctrl.stub.ListSharedCache.side_effect = FakeRpcError()

        from aiauto.grpc_helper import AIAutoRpcError

        with patch("aiauto.core.aiauto_pb2", create=True):
            with pytest.raises(AIAutoRpcError):
                ctrl.list_shared_cache()
