"""Tests for API endpoint edge cases.

Tests input validation, auth boundaries, org isolation, pagination limits,
empty inputs, maximum-length inputs, and invalid types.
Uses the same client fixture pattern as existing test_api_endpoints.py.
"""

from __future__ import annotations

import uuid
from unittest.mock import patch


class TestRunCreateValidation:
    """Tests for run creation endpoint input validation."""

    def test_create_run_missing_required_fields(self, client, auth_header):
        """POST /v1/runs with missing fields should return 422."""
        response = client.post("/v1/runs", json={}, headers=auth_header)
        assert response.status_code == 422

    def test_create_run_missing_env_fingerprint(self, client, auth_header):
        """POST /v1/runs without env_fingerprint should return 422."""
        response = client.post("/v1/runs", json={
            "project_id": str(uuid.uuid4()),
            "entry_cmd": {"command": "pytest", "args": ["-v"]},
            # missing env_fingerprint
        }, headers=auth_header)
        assert response.status_code == 422

    def test_create_run_invalid_project_id_format(self, client, auth_header):
        """POST /v1/runs with non-UUID project_id should return 400."""
        response = client.post("/v1/runs", json={
            "project_id": "not-a-uuid",
            "entry_cmd": {"command": "pytest", "args": ["-v"]},
            "env_fingerprint": {"python_version": "3.11.0"},
        }, headers=auth_header)
        assert response.status_code == 400
        assert "format" in response.json()["detail"].lower() or "invalid" in response.json()["detail"].lower()

    def test_create_run_empty_string_project_id(self, client, auth_header):
        """POST /v1/runs with empty project_id should return 400 or 422."""
        response = client.post("/v1/runs", json={
            "project_id": "",
            "entry_cmd": {"command": "pytest", "args": []},
            "env_fingerprint": {},
        }, headers=auth_header)
        assert response.status_code in (400, 422)

    def test_create_run_null_body(self, client, auth_header):
        """POST /v1/runs with null JSON body should return 422."""
        response = client.post(
            "/v1/runs",
            headers={**auth_header, "Content-Type": "application/json"},
            content="null",
        )
        assert response.status_code == 422


class TestRunFinalizeValidation:
    """Tests for run finalization."""

    def test_finalize_requires_body(self, client, auth_header):
        """POST /v1/runs/{id}/finalize without body should return 422."""
        run_id = str(uuid.uuid4())
        response = client.post(f"/v1/runs/{run_id}/finalize", headers=auth_header)
        assert response.status_code == 422

    def test_finalize_with_negative_duration(self, client, auth_header, sample_run):
        """POST /v1/runs/{id}/finalize with negative duration is rejected.

        RunFinalizeRequest now validates duration >= 0 via Field(ge=0).
        """
        with patch("api.db.get_run") as mock_get, \
             patch("api.db.finalize_run") as mock_finalize:
            mock_get.return_value = sample_run
            response = client.post(
                f"/v1/runs/{sample_run['id']}/finalize",
                headers=auth_header,
                json={"duration": -1.0, "exit_code": 0, "io_count": 0},
            )
            assert response.status_code == 422
            mock_finalize.assert_not_called()

    def test_finalize_nonexistent_run(self, client, auth_header):
        """POST /v1/runs/{id}/finalize for non-existent run should return 404."""
        run_id = str(uuid.uuid4())
        with patch("api.db.get_run") as mock_get:
            mock_get.return_value = None
            response = client.post(
                f"/v1/runs/{run_id}/finalize",
                headers=auth_header,
                json={"duration": 1.0, "exit_code": 0, "io_count": 5},
            )
            assert response.status_code == 404


