"""Property-based tests for agent variant logic.

Feature: agent-variants

Uses Hypothesis to validate correctness properties of variant operations:
single-active invariant, clone field preservation, config resolution,
slug determinism, and partial update idempotence.

Requirements: 1.4, 2.1, 2.3, 3.4, 4.1, 4.3, 4.4, 7
Design: Correctness Properties 1–5, 7
"""
from __future__ import annotations

import copy
import re
import uuid
from typing import Any

from hypothesis import assume, given, settings
from hypothesis import strategies as st

from services.api.app import _slugify
from services.shared.agent_config import ResolvedAgentConfig

# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------

# Config fields that can be overridden in a variant
_CONFIG_FIELDS = [
    "model_provider",
    "model_name",
    "system_prompt",
    "user_prompt_template",
    "prompt_version",
    "temperature",
    "max_tokens",
    "context_window",
    "input_token_limit",
    "token_budget",
    "timeout_seconds",
    "max_retries",
]

_STR_FIELDS = [
    "model_provider",
    "model_name",
    "system_prompt",
    "user_prompt_template",
    "prompt_version",
]

_FLOAT_FIELDS = ["temperature"]

_INT_FIELDS = [
    "max_tokens",
    "context_window",
    "input_token_limit",
    "token_budget",
    "timeout_seconds",
    "max_retries",
]


def _config_value_strategy(field: str) -> st.SearchStrategy:
    """Generate a valid value for a given config field."""
    if field in _STR_FIELDS:
        return st.text(min_size=1, max_size=50, alphabet=st.characters(
            whitelist_categories=("L", "N", "P", "Z"),
        ))
    elif field in _FLOAT_FIELDS:
        return st.floats(min_value=0.0, max_value=2.0, allow_nan=False)
    elif field in _INT_FIELDS:
        return st.integers(min_value=0, max_value=100000)
    return st.text(min_size=1, max_size=20)


def _agent_config_strategy() -> st.SearchStrategy[dict[str, Any]]:
    """Generate a random agent configuration dict."""
    return st.fixed_dictionaries({
        "model_provider": st.sampled_from(["ollama", "openai", "anthropic"]),
        "model_name": st.text(min_size=1, max_size=30, alphabet=st.characters(
            whitelist_categories=("L", "N"),
        )),
        "system_prompt": st.text(min_size=0, max_size=100),
        "user_prompt_template": st.text(min_size=0, max_size=100),
        "prompt_version": st.text(min_size=0, max_size=20),
        "temperature": st.floats(min_value=0.0, max_value=2.0, allow_nan=False),
        "max_tokens": st.integers(min_value=1, max_value=100000),
        "context_window": st.integers(min_value=0, max_value=200000),
        "input_token_limit": st.integers(min_value=0, max_value=200000),
        "token_budget": st.integers(min_value=0, max_value=1000000),
        "timeout_seconds": st.integers(min_value=1, max_value=600),
        "max_retries": st.integers(min_value=0, max_value=10),
    })


def _variant_name_strategy() -> st.SearchStrategy[str]:
    """Generate random variant names with diverse characters."""
    return st.text(
        min_size=1,
        max_size=50,
        alphabet=st.characters(whitelist_categories=("L", "N", "P", "Z")),
    )


def _override_subset_strategy(
    source_config: dict[str, Any],
) -> st.SearchStrategy[dict[str, Any]]:
    """Generate a random subset of config field overrides."""
    # We build this as a composite strategy
    return st.fixed_dictionaries(
        {},
        optional={
            field: _config_value_strategy(field)
            for field in _CONFIG_FIELDS
        },
    )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _simulate_clone(
    source: dict[str, Any],
    overrides: dict[str, Any],
) -> dict[str, Any]:
    """Simulate the clone logic from the API: source fields + overrides.

    Mirrors the clone endpoint: for each config field, if an override is
    provided (not None), use it; otherwise use the source value.
    """
    result = {}
    for field in _CONFIG_FIELDS:
        if field in overrides and overrides[field] is not None:
            result[field] = overrides[field]
        else:
            result[field] = source[field]
    return result


def _simulate_activate_deactivate(
    variants: list[dict[str, Any]],
    operations: list[tuple[str, int]],
) -> list[dict[str, Any]]:
    """Simulate a sequence of activate/deactivate operations.

    operations: list of ("activate", variant_index) or ("deactivate", -1)
    Returns the final state of variants.
    """
    for op, idx in operations:
        if op == "activate" and 0 <= idx < len(variants):
            # Deactivate all first
            for v in variants:
                v["is_active"] = False
            # Activate the target
            variants[idx]["is_active"] = True
        elif op == "deactivate":
            # Deactivate all
            for v in variants:
                v["is_active"] = False
    return variants


