"""Tests for FeatureChainParserMixin."""

from typing import Optional

import pytest

from mloda.user import Feature
from mloda.user import FeatureName
from mloda.user import Options
from mloda.core.abstract_plugins.components.feature_chainer.feature_chain_parser_mixin import (
    FeatureChainParserMixin,
)
from mloda.provider import DefaultOptionKeys


class MockFeatureGroup(FeatureChainParserMixin):
    """Mock Feature group for testing the mixin with default settings."""

    PREFIX_PATTERN = r".*__([\w]+)_test$"
    PROPERTY_MAPPING = {
        "operation": {
            "op1": "Operation 1",
            "op2": "Operation 2",
            DefaultOptionKeys.context: True,
            DefaultOptionKeys.strict_validation: True,
        }
    }


class MockFeatureGroupCustomSeparator(FeatureChainParserMixin):
    """Mock Feature group with custom in_feature separator."""

    PREFIX_PATTERN = r".*__([\w]+)_test$"
    IN_FEATURE_SEPARATOR = ","
    PROPERTY_MAPPING = {
        "operation": {
            "op1": "Operation 1",
            "op2": "Operation 2",
            DefaultOptionKeys.context: True,
            DefaultOptionKeys.strict_validation: True,
        }
    }


class MockFeatureGroupWithMinMax(FeatureChainParserMixin):
    """Mock Feature group with min/max in_feature constraints."""

    PREFIX_PATTERN = r".*__([\w]+)_test$"
    MIN_IN_FEATURES = 2
    MAX_IN_FEATURES = 3
    PROPERTY_MAPPING = {
        "operation": {
            "op1": "Operation 1",
            DefaultOptionKeys.context: True,
            DefaultOptionKeys.strict_validation: True,
        }
    }


class _HiddenAttribute:
    """Descriptor that makes hasattr return False by raising AttributeError."""

    def __set_name__(self, owner: type, name: str) -> None:
        self.name = name

    def __get__(self, obj: object, objtype: Optional[type] = None) -> None:
        raise AttributeError(self.name)


class MockFeatureGroupWithoutMinMax(FeatureChainParserMixin):
    """Mock Feature group without MIN/MAX_IN_FEATURES to test hasattr guard."""

    PREFIX_PATTERN = r".*__([\w]+)_test$"
    MIN_IN_FEATURES = _HiddenAttribute()  # type: ignore[assignment]
    MAX_IN_FEATURES = _HiddenAttribute()
    PROPERTY_MAPPING = {
        "operation": {
            "op1": "Operation 1",
            DefaultOptionKeys.context: True,
            DefaultOptionKeys.strict_validation: True,
        }
    }


class MockFeatureGroupWithValidationHook(FeatureChainParserMixin):
    """Mock Feature group that implements _validate_string_match hook."""

    PREFIX_PATTERN = r".*__([\w]+)_test$"
    PROPERTY_MAPPING = {
        "operation": {
            "op1": "Operation 1",
            DefaultOptionKeys.context: True,
            DefaultOptionKeys.strict_validation: True,
        }
    }

    @classmethod
    def _validate_string_match(cls, feature_name: str, operation_config: str, source_feature: str) -> bool:
        """Hook to validate string-based matches - reject operation 'reject_me'."""
        return operation_config != "reject_me"


