"""
Gaussian Model Tests: Concentration Field

Tests properties of Gaussian concentration field model.
Three-tier classification:

1. Universal Physical Laws (I1-I2):
   - Apply to ALL concentration field models
   - Non-negativity, Bounded

2. Quasi-Universal Laws (I3):
   - Usually true, but has exceptions
   - Maximum at source (assumes passive tracer, steady-state)

3. Gaussian Model Properties (I4-I7):
   - Specific to static Gaussian fields ONLY
   - Monotonic decay, radial symmetry, Gaussian formula
   - NOT universal - violated by turbulence, wind, etc.

Reference: contracts/concentration_field.md, CONTRACTS.md v1.1.0
"""

import numpy as np
import pytest
from hypothesis import assume, given, settings
from hypothesis import strategies as st

from plume_nav_sim.core.types import Coordinates
from plume_nav_sim.plume.gaussian import GaussianPlume

# ============================================================================
# Hypothesis Strategies
# ============================================================================

grid_sizes = st.integers(min_value=10, max_value=50)
sigmas = st.floats(min_value=0.5, max_value=5.0)


# ============================================================================
# Helper: Create Concentration Field
# ============================================================================


def create_test_field(grid_size, source_location, sigma):
    """Create a Gaussian plume for testing."""
    sx, sy = source_location
    return GaussianPlume(
        grid_size=(grid_size, grid_size),
        source_location=Coordinates(int(sx), int(sy)),
        sigma=sigma,
    )


# ============================================================================
# UNIVERSAL PHYSICAL LAWS (All Concentration Fields)
# ============================================================================

# ============================================================================
# Physical Law I1: Non-Negativity
# ============================================================================


