#!/usr/bin/env python3
"""
Test Spatial Endpoint (No GGUF Required)

WO-ECHO-FIBPI3D-LOOP-01 Phase 1: Endpoint-only tests that validate
the /echo/spatial API without requiring an LLM model.

Tests:
1. All 7 query types work correctly
2. Scene determinism: same seed -> same scene_hash
3. Result determinism: same query twice -> identical results (excluding latency_us)
4. Error handling: invalid object names, missing parameters
5. Receipt generation: receipt_id present in all responses

No GGUF required. Just needs running basin_server.
"""

import json
import os
import pytest
import requests
from datetime import datetime, timezone
from pathlib import Path

# Configuration
ENDPOINT = os.environ.get("SPATIAL_ENDPOINT", "http://127.0.0.1:8001")
RECEIPTS_DIR = Path(__file__).parent.parent / "receipts" / "spatial_endpoint_tests"


def server_is_healthy():
    """Check if the server is running and healthy."""
    try:
        resp = requests.get(f"{ENDPOINT}/health", timeout=2)
        return resp.ok
    except requests.exceptions.RequestException:
        return False


# Skip all tests if server is not running
pytestmark = pytest.mark.skipif(
    not server_is_healthy(),
    reason=f"Echo server not running on {ENDPOINT}"
)


class TestQueryTypes:
    """Test all 7 spatial query types work correctly."""

    @pytest.fixture
    def scene_id(self):
        return "query_type_test"

    @pytest.fixture
    def scene_config(self):
        return {"n_objects": 100, "seed": "query_test_42"}

    def _make_query(self, scene_id, queries, scene_config=None):
        """Helper to make spatial queries."""
        payload = {
            "scene_id": scene_id,
            "queries": queries,
            "create_scene_if_missing": True,
        }
        if scene_config:
            payload["scene_config"] = scene_config
        return requests.post(f"{ENDPOINT}/echo/spatial", json=payload, timeout=10)

    def test_near_query(self, scene_id, scene_config):
        """Test 'near' query: Is distance(A, B) < threshold?"""
        resp = self._make_query(
            scene_id,
            [{"query_type": "near", "a": "obj_0000", "b": "obj_0001", "threshold": 100.0}],
            scene_config
        )
        assert resp.ok, f"near query failed: {resp.text}"

        data = resp.json()
        assert len(data["results"]) == 1
        result = data["results"][0]
        assert result["query_type"] == "near"
        assert result["error"] is None
        assert isinstance(result["result"], bool)

    def test_distance_query(self, scene_id, scene_config):
        """Test 'distance' query: Euclidean distance from A to B."""
        resp = self._make_query(
            scene_id,
            [{"query_type": "distance", "a": "obj_0000", "b": "obj_0001"}],
            scene_config
        )
        assert resp.ok, f"distance query failed: {resp.text}"

        data = resp.json()
        result = data["results"][0]
        assert result["query_type"] == "distance"
        assert result["error"] is None
        assert isinstance(result["result"], (int, float))
        assert result["result"] >= 0

    def test_k_nearest_query(self, scene_id, scene_config):
        """Test 'k_nearest' query: K closest objects to A."""
        resp = self._make_query(
            scene_id,
            [{"query_type": "k_nearest", "a": "obj_0000", "k": 5}],
            scene_config
        )
        assert resp.ok, f"k_nearest query failed: {resp.text}"

        data = resp.json()
        result = data["results"][0]
        assert result["query_type"] == "k_nearest"
        assert result["error"] is None
        assert isinstance(result["result"], list)
        assert len(result["result"]) == 5

        # Each result should be [name, distance]
        for neighbor in result["result"]:
            assert len(neighbor) == 2
            assert isinstance(neighbor[0], str)
            assert isinstance(neighbor[1], (int, float))

    def test_between_query(self, scene_id, scene_config):
        """Test 'between' query: Is B geometrically between A and C?"""
        resp = self._make_query(
            scene_id,
            [{
                "query_type": "between",
                "a": "obj_0000",
                "b": "obj_0050",
                "c": "obj_0099",
                "tolerance": 2.0
            }],
            scene_config
        )
        assert resp.ok, f"between query failed: {resp.text}"

        data = resp.json()
        result = data["results"][0]
        assert result["query_type"] == "between"
        assert result["error"] is None
        assert isinstance(result["result"], bool)

    def test_inside_query(self, scene_id, scene_config):
        """Test 'inside' query: Is A within radius of B?"""
        resp = self._make_query(
            scene_id,
            [{"query_type": "inside", "a": "obj_0000", "b": "obj_0001", "threshold": 100.0}],
            scene_config
        )
        assert resp.ok, f"inside query failed: {resp.text}"

        data = resp.json()
        result = data["results"][0]
        assert result["query_type"] == "inside"
        assert result["error"] is None
        assert isinstance(result["result"], bool)

    def test_left_of_query(self, scene_id, scene_config):
        """Test 'left_of' query: Is A.x < B.x?"""
        resp = self._make_query(
            scene_id,
            [{"query_type": "left_of", "a": "obj_0000", "b": "obj_0050"}],
            scene_config
        )
        assert resp.ok, f"left_of query failed: {resp.text}"

        data = resp.json()
        result = data["results"][0]
        assert result["query_type"] == "left_of"
        assert result["error"] is None
        assert isinstance(result["result"], bool)

    def test_above_query(self, scene_id, scene_config):
        """Test 'above' query: Is A.z > B.z?"""
        resp = self._make_query(
            scene_id,
            [{"query_type": "above", "a": "obj_0000", "b": "obj_0050"}],
            scene_config
        )
        assert resp.ok, f"above query failed: {resp.text}"

        data = resp.json()
        result = data["results"][0]
        assert result["query_type"] == "above"
        assert result["error"] is None
        assert isinstance(result["result"], bool)