class TestFeatureChainParserMixinInputFeatures:
    """Tests for input_features() method."""

    def test_input_features_string_based_single_source(self) -> None:
        """Test that string-based parsing extracts single source feature."""
        feature_name = FeatureName("source_feature__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroup()
        result = mock_fg.input_features(options, feature_name)

        assert result is not None
        assert len(result) == 1
        assert Feature("source_feature") in result

    def test_input_features_string_based_multiple_sources(self) -> None:
        """Test parsing feature name with ampersand separator for multiple sources."""
        feature_name = FeatureName("point1&point2__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroup()
        result = mock_fg.input_features(options, feature_name)

        assert result is not None
        assert len(result) == 2
        assert Feature("point1") in result
        assert Feature("point2") in result

    def test_input_features_string_based_three_sources(self) -> None:
        """Test parsing feature name with three sources using ampersand separator."""
        feature_name = FeatureName("feat1&feat2&feat3__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroup()
        result = mock_fg.input_features(options, feature_name)

        assert result is not None
        assert len(result) == 3
        assert Feature("feat1") in result
        assert Feature("feat2") in result
        assert Feature("feat3") in result

    def test_input_features_config_based_fallback(self) -> None:
        """Test that when string parsing fails, fall back to options.get_in_features()."""
        feature_name = FeatureName("simple_name")
        options = Options(
            context={
                DefaultOptionKeys.in_features: ["feature_a", "feature_b"],
                "operation": "op1",
            }
        )

        mock_fg = MockFeatureGroup()
        result = mock_fg.input_features(options, feature_name)

        assert result is not None
        assert len(result) == 2
        assert Feature("feature_a") in result
        assert Feature("feature_b") in result

    def test_input_features_min_constraint_violated(self) -> None:
        """Test that ValueError is raised when fewer than min_source_features."""
        feature_name = FeatureName("single_feature__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroupWithMinMax()

        with pytest.raises(ValueError) as exc_info:
            mock_fg.input_features(options, feature_name)

        assert "minimum" in str(exc_info.value).lower() or "at least" in str(exc_info.value).lower()

    def test_input_features_max_constraint_violated(self) -> None:
        """Test that ValueError is raised when more than max_source_features."""
        feature_name = FeatureName("f1&f2&f3&f4__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroupWithMinMax()

        with pytest.raises(ValueError) as exc_info:
            mock_fg.input_features(options, feature_name)

        assert "maximum" in str(exc_info.value).lower() or "at most" in str(exc_info.value).lower()

    def test_input_features_within_min_max_constraints(self) -> None:
        """Test that parsing succeeds when source features are within min/max bounds."""
        feature_name = FeatureName("f1&f2__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroupWithMinMax()
        result = mock_fg.input_features(options, feature_name)

        assert result is not None
        assert len(result) == 2


class TestFeatureChainParserMixinMatchFeatureGroupCriteria:
    """Tests for match_feature_group_criteria() classmethod."""

    def test_match_feature_group_criteria_pattern_match(self) -> None:
        """Test that pattern-based matching returns True for valid feature names."""
        feature_name = FeatureName("source_feature__op1_test")
        options = Options(context={"operation": "op1"})

        result = MockFeatureGroup.match_feature_group_criteria(feature_name, options)

        assert result is True

    def test_match_feature_group_criteria_no_pattern_match_but_valid_config(self) -> None:
        """Test that config-based matching works even if pattern doesn't match.

        This matches the behavior of match_configuration_feature_chain_parser()
        which returns True for valid config even if pattern doesn't match.
        """
        feature_name = FeatureName("source_feature__invalid_suffix")
        options = Options(context={"operation": "op1"})

        result = MockFeatureGroup.match_feature_group_criteria(feature_name, options)

        # Config-based matching succeeds because operation is valid
        assert result is True

    def test_match_feature_group_criteria_config_based(self) -> None:
        """Test that config-based matching works when pattern doesn't match."""
        feature_name = FeatureName("any_name_without_pattern")
        options = Options(context={"operation": "op1"})

        result = MockFeatureGroup.match_feature_group_criteria(feature_name, options)

        assert result is True

    def test_match_feature_group_criteria_config_based_invalid_operation(self) -> None:
        """Test that config-based matching returns False with invalid operation value.

        When strict_validation is True in PROPERTY_MAPPING, invalid values return False
        instead of raising ValueError, enabling polymorphic feature group identification.
        """
        feature_name = FeatureName("any_name")
        options = Options(context={"operation": "invalid_op"})

        result = MockFeatureGroup.match_feature_group_criteria(feature_name, options)

        assert result is False

    def test_match_feature_group_criteria_missing_required_property(self) -> None:
        """Test that matching fails when required property is missing."""
        feature_name = FeatureName("any_name")
        options = Options(context={})

        result = MockFeatureGroup.match_feature_group_criteria(feature_name, options)

        assert result is False


class TestFeatureChainParserMixinValidateStringMatchHook:
    """Tests for _validate_string_match() hook."""

    def test_validate_string_match_hook_called_and_accepts(self) -> None:
        """Test that hook is called and accepts valid operations."""
        feature_name = FeatureName("source__op1_test")
        options = Options(context={"operation": "op1"})

        result = MockFeatureGroupWithValidationHook.match_feature_group_criteria(feature_name, options)

        assert result is True

    def test_validate_string_match_hook_called_and_rejects(self) -> None:
        """Test that hook is called and can reject based on custom logic."""
        feature_name = FeatureName("source__reject_me_test")
        options = Options(context={"operation": "reject_me"})

        result = MockFeatureGroupWithValidationHook.match_feature_group_criteria(feature_name, options)

        assert result is False

    def test_validate_string_match_hook_not_called_for_config_based(self) -> None:
        """Test that hook is not called for config-based matching."""
        feature_name = FeatureName("simple_name")
        options = Options(context={"operation": "op1"})

        # Should succeed via config-based matching without calling hook
        result = MockFeatureGroupWithValidationHook.match_feature_group_criteria(feature_name, options)

        assert result is True


class TestFeatureChainParserMixinCustomSeparator:
    """Tests for custom source feature separator."""

    def test_custom_source_feature_separator(self) -> None:
        """Test using comma as source feature separator instead of ampersand."""
        feature_name = FeatureName("feat1,feat2,feat3__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroupCustomSeparator()
        result = mock_fg.input_features(options, feature_name)

        assert result is not None
        assert len(result) == 3
        assert Feature("feat1") in result
        assert Feature("feat2") in result
        assert Feature("feat3") in result

    def test_custom_separator_does_not_parse_ampersand(self) -> None:
        """Test that ampersand is not treated as separator with custom separator."""
        feature_name = FeatureName("feat1&feat2__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroupCustomSeparator()
        result = mock_fg.input_features(options, feature_name)

        # Should treat "feat1&feat2" as a single feature name
        assert result is not None
        assert len(result) == 1
        assert Feature("feat1&feat2") in result


class TestFeatureChainParserMixinEdgeCases:
    """Tests for edge cases and error handling."""

    def test_input_features_empty_feature_name(self) -> None:
        """Test handling of empty feature name."""
        feature_name = FeatureName("__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroup()

        # Empty source feature should fall back to config-based
        with pytest.raises(ValueError):
            mock_fg.input_features(options, feature_name)

    def test_input_features_no_separator(self) -> None:
        """Test feature name without double underscore separator."""
        feature_name = FeatureName("simple_feature_name")
        options = Options(
            context={
                DefaultOptionKeys.in_features: ["fallback_feature"],
                "operation": "op1",
            }
        )

        mock_fg = MockFeatureGroup()
        result = mock_fg.input_features(options, feature_name)

        # Should fall back to config-based
        assert result is not None
        assert Feature("fallback_feature") in result

    def test_match_feature_group_criteria_with_string_feature_name(self) -> None:
        """Test that match_feature_group_criteria accepts string feature names."""
        feature_name_str = "source__op1_test"
        options = Options(context={"operation": "op1"})

        result = MockFeatureGroup.match_feature_group_criteria(feature_name_str, options)

        assert result is True

    def test_input_features_preserves_feature_order(self) -> None:
        """Test that order of features is preserved in parsing."""
        feature_name = FeatureName("first&second&third__op1_test")
        options = Options(context={"operation": "op1"})

        mock_fg = MockFeatureGroup()
        result = mock_fg.input_features(options, feature_name)

        assert result is not None
        assert len(result) == 3
        # Result is a set, so we just check all features are present
        feature_names = {f.name for f in result}
        assert feature_names == {"first", "second", "third"}


class TestFeatureChainParserMixinMinMaxInFeatures:
    """Tests for MIN_IN_FEATURES / MAX_IN_FEATURES enforcement in match_feature_group_criteria."""

    def test_rejects_too_few_in_features(self) -> None:
        """Config-based feature with fewer in_features than MIN_IN_FEATURES should not match."""
        options = Options(
            context={
                "operation": "op1",
                "in_features": "single_feature",
            }
        )
        # MockFeatureGroupWithMinMax has MIN_IN_FEATURES=2
        result = MockFeatureGroupWithMinMax.match_feature_group_criteria("any_name", options)
        assert result is False

    def test_rejects_too_many_in_features(self) -> None:
        """Config-based feature with more in_features than MAX_IN_FEATURES should not match."""
        options = Options(
            context={
                "operation": "op1",
                "in_features": frozenset({Feature("a"), Feature("b"), Feature("c"), Feature("d")}),
            }
        )
        # MockFeatureGroupWithMinMax has MAX_IN_FEATURES=3
        result = MockFeatureGroupWithMinMax.match_feature_group_criteria("any_name", options)
        assert result is False

    def test_accepts_in_features_within_range(self) -> None:
        """Config-based feature with in_features count within MIN/MAX should match."""
        options = Options(
            context={
                "operation": "op1",
                "in_features": frozenset({Feature("a"), Feature("b")}),
            }
        )
        # MockFeatureGroupWithMinMax has MIN=2, MAX=3
        result = MockFeatureGroupWithMinMax.match_feature_group_criteria("any_name", options)
        assert result is True

    def test_accepts_at_max_in_features(self) -> None:
        """Config-based feature with exactly MAX_IN_FEATURES should match."""
        options = Options(
            context={
                "operation": "op1",
                "in_features": frozenset({Feature("a"), Feature("b"), Feature("c")}),
            }
        )
        # MockFeatureGroupWithMinMax has MAX_IN_FEATURES=3
        result = MockFeatureGroupWithMinMax.match_feature_group_criteria("any_name", options)
        assert result is True

    def test_no_in_features_skips_validation(self) -> None:
        """When in_features is not in options, MIN/MAX validation is skipped."""
        options = Options(context={"operation": "op1"})
        # Should match based on config, no in_features to validate
        result = MockFeatureGroupWithMinMax.match_feature_group_criteria("any_name", options)
        assert result is True

    def test_default_min_max_allows_single(self) -> None:
        """Default MIN=1, MAX=None allows any count of in_features."""
        options = Options(
            context={
                "operation": "op1",
                "in_features": "single_feature",
            }
        )
        # MockFeatureGroup has default MIN=1, MAX=None
        result = MockFeatureGroup.match_feature_group_criteria("any_name", options)
        assert result is True

    def test_missing_min_max_attributes_skips_validation(self) -> None:
        """When MIN/MAX_IN_FEATURES attributes are absent, validation is skipped."""
        options = Options(
            context={
                "operation": "op1",
                "in_features": "single_feature",
            }
        )
        assert not hasattr(MockFeatureGroupWithoutMinMax, "MIN_IN_FEATURES")
        assert not hasattr(MockFeatureGroupWithoutMinMax, "MAX_IN_FEATURES")
        result = MockFeatureGroupWithoutMinMax.match_feature_group_criteria("any_name", options)
        assert result is True


class TestFeatureChainParserMixinListValuedOptions:
    """Tests for list-valued options in PROPERTY_MAPPING (issue #228)."""

    def test_match_feature_group_criteria_list_valued_option(self) -> None:
        """Test that match_feature_group_criteria works with list-valued options."""

        class ListValuedFeatureGroup(FeatureChainParserMixin):
            PROPERTY_MAPPING = {
                "partition_by": {
                    "explanation": "List of columns to partition by",
                    DefaultOptionKeys.context: True,
                    DefaultOptionKeys.strict_validation: False,
                },
            }

        options = Options(context={"partition_by": ["region", "category"]})
        result = ListValuedFeatureGroup.match_feature_group_criteria("my_feature", options)
        assert result is True

    def test_match_feature_group_criteria_tuple_valued_option(self) -> None:
        """Test that match_feature_group_criteria works with tuple-valued options."""

        class TupleValuedFeatureGroup(FeatureChainParserMixin):
            PROPERTY_MAPPING = {
                "partition_by": {
                    "explanation": "Columns to partition by",
                    DefaultOptionKeys.context: True,
                    DefaultOptionKeys.strict_validation: False,
                },
            }

        options = Options(context={"partition_by": ("region", "category")})
        result = TupleValuedFeatureGroup.match_feature_group_criteria("my_feature", options)
        assert result is True

    def test_list_valued_option_preserves_order(self) -> None:
        """Test that list-valued options preserve element order through tuple conversion."""
        from mloda.core.abstract_plugins.components.feature_chainer.feature_chain_parser import FeatureChainParser

        property_mapping = {
            "partition_by": {
                DefaultOptionKeys.context: True,
                DefaultOptionKeys.strict_validation: False,
            },
        }
        options = Options(context={"partition_by": ["col1", "col2", "col3"]})
        result = FeatureChainParser._validate_options_against_property_mapping(options, property_mapping)
        assert result is True

    def test_scalar_option_still_works(self) -> None:
        """Test that scalar (non-list) options still work after the fix."""
        options = Options(context={"operation": "op1"})
        result = MockFeatureGroup.match_feature_group_criteria("my_feature", options)
        assert result is True


class TestFeatureChainParserMixinExtractSourceFeatures:
    """Tests for _extract_source_features() classmethod."""

    def test_extract_source_features_string_based_single(self) -> None:
        """Test extraction from feature name with single source feature."""
        feature = Feature(
            name="source_feature__op1_test",
            options=Options(context={"operation": "op1"}),
        )

        result = MockFeatureGroup._extract_source_features(feature)

        assert result == ["source_feature"]

    def test_extract_source_features_string_based_multiple(self) -> None:
        """Test extraction from feature name with multiple source features (ampersand separator)."""
        feature = Feature(
            name="feat1&feat2&feat3__op1_test",
            options=Options(context={"operation": "op1"}),
        )

        result = MockFeatureGroup._extract_source_features(feature)

        assert result == ["feat1", "feat2", "feat3"]

    def test_extract_source_features_config_based_fallback(self) -> None:
        """Test that when string parsing fails, it falls back to feature.options.get_in_features()."""
        feature = Feature(
            name="simple_name",
            options=Options(
                context={
                    DefaultOptionKeys.in_features: ["feature_a", "feature_b"],
                    "operation": "op1",
                }
            ),
        )

        result = MockFeatureGroup._extract_source_features(feature)

        # Should return list of feature names from get_in_features()
        assert len(result) == 2
        assert "feature_a" in result
        assert "feature_b" in result

    def test_extract_source_features_custom_separator(self) -> None:
        """Test extraction with custom separator (comma instead of ampersand)."""
        feature = Feature(
            name="feat1,feat2,feat3__op1_test",
            options=Options(context={"operation": "op1"}),
        )

        result = MockFeatureGroupCustomSeparator._extract_source_features(feature)

        assert result == ["feat1", "feat2", "feat3"]