# ---------------------------------------------------------------------------
# Property 1: Single active variant invariant
# ---------------------------------------------------------------------------


class TestProperty1SingleActiveVariantInvariant:
    """Feature: agent-variants, Property 1: Single active variant invariant

    For any sequence of activate/deactivate operations on variants of an
    agent, at most one variant per agent has is_active = TRUE at any point.

    **Validates: Requirements 1.4, 4.1**
    """

    @given(
        num_variants=st.integers(min_value=1, max_value=10),
        operations=st.lists(
            st.tuples(
                st.sampled_from(["activate", "deactivate"]),
                st.integers(min_value=-1, max_value=9),
            ),
            min_size=1,
            max_size=30,
        ),
    )
    @settings(max_examples=100)
    def test_at_most_one_active_after_each_operation(
        self,
        num_variants: int,
        operations: list[tuple[str, int]],
    ):
        """**Validates: Requirements 1.4, 4.1**

        After each activate/deactivate operation, count of active variants
        must be 0 or 1.
        """
        agent_id = str(uuid.uuid4())
        variants = [
            {
                "id": str(uuid.uuid4()),
                "agent_id": agent_id,
                "is_active": False,
            }
            for _ in range(num_variants)
        ]

        for op, idx in operations:
            if op == "activate" and 0 <= idx < num_variants:
                # Simulate transactional activate: deactivate all, then activate target
                for v in variants:
                    v["is_active"] = False
                variants[idx]["is_active"] = True
            elif op == "deactivate":
                for v in variants:
                    v["is_active"] = False

            # Invariant check after each operation
            active_count = sum(1 for v in variants if v["is_active"])
            assert active_count <= 1, (
                f"Invariant violated: {active_count} active variants after "
                f"operation ({op}, {idx})"
            )

    @given(
        num_variants=st.integers(min_value=2, max_value=8),
        activate_sequence=st.lists(
            st.integers(min_value=0, max_value=7),
            min_size=2,
            max_size=20,
        ),
    )
    @settings(max_examples=100)
    def test_rapid_activate_swaps_maintain_invariant(
        self,
        num_variants: int,
        activate_sequence: list[int],
    ):
        """**Validates: Requirements 1.4, 4.1**

        Rapidly activating different variants in sequence still maintains
        at most one active.
        """
        variants = [
            {"id": str(uuid.uuid4()), "is_active": False}
            for _ in range(num_variants)
        ]

        for idx in activate_sequence:
            target = idx % num_variants
            # Transactional swap
            for v in variants:
                v["is_active"] = False
            variants[target]["is_active"] = True

            active_count = sum(1 for v in variants if v["is_active"])
            assert active_count == 1
            assert variants[target]["is_active"] is True


# ---------------------------------------------------------------------------
# Property 2: Clone preserves unoverridden fields
# ---------------------------------------------------------------------------


class TestProperty2ClonePreservesUnoverriddenFields:
    """Feature: agent-variants, Property 2: Clone preserves unoverridden fields

    For any agent config and any subset of override fields, cloning produces
    a variant where overridden fields match the override values and
    non-overridden fields match the source.

    **Validates: Requirements 2.1, 2.3**
    """

    @given(
        source_config=_agent_config_strategy(),
        overrides=st.fixed_dictionaries(
            {},
            optional={
                field: _config_value_strategy(field)
                for field in _CONFIG_FIELDS
            },
        ),
    )
    @settings(max_examples=100)
    def test_overridden_fields_match_overrides(
        self,
        source_config: dict[str, Any],
        overrides: dict[str, Any],
    ):
        """**Validates: Requirements 2.1, 2.3**

        Fields present in overrides must have the override value in the clone.
        """
        result = _simulate_clone(source_config, overrides)

        for field in _CONFIG_FIELDS:
            if field in overrides:
                assert result[field] == overrides[field], (
                    f"Override field {field}: expected {overrides[field]}, "
                    f"got {result[field]}"
                )

    @given(
        source_config=_agent_config_strategy(),
        overrides=st.fixed_dictionaries(
            {},
            optional={
                field: _config_value_strategy(field)
                for field in _CONFIG_FIELDS
            },
        ),
    )
    @settings(max_examples=100)
    def test_non_overridden_fields_match_source(
        self,
        source_config: dict[str, Any],
        overrides: dict[str, Any],
    ):
        """**Validates: Requirements 2.1, 2.3**

        Fields NOT present in overrides must match the source config.
        """
        result = _simulate_clone(source_config, overrides)

        for field in _CONFIG_FIELDS:
            if field not in overrides:
                assert result[field] == source_config[field], (
                    f"Non-overridden field {field}: expected {source_config[field]}, "
                    f"got {result[field]}"
                )

    @given(source_config=_agent_config_strategy())
    @settings(max_examples=100)
    def test_clone_with_no_overrides_is_exact_copy(
        self,
        source_config: dict[str, Any],
    ):
        """**Validates: Requirements 2.1, 2.3**

        Cloning with no overrides produces an exact copy of all config fields.
        """
        result = _simulate_clone(source_config, {})

        for field in _CONFIG_FIELDS:
            assert result[field] == source_config[field], (
                f"Field {field} differs: {result[field]} != {source_config[field]}"
            )


