"""Data-access layer invariants.

These pin the behaviours that only misbehave against a live Control Room:
correct POST body shape, pagination termination, cache hits, heavy-field
clamping, and the rule that auth errors never become empty results.
"""

from unittest.mock import MagicMock

import pytest
import requests

from a360_mcp.auth.auth_manager import A360AuthError
from a360_mcp.tools.a360_api import (
    HEAVY_FIELD_LIMIT_CLAMP,
    A360ApiError,
    ListPage,
    apply_payload_safety,
    build_filter,
    condition,
    get_record,
    invalidate_cache,
    invalidate_cache_for_path,
    list_all,
    list_page,
    strip_empty_fields,
    to_columnar,
)


# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #
@pytest.fixture
def auth():
    """A specced AuthManager mock — unspecced mocks swallow typos."""
    mock = MagicMock(spec=["make_request", "_config"])
    mock._config = MagicMock()
    mock._config.request_timeout = 60
    return mock


@pytest.fixture(autouse=True)
def clear_cache():
    """Each test starts with an empty cache."""
    invalidate_cache()
    yield
    invalidate_cache()


def list_response(rows, total=None, offset=0, length=None):
    """Build a mock list response whose json()/text agree."""
    resp = MagicMock(spec=requests.Response)
    resp.status_code = 200
    payload = {
        "page": {
            "offset": offset,
            "length": length if length is not None else len(rows),
            "total": total if total is not None else len(rows),
        },
        "list": rows,
    }
    import json

    resp.json.return_value = payload
    resp.text = json.dumps(payload)
    resp.content = resp.text.encode("utf-8")
    return resp


def record_response(record):
    """Build a mock single-record GET response."""
    resp = MagicMock(spec=requests.Response)
    resp.status_code = 200
    import json

    resp.json.return_value = record
    resp.text = json.dumps(record)
    resp.content = resp.text.encode("utf-8")
    return resp


def error_response(status_code, message="boom"):
    resp = MagicMock(spec=requests.Response)
    resp.status_code = status_code
    resp.json.return_value = {"message": message}
    resp.text = message
    resp.content = message.encode("utf-8")
    return resp


# --------------------------------------------------------------------------- #
# strip_empty_fields
# --------------------------------------------------------------------------- #
class TestStripEmptyFields:
    def test_drops_none_empty_string_empty_dict_empty_list(self):
        result = strip_empty_fields({"a": None, "b": "", "c": {}, "d": [], "e": "kept", "f": 0})
        assert result == {"e": "kept", "f": 0}

    def test_keeps_meaningful_falsy_values(self):
        result = strip_empty_fields(
            {"zero": 0, "false": False, "str_zero": "0", "str_false": "false"}
        )
        assert result == {"zero": 0, "false": False, "str_zero": "0", "str_false": "false"}

    def test_does_not_mutate_input(self):
        original = {"a": None, "b": "x"}
        strip_empty_fields(original)
        assert original == {"a": None, "b": "x"}


# --------------------------------------------------------------------------- #
# to_columnar
# --------------------------------------------------------------------------- #
class TestToColumnar:
    def test_three_rows_become_columnar(self):
        rows = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}, {"id": 3, "name": "c"}]
        result = to_columnar(rows)
        assert result == {
            "columns": ["id", "name"],
            "data": [[1, "a"], [2, "b"], [3, "c"]],
        }

    def test_preferred_order_respected(self):
        rows = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]
        result = to_columnar(rows, preferred_order=["name", "id"])
        assert result["columns"] == ["name", "id"]
        assert result["data"] == [["a", 1], ["b", 2]]

    def test_ragged_rows_missing_key_becomes_none(self):
        rows = [{"id": 1, "name": "a"}, {"id": 2}]
        result = to_columnar(rows)
        assert result["columns"] == ["id", "name"]
        assert result["data"] == [[1, "a"], [2, None]]

    def test_preferred_order_drops_fields_not_present(self):
        rows = [{"id": 1}]
        result = to_columnar(rows, preferred_order=["id", "absent"])
        assert result["columns"] == ["id"]

    def test_empty_rows(self):
        assert to_columnar([]) == {"columns": [], "data": []}


# --------------------------------------------------------------------------- #
# build_filter / condition
# --------------------------------------------------------------------------- #
class TestBuildFilter:
    def test_no_conditions_returns_none(self):
        assert build_filter() is None

    def test_single_condition_returned_bare(self):
        cond = condition("status", "eq", "RUN_FAILED")
        assert build_filter(cond) == cond

    def test_multiple_conditions_wrapped_in_and(self):
        c1 = condition("status", "eq", "RUN_FAILED")
        c2 = condition("userName", "like", "svc")
        result = build_filter(c1, c2)
        assert result == {"operator": "and", "operands": [c1, c2]}

    def test_multiple_conditions_wrapped_in_or(self):
        c1 = condition("status", "eq", "RUN_FAILED")
        c2 = condition("status", "eq", "RUN_ABORTED")
        result = build_filter(c1, c2, operator="or")
        assert result["operator"] == "or"

    def test_condition_shape(self):
        assert condition("status", "eq", "RUN_FAILED") == {
            "operator": "eq",
            "field": "status",
            "value": "RUN_FAILED",
        }