class TestPhysicalLawNonNegativity:
    """I1: ∀ (x, y) ∈ Grid: field[x, y] >= 0

    Contract: concentration_field.md - Universal Physical Law

    UNIVERSAL: Concentration represents particle count, cannot be negative.
    Applies to all concentration field models.
    """

    @given(grid_size=grid_sizes, sigma=sigmas)
    @settings(max_examples=50)
    def test_all_values_non_negative(self, grid_size, sigma):
        """All field values must be non-negative.

        Physical law: Concentration is a count of particles,
        cannot be negative.
        """
        source = (grid_size // 2, grid_size // 2)
        field = create_test_field(grid_size, source, sigma)

        # Check entire field
        assert np.all(
            field.field_array >= 0
        ), f"Found negative concentrations: min={np.min(field.field_array)}"

    def test_non_negativity_at_all_positions(self):
        """Sample at every position, all should be non-negative."""
        field = create_test_field(32, (16, 16), 2.0)

        for x in range(32):
            for y in range(32):
                value = field.sample(x, y)
                assert value >= 0, f"Negative concentration at ({x},{y}): {value}"


# ============================================================================
# Physical Law I2: Bounded
# ============================================================================


class TestPhysicalLawBounded:
    """I2: ∀ (x, y) ∈ Grid: field[x, y] <= 1.0

    Contract: concentration_field.md - Universal Physical Law

    UNIVERSAL: Normalized concentration cannot exceed 1.0.
    Applies to all concentration field models (after normalization).
    """

    @given(grid_size=grid_sizes, sigma=sigmas)
    @settings(max_examples=50)
    def test_all_values_bounded(self, grid_size, sigma):
        """All field values must be <= 1.0.

        Field is normalized so maximum concentration is 1.0.
        """
        source = (grid_size // 2, grid_size // 2)
        field = create_test_field(grid_size, source, sigma)

        # Check entire field
        assert np.all(
            field.field_array <= 1.0
        ), f"Found values > 1.0: max={np.max(field.field_array)}"

    def test_bounded_at_all_positions(self):
        """Sample at every position, all should be <= 1.0."""
        field = create_test_field(32, (16, 16), 2.0)

        for x in range(32):
            for y in range(32):
                value = field.sample(x, y)
                assert value <= 1.0, f"Concentration at ({x},{y}) exceeds 1.0: {value}"


# ============================================================================
# QUASI-UNIVERSAL LAWS (Usually True, Has Exceptions)
# ============================================================================

# ============================================================================
# Quasi-Universal Law I3: Maximum at Source
# ============================================================================


class TestQuasiUniversalMaximumAtSource:
    """I3: ∀ (x, y) ≠ source: field[source] >= field[x, y]

    Contract: concentration_field.md - Quasi-Universal Law

    QUASI-UNIVERSAL: Usually true, but can be violated by:
    - Turbulent accumulation zones
    - Reactive chemistry (products form downstream)
    - Stochastic particle models (instantaneous snapshots)
    - Boundary reflection effects

    Assumes: Passive tracer, steady-state, continuous emission.
    Holds for our static Gaussian model.
    """

    @given(grid_size=grid_sizes, sigma=sigmas)
    @settings(max_examples=50)
    def test_maximum_at_source(self, grid_size, sigma):
        """Source has maximum concentration.

        Physical law: Plume emanates from source,
        concentration decreases away from it.
        """
        source = (grid_size // 2, grid_size // 2)
        field = create_test_field(grid_size, source, sigma)

        source_value = field.sample(*source)

        # Check all other positions
        for x in range(grid_size):
            for y in range(grid_size):
                if (x, y) != source:
                    value = field.sample(x, y)
                    assert (
                        value <= source_value
                    ), f"Field at ({x},{y})={value:.6f} > source {source_value:.6f}"

    def test_source_value_approximately_one(self):
        """Source concentration should be approximately 1.0.

        For Gaussian: field[source] = exp(0) = 1.0
        """
        field = create_test_field(32, (16, 16), 2.0)
        source_value = field.sample(16, 16)

        assert (
            abs(source_value - 1.0) < 1e-6
        ), f"Source value should be ~1.0, got {source_value}"

    @given(
        grid_size=grid_sizes,
        source_x=st.integers(1, 8),  # Keep away from edges
        source_y=st.integers(1, 8),
        sigma=sigmas,
    )
    @settings(max_examples=30)
    def test_maximum_at_arbitrary_source(self, grid_size, source_x, source_y, sigma):
        """Maximum holds for any source position."""
        assume(source_x < grid_size - 1 and source_y < grid_size - 1)

        source = (source_x, source_y)
        field = create_test_field(grid_size, source, sigma)

        source_value = field.sample(*source)
        max_value = np.max(field.field_array)

        assert (
            abs(source_value - max_value) < 1e-10
        ), f"Source {source_value} != max {max_value}"


# ============================================================================
# GAUSSIAN MODEL PROPERTIES (Static Gaussian Fields Only)
# ============================================================================

# ============================================================================
# Gaussian Property I4: Monotonic Radial Decay
# ============================================================================


class TestGaussianMonotonicDecay:
    """I4: d(p₁, source) <= d(p₂, source) ⇒ field[p₁] >= field[p₂]

    Contract: concentration_field.md - Gaussian Model Property

    GAUSSIAN-SPECIFIC: Monotonic decay with distance from source.
    NOT universal - real plumes with turbulence, wind, obstacles violate this.
    Only holds for radially symmetric models (static Gaussian).
    """

    def test_monotonic_decay_along_axis(self):
        """Moving away from source, concentration decreases."""
        field = create_test_field(32, (16, 16), 2.0)

        # Walk away from source along x-axis
        prev_value = field.sample(16, 16)  # At source

        for x in range(17, 32):
            current_value = field.sample(x, 16)
            assert (
                current_value <= prev_value
            ), f"Concentration increased: {prev_value} -> {current_value} at x={x}"
            prev_value = current_value

    def test_monotonic_decay_radial(self):
        """Moving radially outward, concentration decreases."""
        field = create_test_field(32, (16, 16), 2.0)

        # Sample at increasing distances
        samples = [
            ((16, 16), 0),  # At source
            ((17, 16), 1),  # Distance 1
            ((18, 16), 2),  # Distance 2
            ((19, 16), 3),  # Distance 3
            ((20, 16), 4),  # Distance 4
        ]

        prev_value = float("inf")
        for pos, expected_dist in samples:
            value = field.sample(*pos)
            assert (
                value <= prev_value
            ), f"Concentration increased at distance {expected_dist}"
            prev_value = value

    @given(grid_size=grid_sizes, sigma=sigmas, n_samples=st.integers(5, 15))
    @settings(max_examples=30)
    def test_monotonic_decay_property(self, grid_size, sigma, n_samples):
        """Property: farther positions have lower concentration."""
        source_pos = (grid_size // 2, grid_size // 2)
        field = create_test_field(grid_size, source_pos, sigma)
        source = Coordinates(*source_pos)

        # Generate random positions
        positions = []
        for _ in range(n_samples):
            x = np.random.randint(0, grid_size)
            y = np.random.randint(0, grid_size)
            pos = Coordinates(x, y)
            distance = pos.distance_to(source)
            concentration = field.sample(x, y)
            positions.append((distance, concentration))

        # Sort by distance
        positions.sort(key=lambda p: p[0])

        # Check monotonic: farther should have lower concentration
        for i in range(len(positions) - 1):
            d1, c1 = positions[i]
            d2, c2 = positions[i + 1]

            if d1 < d2:  # Strictly farther
                # Allow small tolerance for numerical precision
                assert (
                    c1 >= c2 - 1e-10
                ), f"Monotonicity violated: d1={d1:.2f} c1={c1:.6f}, d2={d2:.2f} c2={c2:.6f}"


# ============================================================================
# Gaussian Property I5: Radial Symmetry
# ============================================================================


class TestGaussianRadialSymmetry:
    """I5: field[source + Δ] ≈ field[source - Δ]

    Contract: concentration_field.md - Gaussian Model Property

    GAUSSIAN-SPECIFIC: Concentration symmetric around source.
    NOT universal - real plumes with wind, anisotropic diffusion violate this.
    Only holds for isotropic models (static Gaussian, no wind).
    """

    def test_symmetry_horizontal(self):
        """Symmetric horizontally around source."""
        field = create_test_field(32, (16, 16), 2.0)

        # Check pairs at same distance
        for delta in range(1, 10):
            left = field.sample(16 - delta, 16)
            right = field.sample(16 + delta, 16)

            assert np.isclose(
                left, right, rtol=1e-5
            ), f"Asymmetry at delta={delta}: left={left}, right={right}"

    def test_symmetry_vertical(self):
        """Symmetric vertically around source."""
        field = create_test_field(32, (16, 16), 2.0)

        for delta in range(1, 10):
            up = field.sample(16, 16 - delta)
            down = field.sample(16, 16 + delta)

            assert np.isclose(
                up, down, rtol=1e-5
            ), f"Asymmetry at delta={delta}: up={up}, down={down}"

    def test_symmetry_diagonal(self):
        """Symmetric diagonally around source."""
        field = create_test_field(32, (16, 16), 2.0)

        deltas = [(1, 1), (2, 2), (3, 3), (2, 3), (3, 2)]

        for dx, dy in deltas:
            pos1 = field.sample(16 + dx, 16 + dy)
            pos2 = field.sample(16 - dx, 16 - dy)
            pos3 = field.sample(16 + dx, 16 - dy)
            pos4 = field.sample(16 - dx, 16 + dy)

            # All four quadrants should have same value
            assert np.isclose(
                pos1, pos2, rtol=1e-5
            ), f"Asymmetry: (+{dx},+{dy})={pos1} vs (-{dx},-{dy})={pos2}"
            assert np.isclose(
                pos1, pos3, rtol=1e-5
            ), f"Asymmetry: (+{dx},+{dy})={pos1} vs (+{dx},-{dy})={pos3}"
            assert np.isclose(
                pos1, pos4, rtol=1e-5
            ), f"Asymmetry: (+{dx},+{dy})={pos1} vs (-{dx},+{dy})={pos4}"


# ============================================================================
# Invariant I6: Shape Consistency
# ============================================================================


class TestInvariantShapeConsistency:
    """I6: field.shape = (grid_size.height, grid_size.width)

    Contract: concentration_field.md - Invariant I6
    Array dimensions match grid dimensions.
    """

    @given(width=st.integers(10, 50), height=st.integers(10, 50), sigma=sigmas)
    @settings(max_examples=50)
    def test_shape_matches_grid(self, width, height, sigma):
        """Field shape must match grid dimensions."""
        # Use same width/height for simplicity in our test helper
        size = min(width, height)
        source = (size // 2, size // 2)
        field = create_test_field(size, source, sigma)

        assert field.field_array.shape == (
            size,
            size,
        ), f"Shape mismatch: {field.field_array.shape} != ({size}, {size})"

    def test_indexing_consistency(self):
        """Field indexing matches grid coordinates."""
        field = create_test_field(32, (16, 16), 2.0)

        # Spot check: field[y, x] == sample(x, y)
        for x, y in [(0, 0), (15, 15), (31, 31), (10, 20)]:
            direct = field.field_array[y, x]  # Note: numpy is [row, col] = [y, x]
            sampled = field.sample(x, y)

            assert (
                direct == sampled
            ), f"Indexing mismatch at ({x},{y}): direct={direct}, sampled={sampled}"


# ============================================================================
# Gaussian Property I7: Gaussian Formula
# ============================================================================


class TestGaussianFormula:
    """I7: field[x, y] = exp(-distance²/(2σ²))

    Contract: concentration_field.md - Gaussian Model Property

    GAUSSIAN-SPECIFIC: Field follows Gaussian distribution formula.
    Obviously model-specific - other plume models use different equations.
    """

    def test_gaussian_formula_at_sample_points(self):
        """Field values match Gaussian formula."""
        sigma = 3.0
        field = create_test_field(32, (16, 16), sigma)
        source = Coordinates(16, 16)

        # Test at multiple positions
        test_positions = [
            (16, 16),  # At source
            (17, 16),  # Distance 1
            (19, 16),  # Distance 3
            (16, 19),  # Distance 3
            (19, 19),  # Distance sqrt(18) ≈ 4.24
        ]

        for x, y in test_positions:
            pos = Coordinates(x, y)
            distance = pos.distance_to(source)

            # Expected Gaussian value
            expected = np.exp(-(distance**2) / (2 * sigma**2))

            # Actual field value
            actual = field.sample(x, y)

            assert np.isclose(
                actual, expected, rtol=1e-6
            ), f"Gaussian mismatch at ({x},{y}): expected={expected:.6f}, actual={actual:.6f}"

    @given(grid_size=grid_sizes, sigma=sigmas)
    @settings(max_examples=30)
    def test_gaussian_formula_property(self, grid_size, sigma):
        """All field values match Gaussian formula (within tolerance)."""
        source_pos = (grid_size // 2, grid_size // 2)
        field = create_test_field(grid_size, source_pos, sigma)
        source = Coordinates(*source_pos)

        # Sample random positions
        for _ in range(20):
            x = np.random.randint(0, grid_size)
            y = np.random.randint(0, grid_size)

            pos = Coordinates(x, y)
            distance = pos.distance_to(source)

            expected = np.exp(-(distance**2) / (2 * sigma**2))
            actual = field.sample(x, y)

            assert np.isclose(
                actual, expected, rtol=1e-6
            ), f"Gaussian formula violated at ({x},{y})"


# ============================================================================
# Determinism
# ============================================================================


class TestFieldDeterminism:
    """Field generation must be deterministic.

    Contract: concentration_field.md
    Same parameters → identical fields.
    """

    @given(grid_size=grid_sizes, sigma=sigmas)
    @settings(max_examples=30)
    def test_generation_deterministic(self, grid_size, sigma):
        """Same params → identical fields."""
        source = (grid_size // 2, grid_size // 2)

        field1 = create_test_field(grid_size, source, sigma)
        field2 = create_test_field(grid_size, source, sigma)

        assert np.allclose(
            field1.field_array, field2.field_array
        ), "Same parameters produced different fields"

    def test_sampling_deterministic(self):
        """Sampling same position gives same value."""
        field = create_test_field(32, (16, 16), 2.0)

        value1 = field.sample(10, 15)
        value2 = field.sample(10, 15)
        value3 = field.sample(10, 15)

        assert value1 == value2 == value3, "Sampling not deterministic"


# ============================================================================
# Edge Cases
# ============================================================================


class TestFieldEdgeCases:
    """Test edge cases for field generation."""

    def test_source_at_corner(self):
        """Source at grid corner."""
        field = create_test_field(32, (0, 0), 2.0)

        # Should still satisfy invariants
        assert field.sample(0, 0) >= 0
        assert field.sample(0, 0) <= 1.0

        # Max should be at corner
        assert field.sample(0, 0) == np.max(field.field_array)

    def test_source_at_edge(self):
        """Source at grid edge."""
        field = create_test_field(32, (0, 16), 2.0)

        assert field.sample(0, 16) >= 0
        assert field.sample(0, 16) <= 1.0

    def test_small_sigma(self):
        """Small sigma creates sharp peak."""
        field = create_test_field(32, (16, 16), 0.5)

        # Source should be high
        assert field.sample(16, 16) > 0.9

        # Nearby should drop quickly
        nearby = field.sample(18, 16)  # 2 units away
        assert nearby < 0.1, f"Small sigma should create sharp peak, got {nearby}"

    def test_large_sigma(self):
        """Large sigma creates flatter field (less steep falloff)."""
        # Use larger sigma relative to grid for truly flat field
        field = create_test_field(32, (16, 16), 20.0)

        # Field should be relatively uniform
        corner = field.sample(0, 0)
        center = field.sample(16, 16)

        # With sigma=20 and distance ~22.6, expect corner ≈ e^(-22.6²/(2*20²)) ≈ 0.47
        # Use threshold of 0.3 (30% of center) which is achievable
        assert (
            corner > 0.3 * center
        ), f"Large sigma should be flatter: corner={corner:.4f}, center={center}"


if __name__ == "__main__":
    pytest.main([__file__, "-v", "--hypothesis-show-statistics"])