class TestSceneDeterminism:
    """Test that same seed produces same scene."""

    def test_same_seed_same_hash(self):
        """Verify same seed produces identical scene_hash."""
        query = {
            "scene_id": "determinism_a",
            "queries": [{"query_type": "k_nearest", "a": "obj_0000", "k": 3}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "determinism_seed_42"}
        }

        resp1 = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        query["scene_id"] = "determinism_b"  # Different scene_id, same seed
        resp2 = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)

        assert resp1.ok and resp2.ok

        data1 = resp1.json()
        data2 = resp2.json()

        # Same seed should produce same hash
        assert data1["scene_hash"] == data2["scene_hash"], \
            "Same seed should produce identical scene_hash"

    def test_different_seed_different_hash(self):
        """Verify different seeds produce different scene_hash."""
        query1 = {
            "scene_id": "diff_seed_a",
            "queries": [{"query_type": "k_nearest", "a": "obj_0000", "k": 3}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "seed_alpha"}
        }
        query2 = {
            "scene_id": "diff_seed_b",
            "queries": [{"query_type": "k_nearest", "a": "obj_0000", "k": 3}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "seed_beta"}
        }

        resp1 = requests.post(f"{ENDPOINT}/echo/spatial", json=query1, timeout=10)
        resp2 = requests.post(f"{ENDPOINT}/echo/spatial", json=query2, timeout=10)

        assert resp1.ok and resp2.ok

        data1 = resp1.json()
        data2 = resp2.json()

        assert data1["scene_hash"] != data2["scene_hash"], \
            "Different seeds should produce different scene_hash"


class TestResultDeterminism:
    """Test that same query produces same results (excluding latency_us)."""

    def test_same_query_same_results(self):
        """Verify same query twice returns identical results."""
        query = {
            "scene_id": "result_determinism",
            "queries": [
                {"query_type": "k_nearest", "a": "obj_0000", "k": 5},
                {"query_type": "distance", "a": "obj_0010", "b": "obj_0050"},
                {"query_type": "near", "a": "obj_0020", "b": "obj_0025", "threshold": 5.0}
            ],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "result_seed"}
        }

        resp1 = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        resp2 = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)

        assert resp1.ok and resp2.ok

        data1 = resp1.json()
        data2 = resp2.json()

        # Compare results excluding latency_us
        def strip_latency(results):
            return [{k: v for k, v in r.items() if k != "latency_us"} for r in results]

        results1 = strip_latency(data1["results"])
        results2 = strip_latency(data2["results"])

        assert results1 == results2, "Same query should return identical results"