# --------------------------------------------------------------------------- #
# list_page
# --------------------------------------------------------------------------- #
class TestListPage:
    def test_posts_correct_body_and_returns_page(self, auth):
        auth.make_request.return_value = list_response([{"id": 1}, {"id": 2}], total=2)
        page = list_page(auth, "/v3/activity/list", offset=0, length=100)

        assert isinstance(page, ListPage)
        assert page.rows == [{"id": 1}, {"id": 2}]
        assert page.total == 2
        assert page.offset == 0
        auth.make_request.assert_called_once_with(
            "POST",
            "/v3/activity/list",
            json={"page": {"offset": 0, "length": 100}},
        )

    def test_includes_filter_sort_fields_when_given(self, auth):
        auth.make_request.return_value = list_response([])
        flt = condition("status", "eq", "RUN_FAILED")
        list_page(
            auth,
            "/v3/activity/list",
            filter=flt,
            sort=[{"field": "id", "direction": "desc"}],
            fields=["id", "status"],
        )
        sent = auth.make_request.call_args.kwargs["json"]
        assert sent["filter"] == flt
        assert sent["sort"] == [{"field": "id", "direction": "desc"}]
        assert sent["fields"] == ["id", "status"]

    def test_omits_none_filter_sort_fields_from_body(self, auth):
        auth.make_request.return_value = list_response([])
        list_page(auth, "/v3/activity/list")
        sent = auth.make_request.call_args.kwargs["json"]
        assert "filter" not in sent
        assert "sort" not in sent
        assert "fields" not in sent
        assert sent == {"page": {"offset": 0, "length": 100}}

    def test_cache_hit_skips_second_network_call(self, auth):
        auth.make_request.return_value = list_response([{"id": 1}], total=1)
        list_page(auth, "/v3/activity/list", offset=0, length=50)
        list_page(auth, "/v3/activity/list", offset=0, length=50)
        assert auth.make_request.call_count == 1

    def test_different_args_bypass_cache(self, auth):
        auth.make_request.return_value = list_response([{"id": 1}], total=1)
        list_page(auth, "/v3/activity/list", offset=0, length=50)
        list_page(auth, "/v3/activity/list", offset=50, length=50)
        assert auth.make_request.call_count == 2

    def test_heavy_field_clamps_length(self, auth):
        auth.make_request.return_value = list_response([])
        list_page(
            auth,
            "/v2/repository/file/list",
            fields=["content", "id"],
            length=100,
        )
        sent = auth.make_request.call_args.kwargs["json"]
        assert sent["page"]["length"] == HEAVY_FIELD_LIMIT_CLAMP

    def test_http_error_raises_a360_api_error(self, auth):
        auth.make_request.return_value = error_response(400, "bad filter")
        with pytest.raises(A360ApiError) as exc_info:
            list_page(auth, "/v3/activity/list")
        assert exc_info.value.status_code == 400
        assert exc_info.value.path == "/v3/activity/list"
        assert "bad filter" in exc_info.value.body

    def test_auth_error_propagates_not_silenced(self, auth):
        auth.make_request.side_effect = A360AuthError("not logged in")
        with pytest.raises(A360AuthError):
            list_page(auth, "/v3/activity/list")

    def test_does_not_cache_errors(self, auth):
        auth.make_request.return_value = error_response(500)
        with pytest.raises(A360ApiError):
            list_page(auth, "/v3/activity/list")
        # Second call must hit the network again (error was not cached).
        auth.make_request.return_value = list_response([{"id": 1}], total=1)
        list_page(auth, "/v3/activity/list")
        assert auth.make_request.call_count == 2