# ---------------------------------------------------------------------------
# Property 3: Config resolution prefers active variant
# ---------------------------------------------------------------------------


class TestProperty3ConfigResolutionPrefersActiveVariant:
    """Feature: agent-variants, Property 3: Config resolution prefers active variant

    For any agent with N variants, config resolution returns the active
    variant's config when one exists, and the base agent config when none
    is active.

    **Validates: Requirements 4.3, 4.4**
    """

    @given(
        agent_config=_agent_config_strategy(),
        variant_configs=st.lists(
            _agent_config_strategy(),
            min_size=1,
            max_size=5,
        ),
        active_index=st.integers(min_value=0, max_value=4),
    )
    @settings(max_examples=100)
    def test_active_variant_config_is_returned(
        self,
        agent_config: dict[str, Any],
        variant_configs: list[dict[str, Any]],
        active_index: int,
    ):
        """**Validates: Requirements 4.3, 4.4**

        When an active variant exists, resolved config fields must match
        the active variant's values.
        """
        active_idx = active_index % len(variant_configs)
        active_variant = variant_configs[active_idx]

        # Simulate COALESCE resolution: variant fields preferred over agent
        resolved = {}
        for field in _CONFIG_FIELDS:
            # COALESCE(variant.field, agent.field) — variant always wins
            # when it has a value (which it always does in our model)
            resolved[field] = active_variant[field]

        for field in _CONFIG_FIELDS:
            assert resolved[field] == active_variant[field], (
                f"Field {field}: expected variant value {active_variant[field]}, "
                f"got {resolved[field]}"
            )

    @given(agent_config=_agent_config_strategy())
    @settings(max_examples=100)
    def test_no_active_variant_returns_agent_config(
        self,
        agent_config: dict[str, Any],
    ):
        """**Validates: Requirements 4.3, 4.4**

        When no active variant exists, resolved config fields must match
        the base agent's values.
        """
        # Simulate COALESCE with NULL variant: agent fields used
        resolved = {}
        for field in _CONFIG_FIELDS:
            resolved[field] = agent_config[field]

        for field in _CONFIG_FIELDS:
            assert resolved[field] == agent_config[field]

    @given(
        agent_config=_agent_config_strategy(),
        variant_config=_agent_config_strategy(),
        has_active=st.booleans(),
    )
    @settings(max_examples=100)
    def test_resolution_source_matches_active_state(
        self,
        agent_config: dict[str, Any],
        variant_config: dict[str, Any],
        has_active: bool,
    ):
        """**Validates: Requirements 4.3, 4.4**

        The resolver returns the correct source (variant or agent) based
        on whether an active variant exists.
        """
        if has_active:
            source = variant_config
            variant_id = str(uuid.uuid4())
        else:
            source = agent_config
            variant_id = None

        # Build a ResolvedAgentConfig to verify the dataclass works
        config = ResolvedAgentConfig(
            agent_id=str(uuid.uuid4()),
            variant_id=variant_id,
            model_provider=source["model_provider"],
            model_name=source["model_name"],
            system_prompt=source["system_prompt"],
            user_prompt_template=source["user_prompt_template"],
            prompt_version=source["prompt_version"],
            temperature=source["temperature"],
            max_tokens=source["max_tokens"],
            context_window=source["context_window"],
            input_token_limit=source["input_token_limit"],
            token_budget=source["token_budget"],
            timeout_seconds=source["timeout_seconds"],
            max_retries=source["max_retries"],
        )

        assert config.model_provider == source["model_provider"]
        assert config.model_name == source["model_name"]
        assert config.temperature == source["temperature"]
        assert config.max_tokens == source["max_tokens"]

        if has_active:
            assert config.variant_id is not None
        else:
            assert config.variant_id is None


# ---------------------------------------------------------------------------
# Property 4: Slug auto-generation determinism
# ---------------------------------------------------------------------------

_KEBAB_CASE_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")