class TestPaginationLimits:
    """Tests for pagination parameter validation."""

    def test_list_runs_default_pagination(self, client, auth_header):
        """GET /v1/runs should use default pagination."""
        with patch("api.db.list_runs") as mock_list:
            mock_list.return_value = {"runs": [], "total": 0}
            response = client.get("/v1/runs", headers=auth_header)
            assert response.status_code == 200
            data = response.json()
            assert data["page"] == 1
            assert data["per_page"] == 20

    def test_list_runs_page_zero_rejected(self, client, auth_header):
        """GET /v1/runs?page=0 should return 422 (FastAPI ge=1 constraint)."""
        response = client.get("/v1/runs?page=0", headers=auth_header)
        assert response.status_code == 422, (
            f"page=0 should be rejected by Query(ge=1), got {response.status_code}"
        )

    def test_list_runs_negative_page_rejected(self, client, auth_header):
        """GET /v1/runs?page=-1 should return 422."""
        response = client.get("/v1/runs?page=-1", headers=auth_header)
        assert response.status_code == 422

    def test_list_runs_per_page_exceeds_max(self, client, auth_header):
        """GET /v1/runs?per_page=200 should be clamped or rejected."""
        response = client.get("/v1/runs?per_page=200", headers=auth_header)
        # FastAPI Query(le=100) rejects >100 with 422
        assert response.status_code == 422

    def test_list_runs_per_page_zero_rejected(self, client, auth_header):
        """GET /v1/runs?per_page=0 should return 422."""
        response = client.get("/v1/runs?per_page=0", headers=auth_header)
        assert response.status_code == 422

    def test_list_runs_non_integer_page(self, client, auth_header):
        """GET /v1/runs?page=abc should return 422."""
        response = client.get("/v1/runs?page=abc", headers=auth_header)
        assert response.status_code == 422


class TestInvalidUUIDHandling:
    """Tests for invalid UUID format in path parameters."""

    def test_get_run_invalid_uuid(self, client, auth_header):
        """GET /v1/runs/{invalid} should return 400."""
        response = client.get("/v1/runs/not-a-valid-uuid", headers=auth_header)
        assert response.status_code == 400

    def test_get_run_sql_injection_uuid(self, client, auth_header):
        """GET /v1/runs/{sql} should return 400 from UUID validation."""
        response = client.get(
            "/v1/runs/1' OR '1'='1",
            headers=auth_header,
        )
        assert response.status_code == 400

    def test_get_project_invalid_uuid(self, client, auth_header):
        """GET /v1/projects/{invalid} should return 400."""
        response = client.get("/v1/projects/not-a-uuid", headers=auth_header)
        assert response.status_code == 400

    def test_get_fix_invalid_uuid(self, client, auth_header):
        """GET or POST to fixes with invalid UUID should return 400."""
        response = client.post(
            "/v1/fixes/definitely-not-a-uuid/apply",
            headers=auth_header,
        )
        assert response.status_code == 400


class TestOrgIsolation:
    """Tests that organizations cannot access each other's data."""

    def test_run_access_checks_org(self, client, auth_header):
        """GET /v1/runs/{id} should verify org ownership."""
        run_id = str(uuid.uuid4())
        with patch("api.db.get_run") as mock_get:
            # Return a run belonging to a DIFFERENT org
            mock_get.return_value = {
                "id": run_id,
                "org_id": "org_OTHER",  # Different from test org (org_test456)
                "status": "FAILED",
            }
            response = client.get(f"/v1/runs/{run_id}", headers=auth_header)
            # Should be 403 or 404 (not 200)
            assert response.status_code in (403, 404)

    def test_project_access_checks_org(self, client, auth_header):
        """GET /v1/projects/{id} should verify org ownership."""
        project_id = str(uuid.uuid4())
        with patch("api.db.get_project") as mock_get:
            mock_get.return_value = {
                "id": project_id,
                "org_id": "org_OTHER",  # Different org
                "name": "Secret Project",
            }
            response = client.get(f"/v1/projects/{project_id}", headers=auth_header)
            assert response.status_code in (403, 404)

    def test_finalize_run_cross_org_denied(self, client, auth_header):
        """POST /v1/runs/{id}/finalize on another org's run should return 404."""
        run_id = str(uuid.uuid4())
        with patch("api.db.get_run") as mock_get:
            mock_get.return_value = {
                "id": run_id,
                "org_id": "org_ATTACKER",
                "status": "FAILED",
            }
            response = client.post(
                f"/v1/runs/{run_id}/finalize",
                headers=auth_header,
                json={"duration": 1.0, "exit_code": 0, "io_count": 5},
            )
            assert response.status_code in (403, 404), (
                f"Cross-org finalize should be denied, got {response.status_code}"
            )

    def test_delete_project_cross_org_denied(self, client, auth_header):
        """DELETE /v1/projects/{id} on another org's project should return 404."""
        project_id = str(uuid.uuid4())
        with patch("api.db.get_project") as mock_get:
            mock_get.return_value = {
                "id": project_id,
                "org_id": "org_OTHER",
                "name": "Victim Project",
            }
            response = client.delete(
                f"/v1/projects/{project_id}", headers=auth_header
            )
            assert response.status_code in (403, 404)


