"""Tests for document approval endpoints (approve/reject).

These tests exercise the Airflow plugin endpoints at:
  POST /{document_id}/approve
  POST /{document_id}/reject

External dependencies (HMAC auth, DB, DAG trigger, S3) are mocked because
the plugin runs inside the Airflow api-server process with its own infra.
"""

from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from uuid import uuid4

import pytest
from fastapi.testclient import TestClient


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

TENANT_ID = str(uuid4())
NODE_ID = str(uuid4())
USER_ID = str(uuid4())

HMAC_CTX = {
    "tenant_id": TENANT_ID,
    "node_id": NODE_ID,
    "user_id": USER_ID,
    "role": "admin",
}


@pytest.fixture()
def client():
    """TestClient with HMAC verification bypassed."""
    with patch("api.auth.verify_hmac_headers", return_value=HMAC_CTX):
        from api.upload import upload_app

        yield TestClient(upload_app, raise_server_exceptions=False)


@pytest.fixture()
def client_no_hmac():
    """TestClient with HMAC verification raising VerificationError (no auth)."""
    from api.auth import VerificationError

    with patch(
        "api.auth.verify_hmac_headers",
        side_effect=VerificationError("Missing signature"),
    ):
        from api.upload import upload_app

        yield TestClient(upload_app, raise_server_exceptions=False)


def _make_doc_row(doc_id, status="pending", file_path=None, approval_status="pending"):
    """Build a fake DB row tuple matching the SELECT columns."""
    fp = file_path or f"{TENANT_ID}/{NODE_ID}/{doc_id}/test.xlsx"
    return (str(doc_id), status, fp)


def _make_reject_row(doc_id, status="pending"):
    """Build a fake DB row for reject SELECT (id, status)."""
    return (str(doc_id), status)


# ---------------------------------------------------------------------------
# Approve — happy paths
# ---------------------------------------------------------------------------


class TestApproveHappyPaths:
    """POST /{id}/approve happy-path scenarios."""

    @patch("api.upload.trigger_dag")
    @patch("api.upload.get_upload_bucket", return_value="test-bucket")
    @patch("api.upload.rls_connection")
    def test_approve_pending_doc_returns_200_and_triggers_dag(
        self, mock_rls, mock_bucket, mock_dag, client
    ):
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = _make_doc_row(doc_id)
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(f"/{doc_id}/approve", json={"note": "Looks good"})

        assert resp.status_code == 200
        data = resp.json()
        assert data["approval_status"] == "approved"
        assert data["dag_triggered"] is True
        mock_dag.assert_called_once()

    @patch("api.upload.trigger_dag")
    @patch("api.upload.get_upload_bucket", return_value="test-bucket")
    @patch("api.upload.rls_connection")
    def test_approve_completed_doc_does_not_trigger_dag(
        self, mock_rls, mock_bucket, mock_dag, client
    ):
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = _make_doc_row(
            doc_id, status="completed"
        )
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(f"/{doc_id}/approve", json={})

        assert resp.status_code == 200
        data = resp.json()
        assert data["dag_triggered"] is False
        mock_dag.assert_not_called()

    @patch("api.upload.trigger_dag")
    @patch("api.upload.get_upload_bucket", return_value="test-bucket")
    @patch("api.upload.rls_connection")
    def test_approve_with_optional_note(self, mock_rls, mock_bucket, mock_dag, client):
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = _make_doc_row(doc_id)
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(f"/{doc_id}/approve", json={"note": "Reviewed by manager"})

        assert resp.status_code == 200
        # Verify note was passed in the UPDATE call
        update_call = mock_conn.execute.call_args_list[1]
        params = update_call[0][1] if len(update_call[0]) > 1 else update_call[1].get("parameters", {})
        assert params.get("note") == "Reviewed by manager"


# ---------------------------------------------------------------------------
# Reject — happy paths
# ---------------------------------------------------------------------------


class TestRejectHappyPaths:
    """POST /{id}/reject happy-path scenarios."""

    @patch("api.upload._delete_s3_folder")
    @patch("api.upload.rls_connection")
    def test_reject_pending_doc_returns_200_and_deletes_s3(
        self, mock_rls, mock_s3_del, client
    ):
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = _make_reject_row(doc_id)
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(
            f"/{doc_id}/reject",
            json={"rejection_reason": "Document is not relevant to this project"},
        )

        assert resp.status_code == 200
        data = resp.json()
        assert data["approval_status"] == "rejected"
        assert data["s3_deleted"] is True
        mock_s3_del.assert_called_once_with(TENANT_ID, NODE_ID, str(doc_id))

    @patch("api.upload._delete_s3_folder")
    @patch("api.upload.rls_connection")
    def test_reject_completed_doc_skips_s3_deletion(self, mock_rls, mock_s3_del, client):
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = _make_reject_row(
            doc_id, status="completed"
        )
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(
            f"/{doc_id}/reject",
            json={"rejection_reason": "Revoking approval after review"},
        )

        assert resp.status_code == 200
        data = resp.json()
        assert data["s3_deleted"] is False
        mock_s3_del.assert_not_called()


