"""Additional tests for core.py: error hints, retry decorator, TrialController log/flush,
AIAutoController singleton, delete_from_shared_cache gRPC call, _retry_on_unavailable."""

from unittest.mock import MagicMock, patch

import grpc
import pytest

from aiauto.core import (
    _DEFAULT_ERROR_HINT,
    _ERROR_HINTS,
    AIAutoController,
    TrialController,
    _retry_on_unavailable,
)
from aiauto.grpc_helper import AIAutoRpcError


class TestErrorHints:
    """Test _ERROR_HINTS mapping and _DEFAULT_ERROR_HINT."""

    def test_unauthenticated_hint_exists(self):
        assert "unauthenticated" in _ERROR_HINTS
        assert "token" in _ERROR_HINTS["unauthenticated"].lower()

    def test_failed_precondition_hint_exists(self):
        assert "failed_precondition" in _ERROR_HINTS
        assert "deleted" in _ERROR_HINTS["failed_precondition"].lower()

    def test_unavailable_hint_exists(self):
        assert "unavailable" in _ERROR_HINTS
        assert "temporarily" in _ERROR_HINTS["unavailable"].lower()

    def test_default_hint_is_generic(self):
        assert "server error" in _DEFAULT_ERROR_HINT.lower()

    def test_unknown_code_uses_default(self):
        """An unknown error code should fall back to _DEFAULT_ERROR_HINT."""
        hint = _ERROR_HINTS.get("nonexistent_code", _DEFAULT_ERROR_HINT)
        assert hint == _DEFAULT_ERROR_HINT


class TestRetryOnUnavailable:
    """Test _retry_on_unavailable decorator factory."""

    def test_decorator_retries_on_aiauto_rpc_error_unavailable(self):
        """The decorator should retry when AIAutoRpcError with code='unavailable' is raised."""
        call_count = 0

        @_retry_on_unavailable(timeout=5)
        def flaky_func():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise AIAutoRpcError("unavailable", "service down")
            return "success"

        # Need to patch wait to avoid sleeping in tests
        with patch("aiauto.core.wait_fixed", return_value=MagicMock(return_value=0)):
            result = flaky_func()

        assert result == "success"
        assert call_count == 2

    def test_decorator_does_not_retry_on_non_retryable_error(self):
        """The decorator should NOT retry for non-retryable errors (e.g., not_found)."""

        @_retry_on_unavailable(timeout=5)
        def always_fails():
            raise AIAutoRpcError("not_found", "resource missing")

        with pytest.raises(AIAutoRpcError, match="resource missing"):
            always_fails()


class TestTrialControllerLogAndFlush:
    """Test TrialController.log() and flush() methods."""

    @pytest.fixture(autouse=True)
    def clear_instances(self):
        yield
        TrialController._instances.clear()

    def _make_trial(self):
        import optuna

        storage = optuna.storages.InMemoryStorage()
        study = optuna.create_study(storage=storage, study_name="test-log-flush")
        trial = study.ask()
        return study, trial

    def test_log_appends_to_logs_list(self):
        study, trial = self._make_trial()
        tc = TrialController(trial)
        tc.log("message1")
        tc.log("message2")
        assert tc.logs == ["message1", "message2"]
        assert tc.log_count == 2
        study.tell(trial, 1.0)

    def test_log_triggers_save_note_every_5_logs(self):
        study, trial = self._make_trial()
        tc = TrialController(trial)

        with patch.object(tc, "_save_note") as mock_save:
            for i in range(10):
                tc.log(f"msg-{i}")
            # _save_note called at log 5 and log 10
            assert mock_save.call_count == 2

        study.tell(trial, 1.0)

    def test_flush_calls_save_note_when_logs_exist(self):
        study, trial = self._make_trial()
        tc = TrialController(trial)
        tc.log("pending log")

        with patch.object(tc, "_save_note") as mock_save:
            tc.flush()
            mock_save.assert_called_once()

        study.tell(trial, 1.0)

    def test_flush_noop_when_no_logs(self):
        study, trial = self._make_trial()
        tc = TrialController(trial)

        with patch.object(tc, "_save_note") as mock_save:
            tc.flush()
            mock_save.assert_not_called()

        study.tell(trial, 1.0)

    def test_save_note_failure_does_not_raise(self):
        """_save_note failure should be silently caught (fallback to console)."""
        study, trial = self._make_trial()
        tc = TrialController(trial)
        tc.log("test")

        with patch("aiauto.core.save_note", side_effect=RuntimeError("dashboard down")):
            # Should not raise
            tc._save_note()

        study.tell(trial, 1.0)

    def test_save_note_formats_logs_with_index(self):
        """_save_note formats each log with [XXXXX] index prefix."""
        study, trial = self._make_trial()
        tc = TrialController(trial)
        tc.log("first")
        tc.log("second")

        with patch("aiauto.core.save_note") as mock_save:
            tc._save_note()
            call_args = mock_save.call_args[0]
            note_content = call_args[1]
            assert "[00001] first" in note_content
            assert "[00002] second" in note_content

        study.tell(trial, 1.0)


class TestTrialControllerGetTrial:
    """Test TrialController.get_trial()."""

    @pytest.fixture(autouse=True)
    def clear_instances(self):
        yield
        TrialController._instances.clear()

    def test_get_trial_returns_stored_trial(self):
        import optuna

        storage = optuna.storages.InMemoryStorage()
        study = optuna.create_study(storage=storage)
        trial = study.ask()
        tc = TrialController(trial)
        assert tc.get_trial() is trial
        study.tell(trial, 1.0)


class TestAIAutoControllerSingleton:
    """Test AIAutoController.__new__ singleton pattern."""

    @pytest.fixture(autouse=True)
    def clear_instances(self):
        yield
        AIAutoController._instances.clear()

    def test_same_token_returns_same_instance(self):
        """Same token should return the same (uninitialized) instance from __new__."""
        # We only test __new__, not __init__ (which requires server connection)
        inst1 = AIAutoController.__new__(AIAutoController, "token-abc")
        inst2 = AIAutoController.__new__(AIAutoController, "token-abc")
        assert inst1 is inst2

    def test_different_token_returns_different_instance(self):
        inst1 = AIAutoController.__new__(AIAutoController, "token-1")
        inst2 = AIAutoController.__new__(AIAutoController, "token-2")
        assert inst1 is not inst2


class TestDeleteFromSharedCacheGrpcCall:
    """Test delete_from_shared_cache successful gRPC call path (mocked)."""

    def _make_controller_stub(self):
        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 = "http://localhost:1080/files/"
                return ctrl

    def test_successful_delete_returns_parsed_json(self):
        ctrl = self._make_controller_stub()
        mock_response = MagicMock()
        mock_response.json_response = '{"deleted": true}'
        ctrl.stub.DeleteFromSharedCache.return_value = mock_response

        with patch("aiauto.core.aiauto_pb2", create=True):
            result = ctrl.delete_from_shared_cache("data/file.txt")

        assert result == {"deleted": True}

    def test_delete_empty_response(self):
        ctrl = self._make_controller_stub()
        mock_response = MagicMock()
        mock_response.json_response = ""
        ctrl.stub.DeleteFromSharedCache.return_value = mock_response

        with patch("aiauto.core.aiauto_pb2", create=True):
            result = ctrl.delete_from_shared_cache("data/file.txt")

        assert result == {}

    def test_delete_grpc_error_raises_aiauto_rpc_error(self):
        ctrl = self._make_controller_stub()

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

            def details(self):
                return "file not found"

        ctrl.stub.DeleteFromSharedCache.side_effect = FakeRpcError()

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