class TestErrorHandling:
    """Test error handling for invalid inputs."""

    def test_invalid_object_name(self):
        """Query with non-existent object name handles gracefully."""
        query = {
            "scene_id": "error_test",
            "queries": [{"query_type": "k_nearest", "a": "nonexistent_object", "k": 5}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 10, "seed": "error_seed"}
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.ok  # Should not crash

        data = resp.json()
        result = data["results"][0]
        # Should return empty result for non-existent object
        assert result["result"] == []

    def test_missing_b_for_near(self):
        """Near query without b parameter returns error."""
        query = {
            "scene_id": "missing_param_test",
            "queries": [{"query_type": "near", "a": "obj_0000", "threshold": 5.0}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 10, "seed": "missing_b_seed"}
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.ok  # Should not crash

        data = resp.json()
        result = data["results"][0]
        # Should have error for missing b parameter
        assert result["error"] is not None

    def test_missing_c_for_between(self):
        """Between query without c parameter returns error."""
        query = {
            "scene_id": "missing_c_test",
            "queries": [{
                "query_type": "between",
                "a": "obj_0000",
                "b": "obj_0050"
                # c is missing
            }],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "missing_c_seed"}
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.ok  # Should not crash

        data = resp.json()
        result = data["results"][0]
        # Should have error for missing c parameter
        assert result["error"] is not None

    def test_scene_not_found_no_create(self):
        """Query on non-existent scene without create flag returns 404."""
        query = {
            "scene_id": "nonexistent_scene_abc123",
            "queries": [{"query_type": "k_nearest", "a": "obj_0000", "k": 5}],
            "create_scene_if_missing": False
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.status_code == 404


class TestReceiptGeneration:
    """Test that receipt_id is present in all responses."""

    def test_receipt_id_present(self):
        """Verify receipt_id is present and non-empty."""
        query = {
            "scene_id": "receipt_test",
            "queries": [{"query_type": "k_nearest", "a": "obj_0000", "k": 3}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 50, "seed": "receipt_seed"}
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.ok

        data = resp.json()
        assert "receipt_id" in data, "receipt_id missing from response"
        assert data["receipt_id"], "receipt_id is empty"
        assert len(data["receipt_id"]) == 16, "receipt_id should be 16 hex chars"

    def test_receipt_id_unique_per_request(self):
        """Verify different requests get different receipt_ids."""
        query = {
            "scene_id": "unique_receipt_test",
            "queries": [{"query_type": "k_nearest", "a": "obj_0000", "k": 3}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 50, "seed": "unique_seed"}
        }

        resp1 = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        resp2 = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)

        assert resp1.ok and resp2.ok

        data1 = resp1.json()
        data2 = resp2.json()

        assert data1["receipt_id"] != data2["receipt_id"], \
            "Each request should get unique receipt_id"


class TestSeMetrics:
    """Test that Se metrics are included in responses."""

    def test_se_metrics_present(self):
        """Verify se_metrics are present in response."""
        query = {
            "scene_id": "se_metrics_test",
            "queries": [{"query_type": "k_nearest", "a": "obj_0000", "k": 5}],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "se_seed"}
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.ok

        data = resp.json()
        assert "se_metrics" in data, "se_metrics missing from response"

        se = data["se_metrics"]
        assert "H" in se, "H (entropy) missing"
        assert "C" in se, "C (coherence) missing"
        assert "D" in se, "D (depth) missing"
        assert "Se" in se, "Se (symbolic entropy) missing"
        assert "regime" in se, "regime missing"
        assert "classification_mode" in se, "classification_mode missing"

        # Verify types
        assert isinstance(se["H"], (int, float))
        assert isinstance(se["C"], (int, float))
        assert isinstance(se["D"], (int, float))
        assert isinstance(se["Se"], (int, float))


class TestBatchQueries:
    """Test batch query execution."""

    def test_multiple_queries_in_batch(self):
        """Verify multiple queries execute in single request."""
        query = {
            "scene_id": "batch_test",
            "queries": [
                {"query_type": "k_nearest", "a": "obj_0000", "k": 3},
                {"query_type": "distance", "a": "obj_0000", "b": "obj_0010"},
                {"query_type": "near", "a": "obj_0020", "b": "obj_0030", "threshold": 10.0},
                {"query_type": "left_of", "a": "obj_0005", "b": "obj_0015"},
                {"query_type": "above", "a": "obj_0025", "b": "obj_0035"}
            ],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "batch_seed"}
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.ok

        data = resp.json()
        assert len(data["results"]) == 5, "Should return 5 results for 5 queries"

        # All should succeed
        for i, result in enumerate(data["results"]):
            assert result["error"] is None, f"Query {i} failed: {result.get('error')}"


class TestFullReceipt:
    """Integration test: save full receipt for audit."""

    def test_save_spatial_test_receipt(self):
        """Save a comprehensive test receipt."""
        RECEIPTS_DIR.mkdir(parents=True, exist_ok=True)

        query = {
            "scene_id": "full_receipt_test",
            "queries": [
                {"query_type": "near", "a": "obj_0000", "b": "obj_0001", "threshold": 10.0},
                {"query_type": "distance", "a": "obj_0000", "b": "obj_0001"},
                {"query_type": "k_nearest", "a": "obj_0000", "k": 5},
                {"query_type": "between", "a": "obj_0000", "b": "obj_0050", "c": "obj_0099", "tolerance": 2.0},
                {"query_type": "inside", "a": "obj_0010", "b": "obj_0020", "threshold": 15.0},
                {"query_type": "left_of", "a": "obj_0005", "b": "obj_0015"},
                {"query_type": "above", "a": "obj_0025", "b": "obj_0035"}
            ],
            "create_scene_if_missing": True,
            "scene_config": {"n_objects": 100, "seed": "full_receipt_42"}
        }

        resp = requests.post(f"{ENDPOINT}/echo/spatial", json=query, timeout=10)
        assert resp.ok

        data = resp.json()

        # Build comprehensive receipt
        receipt = {
            "work_order": "WO-ECHO-FIBPI3D-LOOP-01",
            "test": "spatial_endpoint_full",
            "timestamp_utc": datetime.now(timezone.utc).isoformat(),
            "endpoint": f"{ENDPOINT}/echo/spatial",
            "request_summary": {
                "scene_id": query["scene_id"],
                "n_queries": len(query["queries"]),
                "query_types": [q["query_type"] for q in query["queries"]]
            },
            "response_summary": {
                "scene_id": data["scene_id"],
                "scene_hash": data["scene_hash"],
                "n_objects": data["n_objects"],
                "receipt_id": data["receipt_id"],
                "total_latency_ms": data["total_latency_ms"],
                "all_queries_succeeded": all(r.get("error") is None for r in data["results"]),
                "query_results": [
                    {
                        "query_type": r["query_type"],
                        "a": r["a"],
                        "b": r.get("b"),
                        "success": r.get("error") is None,
                        "result_type": type(r["result"]).__name__
                    }
                    for r in data["results"]
                ]
            },
            "se_metrics": data.get("se_metrics"),
            "status": "PASS" if all(r.get("error") is None for r in data["results"]) else "FAIL"
        }

        # Save receipt
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        receipt_path = RECEIPTS_DIR / f"spatial_endpoint_test_{timestamp}.json"
        receipt_path.write_text(json.dumps(receipt, indent=2))

        print(f"\nReceipt saved: {receipt_path}")
        print(f"Status: {receipt['status']}")
        print(f"Scene hash: {receipt['response_summary']['scene_hash'][:16]}...")

        # Assertions
        assert receipt["response_summary"]["all_queries_succeeded"]
        assert receipt["status"] == "PASS"


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