"""Tests for airflow/plugins/api/tags.py.

Uses FastAPI TestClient with mocked HMAC middleware, DB engine, and Milvus client.
"""

import copy
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from fastapi.testclient import TestClient

# ---------------------------------------------------------------------------
# sys.path injection for airflow plugin imports
# ---------------------------------------------------------------------------
_plugins_dir = str(Path(__file__).resolve().parents[3] / "airflow" / "plugins")
if _plugins_dir not in sys.path:
    sys.path.insert(0, _plugins_dir)


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

_VALID_CTX = {"tenant_id": "t1", "node_id": "n1", "user_id": "u1", "role": "admin"}

_SAMPLE_TAGS = [
    {
        "tag_id": "tag-aaa",
        "name": "domain:nursing",
        "source": "ai",
        "confidence": 0.85,
        "status": "pending",
        "history": [{"action": "suggested", "by": "ai", "ts": 1700000000}],
    },
    {
        "tag_id": "tag-bbb",
        "name": "topic:coronavirus",
        "source": "ai",
        "confidence": 0.90,
        "status": "pending",
        "history": [],
    },
    {
        "tag_id": "tag-ccc",
        "name": "category:medical",
        "source": "manual",
        "confidence": 1.0,
        "status": "verified",
        "history": [],
    },
]


def _make_chunk_value(tags=None):
    """Build a chunk value dict matching the data_hierarchical_nodes schema.

    Deep-copies tags so endpoints mutating the list/dicts don't leak state
    across tests (tag fixtures are class-level constants).
    """
    return {
        "__data__": {
            "metadata": {
                "tags": copy.deepcopy(tags) if tags is not None else copy.deepcopy(_SAMPLE_TAGS),
                "domain_terms": ["term1", "term2"],
            }
        }
    }


def _mock_fetchone(value_dict):
    """Return a mock row whose [0] is the jsonb value."""
    row = MagicMock()
    row.__getitem__ = lambda self, idx: value_dict if idx == 0 else None
    return row


class _FakeConnection:
    """Minimal fake connection for rls_connection context manager."""

    def __init__(self, chunk_value=None):
        self._chunk_value = chunk_value
        self.executed = []

    def execute(self, stmt, params=None):
        sql = str(stmt)
        self.executed.append((sql, params))

        # SELECT ... FOR UPDATE
        if "SELECT value FROM data_hierarchical_nodes" in sql:
            if self._chunk_value is None:
                result = MagicMock()
                result.fetchone.return_value = None
                return result
            result = MagicMock()
            result.fetchone.return_value = _mock_fetchone(self._chunk_value)
            return result

        # UPDATE
        result = MagicMock()
        result.rowcount = 1
        return result


@pytest.fixture
def client():
    """TestClient with mocked HMAC middleware."""
    with patch("api.middleware.verify_hmac_headers", return_value=_VALID_CTX):
        from api.tags import tags_app
        yield TestClient(tags_app)


# ---------------------------------------------------------------------------
# PATCH /{chunk_id}/tags/status
# ---------------------------------------------------------------------------