# ---------------------------------------------------------------------------
# Reversals (approve <-> reject)
# ---------------------------------------------------------------------------


class TestReversals:
    """Transitions between approved and rejected states."""

    @patch("api.upload.trigger_dag")
    @patch("api.upload.get_upload_bucket", return_value="test-bucket")
    @patch("api.upload.rls_connection")
    def test_rejected_to_approved_triggers_dag_when_pending(
        self, mock_rls, mock_bucket, mock_dag, client
    ):
        """Reverse: rejected -> approved on a doc still at status=pending."""
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = _make_doc_row(doc_id, status="pending")
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(f"/{doc_id}/approve", json={})

        assert resp.status_code == 200
        assert resp.json()["dag_triggered"] is True
        mock_dag.assert_called_once()

    @patch("api.upload._delete_s3_folder")
    @patch("api.upload.rls_connection")
    def test_approved_to_rejected_deletes_s3_when_pending(
        self, mock_rls, mock_s3_del, client
    ):
        """Reverse: approved -> rejected on a doc still at status=pending."""
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = _make_reject_row(doc_id, status="pending")
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(
            f"/{doc_id}/reject",
            json={"rejection_reason": "Changed my mind after further review"},
        )

        assert resp.status_code == 200
        assert resp.json()["approval_status"] == "rejected"
        mock_s3_del.assert_called_once()


# ---------------------------------------------------------------------------
# 404 — document not found / cross-tenant
# ---------------------------------------------------------------------------


class TestNotFound:
    """Document not found or cross-tenant isolation via RLS."""

    @patch("api.upload.rls_connection")
    def test_approve_nonexistent_doc_returns_404(self, mock_rls, client):
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = None
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(f"/{doc_id}/approve", json={})

        assert resp.status_code == 404
        assert resp.json()["error"]["code"] == "NOT_FOUND"

    @patch("api.upload.rls_connection")
    def test_reject_nonexistent_doc_returns_404(self, mock_rls, client):
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = None
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(
            f"/{doc_id}/reject",
            json={"rejection_reason": "This document does not belong here"},
        )

        assert resp.status_code == 404
        assert resp.json()["error"]["code"] == "NOT_FOUND"

    @patch("api.upload.rls_connection")
    def test_cross_tenant_approve_returns_404(self, mock_rls, client):
        """RLS hides the row, so cross-tenant lookups return 404."""
        doc_id = uuid4()
        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = None  # RLS filtered
        mock_rls.return_value.__enter__ = MagicMock(return_value=mock_conn)
        mock_rls.return_value.__exit__ = MagicMock(return_value=False)

        resp = client.post(f"/{doc_id}/approve", json={})

        assert resp.status_code == 404


# ---------------------------------------------------------------------------
# 422 — validation errors
# ---------------------------------------------------------------------------


class TestValidationErrors:
    """Pydantic / UUID validation errors → 422."""

    def test_approve_invalid_uuid_returns_422(self, client):
        resp = client.post("/not-a-uuid/approve", json={})
        assert resp.status_code == 422

    def test_reject_invalid_uuid_returns_422(self, client):
        resp = client.post(
            "/not-a-uuid/reject",
            json={"rejection_reason": "This should not matter"},
        )
        assert resp.status_code == 422

    def test_reject_reason_too_short_returns_422(self, client):
        doc_id = uuid4()
        resp = client.post(
            f"/{doc_id}/reject",
            json={"rejection_reason": "short"},
        )
        assert resp.status_code == 422

    def test_reject_reason_too_long_returns_422(self, client):
        doc_id = uuid4()
        resp = client.post(
            f"/{doc_id}/reject",
            json={"rejection_reason": "x" * 501},
        )
        assert resp.status_code == 422

    def test_reject_missing_reason_returns_422(self, client):
        doc_id = uuid4()
        resp = client.post(f"/{doc_id}/reject", json={})
        assert resp.status_code == 422


# ---------------------------------------------------------------------------
# 401 — HMAC authentication
# ---------------------------------------------------------------------------


class TestHMACAuth:
    """Missing or invalid HMAC headers → 401."""

    def test_approve_without_hmac_returns_401(self, client_no_hmac):
        doc_id = uuid4()
        resp = client_no_hmac.post(f"/{doc_id}/approve", json={})
        assert resp.status_code == 401

    def test_reject_without_hmac_returns_401(self, client_no_hmac):
        doc_id = uuid4()
        resp = client_no_hmac.post(
            f"/{doc_id}/reject",
            json={"rejection_reason": "This should be rejected by auth"},
        )
        assert resp.status_code == 401
