"""Tests for serializer module."""

import json
import tempfile
from pathlib import Path

import optuna
import pytest

from aiauto.sampler import ExhaustiveCategoricalSampler
from aiauto.serializer import build_requirements, from_json, object_to_json, serialize


class TestSerialize:
    """Test serialize function."""

    def test_serialize_function_success(self):
        """Test serializing a function defined in a file."""

        def sample_function(x):
            return x * 2

        # Functions defined in test files can be serialized
        result = serialize(sample_function)
        assert "def sample_function(x):" in result
        assert "return x * 2" in result

    def test_serialize_lambda_success(self):
        """Test serializing lambda defined in file."""
        func = lambda x: x * 2  # noqa: E731
        # Lambda in file can be serialized
        result = serialize(func)
        assert "lambda" in result

    def test_serialize_builtin_fails(self):
        """Test serializing builtin function fails."""
        with pytest.raises(ValueError, match="serialize failed"):
            serialize(len)


class TestBuildRequirements:
    """Test build_requirements function."""

    def test_from_file(self):
        """Test building requirements from file."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
            f.write("numpy>=1.0\npandas>=2.0\n")
            f.flush()

            result = build_requirements(file_path=f.name)
            assert result == "numpy>=1.0\npandas>=2.0\n"

            Path(f.name).unlink()

    def test_from_list(self):
        """Test building requirements from list."""
        result = build_requirements(reqs=["numpy>=1.0", "pandas>=2.0"])
        assert result == "numpy>=1.0\npandas>=2.0"

    def test_empty_when_none(self):
        """Test empty string when no arguments."""
        result = build_requirements()
        assert result == ""

    def test_both_raises_error(self):
        """Test providing both file_path and reqs raises error."""
        with pytest.raises(ValueError, match="cannot be specified together"):
            build_requirements(file_path="requirements.txt", reqs=["numpy"])

    def test_empty_list(self):
        """Test empty list returns empty string."""
        result = build_requirements(reqs=[])
        assert result == ""


class TestObjectToJson:
    """Test object_to_json function."""

    def test_none_returns_empty(self):
        """Test None returns empty string."""
        result = object_to_json(None)
        assert result == ""

    def test_dict_returns_json(self):
        """Test dict returns JSON string."""
        result = object_to_json({"key": "value", "num": 42})
        assert '"key"' in result
        assert '"value"' in result
        assert "42" in result

    def test_non_optuna_class_raises_error(self):
        """Test non-optuna class raises error."""

        class CustomClass:
            pass

        obj = CustomClass()
        with pytest.raises(ValueError, match="only optuna/aiauto core classes are supported"):
            object_to_json(obj)

    def test_empty_dict(self):
        """Test empty dict returns JSON."""
        result = object_to_json({})
        assert result == "{}"

    def test_aiauto_class_is_allowed(self):
        """Test aiauto.* module classes are allowed (not just optuna.*)."""
        sampler = ExhaustiveCategoricalSampler()
        result = object_to_json(sampler)
        parsed = json.loads(result)
        assert parsed["cls"] == "ExhaustiveCategoricalSampler"


_FROM_JSON_SAMPLER_WHITELIST = {
    "TPESampler": optuna.samplers.TPESampler,
    "RandomSampler": optuna.samplers.RandomSampler,
    "ExhaustiveCategoricalSampler": ExhaustiveCategoricalSampler,
}
_FROM_JSON_PRUNER_WHITELIST = {
    "MedianPruner": optuna.pruners.MedianPruner,
    "NopPruner": optuna.pruners.NopPruner,
    "PatientPruner": optuna.pruners.PatientPruner,
}


class TestFromJson:
    """Test from_json function."""

    def test_none_on_empty_string(self):
        """Empty string returns None (optional config not set)."""
        result = from_json("", _FROM_JSON_SAMPLER_WHITELIST)
        assert result is None

    def test_none_on_none_input(self):
        """None input returns None."""
        result = from_json(None, _FROM_JSON_SAMPLER_WHITELIST)
        assert result is None

    def test_deserialize_sampler(self):
        """Valid sampler JSON is correctly deserialized."""
        config = json.dumps({"cls": "RandomSampler", "kwargs": {"seed": 42}})
        result = from_json(config, _FROM_JSON_SAMPLER_WHITELIST)
        assert isinstance(result, optuna.samplers.RandomSampler)

    def test_deserialize_exhaustive_categorical_sampler(self):
        """ExhaustiveCategoricalSampler is correctly deserialized (no search_space in constructor)."""
        config = json.dumps({"cls": "ExhaustiveCategoricalSampler", "kwargs": {}})
        result = from_json(config, _FROM_JSON_SAMPLER_WHITELIST)
        assert isinstance(result, ExhaustiveCategoricalSampler)

    def test_deserialize_exhaustive_categorical_sampler_with_seed(self):
        """ExhaustiveCategoricalSampler with seed is correctly deserialized."""
        config = json.dumps({"cls": "ExhaustiveCategoricalSampler", "kwargs": {"seed": 42}})
        result = from_json(config, _FROM_JSON_SAMPLER_WHITELIST)
        assert isinstance(result, ExhaustiveCategoricalSampler)

    def test_class_not_in_whitelist_raises(self):
        """Class not in whitelist raises ValueError (security boundary)."""
        config = json.dumps({"cls": "BruteForceSampler", "kwargs": {}})
        with pytest.raises(ValueError, match="not in the whitelist"):
            from_json(config, _FROM_JSON_SAMPLER_WHITELIST)

    def test_missing_cls_field_raises(self):
        """JSON without 'cls' field raises ValueError."""
        config = json.dumps({"kwargs": {}})
        with pytest.raises(ValueError, match="'cls' not specified"):
            from_json(config, _FROM_JSON_SAMPLER_WHITELIST)

    def test_invalid_json_raises(self):
        """Malformed JSON raises json.JSONDecodeError (re-raised)."""
        with pytest.raises(json.JSONDecodeError):
            from_json("{invalid_json", _FROM_JSON_SAMPLER_WHITELIST)

    def test_patient_pruner_nested_deserialization(self):
        """PatientPruner.wrapped_pruner is recursively deserialized."""
        config = json.dumps(
            {
                "cls": "PatientPruner",
                "kwargs": {
                    "wrapped_pruner": {"cls": "MedianPruner", "kwargs": {"n_startup_trials": 3}},
                    "patience": 5,
                },
            }
        )
        result = from_json(config, _FROM_JSON_PRUNER_WHITELIST)
        assert isinstance(result, optuna.pruners.PatientPruner)
        assert isinstance(result._wrapped_pruner, optuna.pruners.MedianPruner)

    def test_patient_pruner_null_wrapped_pruner(self):
        """PatientPruner with null wrapped_pruner does not crash."""
        config = json.dumps(
            {
                "cls": "PatientPruner",
                "kwargs": {"wrapped_pruner": None, "patience": 5},
            }
        )
        result = from_json(config, _FROM_JSON_PRUNER_WHITELIST)
        assert isinstance(result, optuna.pruners.PatientPruner)

    def test_wrong_kwargs_raises_type_error(self):
        """Invalid kwargs for a class raises TypeError (re-raised)."""
        config = json.dumps({"cls": "NopPruner", "kwargs": {"nonexistent_param": 999}})
        with pytest.raises(TypeError):
            from_json(config, _FROM_JSON_PRUNER_WHITELIST)

    def test_depth_limit_exceeded_raises_value_error(self):
        """Nesting depth exceeding _FROM_JSON_MAX_DEPTH (3) raises ValueError.

        PatientPruner wrapping PatientPruner wrapping PatientPruner wrapping
        MedianPruner = depth 4, which exceeds the limit.
        This guards against malformed/malicious deeply nested JSON payloads.
        """
        # Build a 4-level deep PatientPruner chain (depth 0->1->2->3->4, limit is 3)
        innermost = {"cls": "MedianPruner", "kwargs": {}}
        depth3 = {"cls": "PatientPruner", "kwargs": {"wrapped_pruner": innermost, "patience": 1}}
        depth2 = {"cls": "PatientPruner", "kwargs": {"wrapped_pruner": depth3, "patience": 1}}
        depth1 = {"cls": "PatientPruner", "kwargs": {"wrapped_pruner": depth2, "patience": 1}}
        config = json.dumps(
            {"cls": "PatientPruner", "kwargs": {"wrapped_pruner": depth1, "patience": 1}}
        )
        with pytest.raises(ValueError, match="Maximum nesting depth"):
            from_json(config, _FROM_JSON_PRUNER_WHITELIST)

    def test_depth_limit_boundary_allowed(self):
        """Nesting exactly at _FROM_JSON_MAX_DEPTH (depth=3) does not raise.

        PatientPruner(PatientPruner(PatientPruner(MedianPruner))) = depth 3 calls,
        which is at the boundary and must succeed.
        """
        innermost = {"cls": "MedianPruner", "kwargs": {}}
        depth2 = {"cls": "PatientPruner", "kwargs": {"wrapped_pruner": innermost, "patience": 1}}
        depth1 = {"cls": "PatientPruner", "kwargs": {"wrapped_pruner": depth2, "patience": 1}}
        config = json.dumps(
            {"cls": "PatientPruner", "kwargs": {"wrapped_pruner": depth1, "patience": 1}}
        )
        result = from_json(config, _FROM_JSON_PRUNER_WHITELIST)
        assert isinstance(result, optuna.pruners.PatientPruner)

    def test_cmaes_sampler_with_independent_sampler_nested(self):
        """CmaEsSampler.independent_sampler is recursively deserialized via _depth+1 path.

        serializer.py의 from_json()에는 CmaEsSampler/QMCSampler의 independent_sampler를
        재귀 역직렬화하는 경로가 존재한다. 이 경로도 PatientPruner와 동일하게
        _depth+1로 호출되므로 depth guard가 적용된다.
        이 테스트는 해당 경로가 실제로 동작함을 검증한다.
        """
        _whitelist = {
            "CmaEsSampler": optuna.samplers.CmaEsSampler,
            "RandomSampler": optuna.samplers.RandomSampler,
        }
        config = json.dumps(
            {
                "cls": "CmaEsSampler",
                "kwargs": {
                    "n_startup_trials": 3,
                    "independent_sampler": {"cls": "RandomSampler", "kwargs": {"seed": 7}},
                },
            }
        )
        result = from_json(config, _whitelist)
        assert isinstance(result, optuna.samplers.CmaEsSampler)
        # independent_sampler가 RandomSampler로 역직렬화됐는지 확인
        assert isinstance(result._independent_sampler, optuna.samplers.RandomSampler)

    def test_qmc_sampler_with_independent_sampler_nested(self):
        """QMCSampler.independent_sampler is recursively deserialized via _depth+1 path.

        CmaEsSampler와 동일한 independent_sampler 재귀 경로를 QMCSampler에서도 검증한다.
        """
        _whitelist = {
            "QMCSampler": optuna.samplers.QMCSampler,
            "RandomSampler": optuna.samplers.RandomSampler,
        }
        config = json.dumps(
            {
                "cls": "QMCSampler",
                "kwargs": {
                    "qmc_type": "sobol",
                    "independent_sampler": {"cls": "RandomSampler", "kwargs": {"seed": 99}},
                },
            }
        )
        result = from_json(config, _whitelist)
        assert isinstance(result, optuna.samplers.QMCSampler)
        assert isinstance(result._independent_sampler, optuna.samplers.RandomSampler)
