"""Property-based tests using Hypothesis.

These tests verify fundamental properties of utility functions using
property-based testing to catch edge cases and ensure correctness.
"""

from __future__ import annotations

from typing import Any, Dict

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

from py.utils.cache_paths import _sanitize_library_name
from py.utils.file_utils import get_preview_extension, normalize_path
from py.utils.model_utils import determine_base_model
from py.utils.utils import (
    calculate_recipe_fingerprint,
    fuzzy_match,
    sanitize_folder_name,
)


class TestSanitizeFolderName:
    """Property-based tests for sanitize_folder_name function."""

    @given(st.text(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- '))
    def test_sanitize_is_idempotent_for_ascii(self, name: str):
        """Sanitizing an already sanitized ASCII name should not change it."""
        sanitized = sanitize_folder_name(name)
        resanitized = sanitize_folder_name(sanitized)
        assert sanitized == resanitized

    @given(st.text())
    def test_sanitize_never_contains_invalid_chars(self, name: str):
        """Sanitized names should never contain filesystem-invalid characters."""
        sanitized = sanitize_folder_name(name)
        invalid_chars = '<>:"/\\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08'
        for char in invalid_chars:
            assert char not in sanitized

    @given(st.text())
    def test_sanitize_never_returns_none(self, name: str):
        """Sanitize should never return None (always returns a string)."""
        result = sanitize_folder_name(name)
        assert result is not None
        assert isinstance(result, str)

    @given(st.text(min_size=1))
    def test_sanitize_preserves_some_content(self, name: str):
        """Sanitizing a non-empty string should not produce an empty result
        unless the input was only invalid characters."""
        result = sanitize_folder_name(name)
        # If input had valid characters, output should not be empty
        has_valid_chars = any(c.isalnum() or c in '._-' for c in name)
        if has_valid_chars:
            assert result != ""


class TestSanitizeLibraryName:
    """Property-based tests for _sanitize_library_name function."""

    @given(st.text() | st.none())
    def test_sanitize_library_name_is_idempotent(self, library_name: str | None):
        """Sanitizing an already sanitized library name should not change it."""
        sanitized = _sanitize_library_name(library_name)
        resanitized = _sanitize_library_name(sanitized)
        assert sanitized == resanitized

    @given(st.text())
    def test_sanitize_library_name_only_contains_safe_chars(self, library_name: str):
        """Sanitized library names should only contain safe filename characters."""
        sanitized = _sanitize_library_name(library_name)
        # Should only contain alphanumeric, underscore, dot, and hyphen
        for char in sanitized:
            assert char.isalnum() or char in '._-'


class TestNormalizePath:
    """Property-based tests for normalize_path function."""

    @given(st.text(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/\\') | st.none())
    def test_normalize_path_is_idempotent_for_ascii(self, path: str | None):
        """Normalizing an already normalized ASCII path should not change it."""
        if path is None:
            return
        normalized = normalize_path(path)
        renormalized = normalize_path(normalized)
        assert normalized == renormalized

    @given(st.text())
    def test_normalized_path_returns_string(self, path: str):
        """Normalized path should always return a string (or None)."""
        normalized = normalize_path(path)
        # Result is either None or a string
        assert normalized is None or isinstance(normalized, str)


class TestFuzzyMatch:
    """Property-based tests for fuzzy_match function."""

    @given(st.text(), st.text())
    def test_fuzzy_match_empty_pattern_returns_false(self, text: str, pattern: str):
        """Empty pattern should never match (except empty text with exact match)."""
        if not pattern:
            result = fuzzy_match(text, pattern)
            assert result is False

    @given(st.text(min_size=1), st.text(min_size=1))
    def test_fuzzy_match_exact_substring_always_matches(self, text: str, pattern: str):
        """If pattern is a substring of text (case-insensitive), it should match."""
        # Create a case where pattern is definitely in text
        combined = text.lower() + " " + pattern.lower()
        result = fuzzy_match(combined, pattern.lower())
        assert result is True

    @given(st.text(min_size=1), st.text(min_size=1))
    def test_fuzzy_match_substring_always_matches(self, text: str, pattern: str):
        """If pattern is a substring of text, it should always match."""
        if pattern in text:
            result = fuzzy_match(text, pattern)
            assert result is True


class TestDetermineBaseModel:
    """Property-based tests for determine_base_model function."""

    @given(st.text() | st.none())
    def test_determine_base_model_never_returns_none(self, version_string: str | None):
        """Function should never return None (always returns a string)."""
        result = determine_base_model(version_string)
        assert result is not None
        assert isinstance(result, str)

    @given(st.text())
    def test_determine_base_model_case_insensitive(self, version: str):
        """Base model detection should be case-insensitive."""
        lower_result = determine_base_model(version.lower())
        upper_result = determine_base_model(version.upper())
        # Results should be the same for known mappings
        if version.lower() in ['sdxl', 'sd_1.5', 'pony', 'flux1']:
            assert lower_result == upper_result


class TestGetPreviewExtension:
    """Property-based tests for get_preview_extension function."""

    @given(st.text())
    def test_get_preview_extension_returns_string(self, preview_path: str):
        """Function should always return a string."""
        result = get_preview_extension(preview_path)
        assert isinstance(result, str)

    @given(st.text(alphabet='abcdefghijklmnopqrstuvwxyz._'))
    def test_get_preview_extension_starts_with_dot(self, preview_path: str):
        """Extension should always start with a dot for valid paths."""
        if '.' in preview_path:
            result = get_preview_extension(preview_path)
            if result:
                assert result.startswith('.')


class TestCalculateRecipeFingerprint:
    """Property-based tests for calculate_recipe_fingerprint function."""

    @given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=0, max_size=50))
    def test_fingerprint_is_deterministic(self, loras: list[Dict[str, Any]]):
        """Same input should always produce same fingerprint."""
        fp1 = calculate_recipe_fingerprint(loras)
        fp2 = calculate_recipe_fingerprint(loras)
        assert fp1 == fp2

    @given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=0, max_size=50))
    def test_fingerprint_returns_string(self, loras: list[Dict[str, Any]]):
        """Function should always return a string."""
        result = calculate_recipe_fingerprint(loras)
        assert isinstance(result, str)

    def test_fingerprint_empty_list_returns_empty_string(self):
        """Empty list should return empty string."""
        result = calculate_recipe_fingerprint([])
        assert result == ""

    @given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=1, max_size=10))
    def test_fingerprint_different_inputs_produce_different_results(self, loras1: list[Dict[str, Any]]):
        """Different inputs should generally produce different fingerprints."""
        # Create a different input by modifying the first LoRA
        loras2 = loras1.copy()
        if loras2:
            loras2[0] = {**loras2[0], 'hash': 'different_hash_12345'}
        
        fp1 = calculate_recipe_fingerprint(loras1)
        fp2 = calculate_recipe_fingerprint(loras2)
        
        # If the first LoRA had a hash, fingerprints should differ
        if loras1 and loras1[0].get('hash'):
            assert fp1 != fp2