class TestProperty4SlugAutoGenerationDeterminism:
    """Feature: agent-variants, Property 4: Slug auto-generation determinism

    For any variant_name, the auto-generated slug is deterministic,
    produces valid kebab-case, and is non-empty for non-empty input
    containing at least one alphanumeric character.

    **Validates: Requirements 2.4**
    """

    @given(name=_variant_name_strategy())
    @settings(max_examples=100)
    def test_slugify_is_deterministic(self, name: str):
        """**Validates: Requirements 2.4**

        Calling _slugify twice with the same name produces the same slug.
        """
        slug1 = _slugify(name)
        slug2 = _slugify(name)
        assert slug1 == slug2, (
            f"Non-deterministic: _slugify({name!r}) produced {slug1!r} and {slug2!r}"
        )

    @given(name=st.from_regex(r"[a-zA-Z0-9][\w\s\-]{0,49}", fullmatch=True))
    @settings(max_examples=100)
    def test_slugify_produces_valid_kebab_case(self, name: str):
        """**Validates: Requirements 2.4**

        The slug must be lowercase alphanumeric with hyphens, no leading
        or trailing hyphens.
        """
        slug = _slugify(name)
        assume(len(slug) > 0)

        # No leading or trailing hyphens
        assert not slug.startswith("-"), f"Slug starts with hyphen: {slug!r}"
        assert not slug.endswith("-"), f"Slug ends with hyphen: {slug!r}"

        # Only lowercase alphanumeric and hyphens
        assert _KEBAB_CASE_RE.match(slug), (
            f"Slug {slug!r} is not valid kebab-case (from name {name!r})"
        )

    @given(name=st.from_regex(r"[a-zA-Z0-9][\w\s]{0,49}", fullmatch=True))
    @settings(max_examples=100)
    def test_slugify_non_empty_for_alphanumeric_input(self, name: str):
        """**Validates: Requirements 2.4**

        For any name containing at least one alphanumeric character,
        the slug is non-empty.
        """
        slug = _slugify(name)
        assert len(slug) > 0, (
            f"Empty slug for name {name!r}"
        )

    @given(name=_variant_name_strategy())
    @settings(max_examples=100)
    def test_slugify_is_lowercase(self, name: str):
        """**Validates: Requirements 2.4**

        The slug must be entirely lowercase.
        """
        slug = _slugify(name)
        assert slug == slug.lower(), (
            f"Slug {slug!r} contains uppercase characters"
        )


# ---------------------------------------------------------------------------
# Property 5: Partial update idempotence
# ---------------------------------------------------------------------------


class TestProperty5PartialUpdateIdempotence:
    """Feature: agent-variants, Property 5: Partial update idempotence

    For any variant, applying a partial update twice produces the same
    variant state (excluding updated_at).

    **Validates: Requirements 3.4**
    """

    @given(
        base_config=_agent_config_strategy(),
        update_fields=st.fixed_dictionaries(
            {},
            optional={
                field: _config_value_strategy(field)
                for field in _CONFIG_FIELDS
            },
        ),
    )
    @settings(max_examples=100)
    def test_double_apply_produces_same_state(
        self,
        base_config: dict[str, Any],
        update_fields: dict[str, Any],
    ):
        """**Validates: Requirements 3.4**

        Applying the same partial update twice yields identical field values
        (excluding updated_at).
        """
        assume(len(update_fields) > 0)

        # First application
        state_after_first = copy.deepcopy(base_config)
        for field, value in update_fields.items():
            state_after_first[field] = value

        # Second application (same update on the result of the first)
        state_after_second = copy.deepcopy(state_after_first)
        for field, value in update_fields.items():
            state_after_second[field] = value

        # All config fields must match
        for field in _CONFIG_FIELDS:
            assert state_after_first[field] == state_after_second[field], (
                f"Field {field} differs after double apply: "
                f"{state_after_first[field]} != {state_after_second[field]}"
            )

    @given(
        base_config=_agent_config_strategy(),
        update_fields=st.fixed_dictionaries(
            {},
            optional={
                field: _config_value_strategy(field)
                for field in _CONFIG_FIELDS
            },
        ),
    )
    @settings(max_examples=100)
    def test_unchanged_fields_preserved_after_partial_update(
        self,
        base_config: dict[str, Any],
        update_fields: dict[str, Any],
    ):
        """**Validates: Requirements 3.4**

        Fields not included in the update must retain their original values.
        """
        updated = copy.deepcopy(base_config)
        for field, value in update_fields.items():
            updated[field] = value

        for field in _CONFIG_FIELDS:
            if field not in update_fields:
                assert updated[field] == base_config[field], (
                    f"Unchanged field {field} was modified: "
                    f"{base_config[field]} -> {updated[field]}"
                )

    @given(base_config=_agent_config_strategy())
    @settings(max_examples=100)
    def test_empty_update_is_noop(
        self,
        base_config: dict[str, Any],
    ):
        """**Validates: Requirements 3.4**

        An empty update (no fields) leaves all config fields unchanged.
        """
        updated = copy.deepcopy(base_config)
        # Apply empty update — no fields changed

        for field in _CONFIG_FIELDS:
            assert updated[field] == base_config[field]