class TestUpdateTagStatus:

    def test_happy_path(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/status",
                json={"tag_id": "tag-aaa", "status": "verified"},
            )

        assert resp.status_code == 200
        body = resp.json()
        assert body["tag_id"] == "tag-aaa"
        assert body["status"] == "verified"
        mock_milvus.assert_called_once()

    def test_chunk_not_found(self, client):
        fake_conn = _FakeConnection(chunk_value=None)

        with patch("api.tags.rls_connection") as mock_rls:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/status",
                json={"tag_id": "tag-aaa", "status": "verified"},
            )

        assert resp.status_code == 404

    def test_tag_not_found(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/status",
                json={"tag_id": "nonexistent", "status": "verified"},
            )

        assert resp.status_code == 404

    def test_invalid_status_rejected_by_pydantic(self, client):
        resp = client.patch(
            "/chunk-1/tags/status",
            json={"tag_id": "tag-aaa", "status": "invalid_value"},
        )
        assert resp.status_code == 422

    def test_reopen_verified_to_pending(self, client):
        """PATCH status=pending on a verified tag re-opens it."""
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/status",
                json={"tag_id": "tag-ccc", "status": "pending"},
            )

        assert resp.status_code == 200
        assert resp.json()["status"] == "pending"
        mock_milvus.assert_called_once()

    def test_same_status_returns_invalid_transition(self, client):
        """Same-status PATCH returns 400 INVALID_TRANSITION and does not call Milvus."""
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/status",
                json={"tag_id": "tag-ccc", "status": "verified"},
            )

        assert resp.status_code == 400
        assert resp.json()["error"]["code"] == "INVALID_TRANSITION"
        assert "verified" in resp.json()["error"]["message"]
        mock_milvus.assert_not_called()

    @pytest.mark.parametrize("from_status,to_status,expected_action", [
        ("pending", "verified", "confirmed"),
        ("pending", "rejected", "rejected"),
        ("verified", "pending", "edited"),
        ("verified", "rejected", "rejected"),
        ("rejected", "pending", "edited"),
        ("rejected", "verified", "confirmed"),
    ])
    def test_history_action_maps_to_enum(self, client, from_status, to_status, expected_action):
        """Emitted history action matches TagHistoryAction enum values."""
        tag = {
            "tag_id": "tag-xyz",
            "name": "ns:val",
            "source": "ai",
            "confidence": 0.8,
            "status": from_status,
            "history": [],
        }
        chunk_value = _make_chunk_value(tags=[tag])
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/status",
                json={"tag_id": "tag-xyz", "status": to_status},
            )

        assert resp.status_code == 200
        _, kwargs = mock_milvus.call_args
        assert kwargs["tags"][0]["history"][-1]["action"] == expected_action


# ---------------------------------------------------------------------------
# PATCH /{chunk_id}/tags/bulk-status
# ---------------------------------------------------------------------------


class TestBulkUpdateTagStatus:

    def test_happy_path(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/bulk-status",
                json={
                    "updates": [
                        {"tag_id": "tag-aaa", "status": "verified"},
                        {"tag_id": "tag-bbb", "status": "rejected"},
                    ]
                },
            )

        assert resp.status_code == 200
        body = resp.json()
        assert len(body["updated"]) == 2
        mock_milvus.assert_called_once()

    def test_missing_tag_id_returns_400(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/bulk-status",
                json={
                    "updates": [
                        {"tag_id": "tag-aaa", "status": "verified"},
                        {"tag_id": "nonexistent", "status": "rejected"},
                    ]
                },
            )

        assert resp.status_code == 400
        assert "nonexistent" in resp.json()["error"]["message"]

    def test_empty_updates_rejected(self, client):
        resp = client.patch(
            "/chunk-1/tags/bulk-status",
            json={"updates": []},
        )
        assert resp.status_code == 422

    def test_bulk_rejects_all_on_any_invalid_transition(self, client):
        """Any invalid transition aborts the whole batch; no Milvus call, offender listed."""
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/bulk-status",
                json={
                    "updates": [
                        {"tag_id": "tag-aaa", "status": "verified"},
                        {"tag_id": "tag-bbb", "status": "rejected"},
                        {"tag_id": "tag-ccc", "status": "verified"},
                    ]
                },
            )

        assert resp.status_code == 400
        assert resp.json()["error"]["code"] == "INVALID_TRANSITION"
        assert "tag-ccc" in resp.json()["error"]["message"]
        mock_milvus.assert_not_called()
        # Atomicity: no UPDATE SQL should have been issued on the chunk row.
        assert not any(
            "UPDATE data_hierarchical_nodes" in sql for sql, _ in fake_conn.executed
        )

    def test_bulk_duplicate_tag_ids_rejected(self, client):
        """Duplicate tag_ids in the request are rejected before any transition check."""
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/bulk-status",
                json={
                    "updates": [
                        {"tag_id": "tag-aaa", "status": "verified"},
                        {"tag_id": "tag-aaa", "status": "rejected"},
                    ]
                },
            )

        assert resp.status_code == 400
        assert resp.json()["error"]["code"] == "DUPLICATE_TAG_ID"
        assert "tag-aaa" in resp.json()["error"]["message"]
        mock_milvus.assert_not_called()