class TestInputSanitization:
    """Tests for input sanitization and maximum-length inputs."""

    def test_create_project_name_too_long_is_truncated(self, client, auth_header):
        """POST /v1/projects with a 200-char name should truncate to 100 chars.

        sanitize_string(max_length=100) truncates before regex validation,
        so the request succeeds with a shorter name.
        """
        long_name = "A" * 200
        with patch("api.db.create_project"):
            response = client.post(
                "/v1/projects",
                headers=auth_header,
                json={"name": long_name},
            )
            assert response.status_code == 200
            data = response.json()
            assert len(data["name"]) <= 100, (
                f"Project name should be truncated to <=100 chars, got {len(data['name'])}"
            )

    def test_create_project_empty_name(self, client, auth_header):
        """POST /v1/projects with empty name should return 400."""
        response = client.post(
            "/v1/projects",
            headers=auth_header,
            json={"name": ""},
        )
        assert response.status_code == 400

    def test_create_project_null_bytes_in_name(self, client, auth_header):
        """POST /v1/projects with null bytes in name should be sanitized or rejected."""
        with patch("api.db.create_project"):
            response = client.post(
                "/v1/projects",
                headers=auth_header,
                json={"name": "test\x00project"},
            )
            # sanitize_string strips null bytes; validate_project_name then checks the result
            assert response.status_code in (200, 400), (
                f"Null bytes in project name should be handled, got {response.status_code}"
            )

    def test_run_invalid_status_filter(self, client, auth_header):
        """GET /v1/runs?status=DROP_TABLE should return 400."""
        response = client.get(
            "/v1/runs?status=DROP_TABLE",
            headers=auth_header,
        )
        assert response.status_code == 400
        detail = response.json().get("detail", "")
        assert "invalid" in detail.lower() or "status" in detail.lower()


class TestAuthEdgeCases:
    """Tests for authentication edge cases."""

    def test_empty_bearer_token(self, client):
        """Request with empty bearer token should return 401.

        The mock_auth fixture intercepts verify_clerk_jwt. We configure it to
        raise so that an empty token is properly rejected by the auth layer.
        """
        with patch("api.auth.verify_clerk_jwt") as mock_verify:
            mock_verify.side_effect = ValueError("Empty token")
            from fastapi.testclient import TestClient
            from api.main import app

            raw_client = TestClient(app)
            response = raw_client.get("/v1/runs", headers={"Authorization": "Bearer "})
            assert response.status_code == 401

    def test_missing_authorization_header_on_project_create(self, client):
        """POST /v1/projects without auth should return 401."""
        response = client.post(
            "/v1/projects",
            json={"name": "Unauthorized Project"},
        )
        assert response.status_code == 401

    def test_missing_authorization_header_on_run_finalize(self, client):
        """POST /v1/runs/{id}/finalize without auth should return 401."""
        run_id = str(uuid.uuid4())
        response = client.post(
            f"/v1/runs/{run_id}/finalize",
            json={"duration": 1.0, "exit_code": 0, "io_count": 5},
        )
        assert response.status_code == 401


class TestHealthEndpoint:
    """Tests for the health check endpoint."""

    def test_health_returns_response(self, client):
        """GET /health should return a valid response (503 if env vars missing, 200 if present)."""
        response = client.get("/health")
        assert response.status_code in (200, 503)
        data = response.json()
        assert "status" in data
        assert "service" in data

    def test_health_response_structure(self, client):
        """GET /health should return expected JSON structure."""
        response = client.get("/health")
        data = response.json()
        assert "status" in data, "Health response should contain 'status' field"
        assert data["status"] in ("healthy", "unhealthy"), "status must be healthy or unhealthy"
        assert "service" in data, "Health response should contain 'service' field"