# --------------------------------------------------------------------------- #
# list_all
# --------------------------------------------------------------------------- #
class TestListAll:
    def test_paginates_until_max_items(self, auth):
        # Server has 5 items, max_items=3 → fetch one page of 3 and stop.
        auth.make_request.return_value = list_response([{"id": 1}, {"id": 2}, {"id": 3}], total=5)
        rows = list_all(auth, "/v3/activity/list", max_items=3, page_size=2)
        assert len(rows) == 3

    def test_stops_when_page_returns_fewer_than_requested(self, auth):
        responses = [
            list_response([{"id": 1}, {"id": 2}], total=3),
            list_response([{"id": 3}], total=3),  # fewer than requested → end
        ]
        auth.make_request.side_effect = responses
        rows = list_all(auth, "/v3/activity/list", max_items=100, page_size=2)
        assert [r["id"] for r in rows] == [1, 2, 3]
        assert auth.make_request.call_count == 2

    def test_max_items_is_hard_cap(self, auth):
        # Server returns full pages but max_items caps the result.
        auth.make_request.return_value = list_response([{"id": i} for i in range(100)], total=1000)
        rows = list_all(auth, "/v3/activity/list", max_items=5, page_size=100)
        assert len(rows) == 5

    def test_strips_empty_fields_from_each_row(self, auth):
        auth.make_request.return_value = list_response(
            [{"id": 1, "junk": None, "name": "a"}], total=1
        )
        rows = list_all(auth, "/v3/activity/list", max_items=1)
        assert rows == [{"id": 1, "name": "a"}]

    def test_zero_max_items_returns_empty(self, auth):
        rows = list_all(auth, "/v3/activity/list", max_items=0)
        assert rows == []
        auth.make_request.assert_not_called()

    def test_stops_at_server_total(self, auth):
        auth.make_request.return_value = list_response([{"id": 1}, {"id": 2}], total=2)
        rows = list_all(auth, "/v3/activity/list", max_items=100, page_size=2)
        assert len(rows) == 2
        assert auth.make_request.call_count == 1


# --------------------------------------------------------------------------- #
# get_record
# --------------------------------------------------------------------------- #
class TestGetRecord:
    def test_returns_parsed_dict(self, auth):
        record = {"id": "exec-1", "status": "RUN_FAILED", "message": "oops"}
        auth.make_request.return_value = record_response(record)
        result = get_record(auth, "/v3/activity/execution/exec-1")
        assert result == record
        auth.make_request.assert_called_once_with("GET", "/v3/activity/execution/exec-1")

    def test_http_404_raises_api_error(self, auth):
        auth.make_request.return_value = error_response(404, "not found")
        with pytest.raises(A360ApiError) as exc_info:
            get_record(auth, "/v3/activity/execution/missing")
        assert exc_info.value.status_code == 404

    def test_auth_error_propagates(self, auth):
        auth.make_request.side_effect = A360AuthError("not logged in")
        with pytest.raises(A360AuthError):
            get_record(auth, "/v3/activity/execution/exec-1")


# --------------------------------------------------------------------------- #
# apply_payload_safety
# --------------------------------------------------------------------------- #
class TestApplyPayloadSafety:
    def test_clamps_to_max_page_length(self):
        safe_length, _, _ = apply_payload_safety("/v3/activity/list", 50_000, None)
        assert safe_length == 10_000

    def test_no_fields_returns_as_is(self):
        safe_length, fields, notice = apply_payload_safety("/v3/activity/list", 100, None)
        assert safe_length == 100
        assert fields is None
        assert notice is None

    def test_heavy_field_clamps_and_notices(self):
        safe_length, fields, notice = apply_payload_safety(
            "/v2/repository/file/list", 100, ["content", "id"]
        )
        assert safe_length == HEAVY_FIELD_LIMIT_CLAMP
        assert fields == ["content", "id"]
        assert notice is not None

    def test_non_heavy_fields_not_clamped(self):
        safe_length, _, notice = apply_payload_safety("/v3/activity/list", 100, ["id", "status"])
        assert safe_length == 100
        assert notice is None


# --------------------------------------------------------------------------- #
# cache invalidation
# --------------------------------------------------------------------------- #
class TestCacheInvalidation:
    def test_invalidate_cache_clears_all(self, auth):
        auth.make_request.return_value = list_response([{"id": 1}], total=1)
        list_page(auth, "/v3/activity/list", offset=0, length=10)
        removed = invalidate_cache()
        assert removed >= 1
        # Next call hits the network again.
        list_page(auth, "/v3/activity/list", offset=0, length=10)
        assert auth.make_request.call_count == 2

    def test_invalidate_cache_for_path_clears_only_matching(self, auth):
        auth.make_request.return_value = list_response([{"id": 1}], total=1)
        list_page(auth, "/v3/activity/list", offset=0, length=10)
        list_page(auth, "/v2/devices/list", offset=0, length=10)
        removed = invalidate_cache_for_path("/v3/activity/list")
        assert removed == 1
        # devices cache survives.
        list_page(auth, "/v2/devices/list", offset=0, length=10)
        # activity cache was cleared → network call.
        list_page(auth, "/v3/activity/list", offset=0, length=10)
        # 2 initial + 1 activity re-fetch = 3; devices was cached.
        assert auth.make_request.call_count == 3

    def test_invalidate_cache_for_path_no_match_returns_zero(self):
        assert invalidate_cache_for_path("/nonexistent") == 0