# ---------------------------------------------------------------------------
# PATCH /{chunk_id}/tags/{tag_id}/name
# ---------------------------------------------------------------------------


class TestRenameTag:

    def test_happy_path(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/tag-bbb/name",
                json={"name": "topic:covid19"},
            )

        assert resp.status_code == 200
        body = resp.json()
        assert body["old_name"] == "topic:coronavirus"
        assert body["new_name"] == "topic:covid19"
        mock_milvus.assert_called_once()

    def test_tag_not_found(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/nonexistent/name",
                json={"name": "topic:new"},
            )

        assert resp.status_code == 404

    def test_duplicate_tag_name(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            # Try to rename tag-bbb to domain:nursing (already exists on tag-aaa)
            resp = client.patch(
                "/chunk-1/tags/tag-bbb/name",
                json={"name": "domain:nursing"},
            )

        assert resp.status_code == 400
        assert resp.json()["error"]["code"] == "DUPLICATE_TAG_NAME"

    def test_name_without_namespace_rejected(self, client):
        resp = client.patch(
            "/chunk-1/tags/tag-aaa/name",
            json={"name": "no_namespace"},
        )
        assert resp.status_code == 422

    def test_empty_name_rejected(self, client):
        resp = client.patch(
            "/chunk-1/tags/tag-aaa/name",
            json={"name": ""},
        )
        assert resp.status_code == 422

    def test_rename_emits_edited_action(self, client):
        """Rename appends history entry with action='edited' (TagHistoryAction enum)."""
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.patch(
                "/chunk-1/tags/tag-bbb/name",
                json={"name": "topic:covid19"},
            )

        assert resp.status_code == 200
        _, kwargs = mock_milvus.call_args
        target = next(t for t in kwargs["tags"] if t["tag_id"] == "tag-bbb")
        assert target["history"][-1]["action"] == "edited"
        assert target["history"][-1]["old_name"] == "topic:coronavirus"


# ---------------------------------------------------------------------------
# DELETE /{chunk_id}/tags/{tag_id}
# ---------------------------------------------------------------------------


class TestDeleteTag:

    def test_happy_path(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.delete("/chunk-1/tags/tag-aaa")

        assert resp.status_code == 204

        # Verify Milvus called with remaining tags only (tag-aaa removed)
        mock_milvus.assert_called_once()
        _, kwargs = mock_milvus.call_args
        milvus_tags = kwargs["tags"]
        assert len(milvus_tags) == len(_SAMPLE_TAGS) - 1
        assert all(t["tag_id"] != "tag-aaa" for t in milvus_tags)

    def test_chunk_not_found(self, client):
        fake_conn = _FakeConnection(chunk_value=None)

        with patch("api.tags.rls_connection") as mock_rls:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.delete("/chunk-1/tags/tag-aaa")

        assert resp.status_code == 404

    def test_tag_not_found(self, client):
        chunk_value = _make_chunk_value()
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.delete("/chunk-1/tags/nonexistent")

        assert resp.status_code == 404

    def test_delete_last_tag(self, client):
        """Deleting the sole tag on a chunk results in an empty tags list."""
        single_tag = [_SAMPLE_TAGS[0].copy()]
        chunk_value = _make_chunk_value(tags=single_tag)
        fake_conn = _FakeConnection(chunk_value)

        with patch("api.tags.rls_connection") as mock_rls, \
             patch("api.tags.upsert_chunk_field") as mock_milvus:
            mock_rls.return_value.__enter__ = MagicMock(return_value=fake_conn)
            mock_rls.return_value.__exit__ = MagicMock(return_value=False)

            resp = client.delete("/chunk-1/tags/tag-aaa")

        assert resp.status_code == 204
        mock_milvus.assert_called_once()
        _, kwargs = mock_milvus.call_args
        assert kwargs["tags"] == []


# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------


class TestAuth:

    def test_unauthorized_returns_401(self):
        from textiq_hmac import VerificationError
        with patch("api.middleware.verify_hmac_headers", side_effect=VerificationError("bad")):
            from api.tags import tags_app
            c = TestClient(tags_app)
            resp = c.patch("/chunk-1/tags/status", json={"tag_id": "x", "status": "verified"})
        assert resp.status_code == 401
