"""Tests for bitable view config grouping validation (U5 / R4).

Layered tests:
1. Pure Pydantic validation via ``validate_view_config`` (no DB needed — always runs)
2. Route-level 422 via a mocked BitableService (no DB needed — always runs)
3. Round-trip via real PG service (skipped if PG unavailable)

The validator lives in ``agentkit.bitable.view_config`` and is called from
the PATCH /views route before ``service.update_view`` is invoked.
"""

from __future__ import annotations

from typing import Any

import httpx
import pytest
from fastapi import FastAPI
from httpx import ASGITransport
from pydantic import ValidationError

from agentkit.bitable.models import View, ViewType
from agentkit.bitable.view_config import (
    MAX_GROUP_BY_FIELDS,
    GroupByItem,
    ViewConfigSchema,
    ViewConfigValidationError,
    validate_view_config,
)
from agentkit.server.routes import bitable as bitable_routes
from agentkit.server.routes.bitable import require_bitable_auth

TEST_USER_ID = "test-user-id"


# ---------------------------------------------------------------------------
# 1. Pure Pydantic validation — always runs (no DB)
# ---------------------------------------------------------------------------


def _gb(field_id: str, direction: str = "asc") -> dict[str, str]:
    return {"field_id": field_id, "direction": direction}


def test_validate_accepts_empty_config() -> None:
    """Empty / None config is valid (no group_by to check)."""
    validate_view_config(None)
    validate_view_config({})
    validate_view_config({"filters": []})  # non-U5 keys ignored


def test_validate_accepts_one_to_three_group_by_fields() -> None:
    """Spec: max 3 levels. 1, 2, 3 all accepted."""
    for n in (1, 2, 3):
        config = {"group_by": [_gb(f"f{i}") for i in range(n)]}
        validate_view_config(config)  # no raise


def test_validate_rejects_more_than_three_group_by_fields() -> None:
    """group_by with >3 fields raises ValidationError (422 at the route)."""
    config = {"group_by": [_gb(f"f{i}") for i in range(MAX_GROUP_BY_FIELDS + 1)]}
    with pytest.raises(ViewConfigValidationError):
        validate_view_config(config)


def test_validate_rejects_invalid_direction() -> None:
    """direction must be 'asc' or 'desc' (Literal)."""
    config = {"group_by": [_gb("f1", "sideways")]}
    with pytest.raises(ViewConfigValidationError):
        validate_view_config(config)


def test_validate_rejects_missing_field_id() -> None:
    """field_id is required."""
    config = {"group_by": [{"direction": "asc"}]}
    with pytest.raises(ViewConfigValidationError):
        validate_view_config(config)


def test_validate_rejects_empty_field_id() -> None:
    """field_id must be non-empty (min_length=1)."""
    config = {"group_by": [_gb("")]}
    with pytest.raises(ViewConfigValidationError):
        validate_view_config(config)


def test_validate_rejects_extra_keys_in_group_by_item() -> None:
    """extra='forbid' on GroupByItem — unknown keys rejected."""
    config = {"group_by": [{"field_id": "f1", "direction": "asc", "color": "red"}]}
    with pytest.raises(ViewConfigValidationError):
        validate_view_config(config)


def test_validate_defaults_direction_to_asc() -> None:
    """direction is optional, defaults to 'asc'."""
    item = GroupByItem(field_id="f1")
    assert item.direction == "asc"


def test_validate_passes_through_non_u5_keys() -> None:
    """filters / sort / hidden_fields are NOT validated here — pass through."""
    config = {
        "filters": [{"field_id": "f1", "op": "weird-op", "value": None}],
        "sort": {"field": "f1", "order": "asc"},
        "hidden_fields": ["f2"],
        "group_by": [_gb("f1")],
    }
    validate_view_config(config)  # no raise — non-U5 keys ignored


def test_schema_max_length_constant() -> None:
    """MAX_GROUP_BY_FIELDS is 3 (matches spec / Feishu / Twenty UX)."""
    assert MAX_GROUP_BY_FIELDS == 3


def test_schema_round_trips_through_model() -> None:
    """ViewConfigSchema.model_validate round-trips a valid config."""
    raw = {
        "group_by": [{"field_id": "f1", "direction": "asc"}],
        "conditional_formatting": [],
    }
    schema = ViewConfigSchema.model_validate(raw)
    assert len(schema.group_by) == 1
    assert schema.group_by[0].field_id == "f1"
    dumped = schema.model_dump()
    assert dumped["group_by"][0]["direction"] == "asc"


def test_validation_error_carries_structured_errors() -> None:
    """ViewConfigValidationError.errors is the Pydantic error list (for 422 body)."""
    config = {"group_by": [_gb("")]}
    with pytest.raises(ViewConfigValidationError) as exc_info:
        validate_view_config(config)
    assert isinstance(exc_info.value.errors, list)
    assert len(exc_info.value.errors) > 0


def test_validation_error_is_value_error_subclass() -> None:
    """Subclasses ValueError so existing handlers pick it up."""
    config = {"group_by": [_gb("")]}
    with pytest.raises(ValueError):
        validate_view_config(config)


# ---------------------------------------------------------------------------
# 2. Route-level 422 — uses a fake service (no DB needed)
# ---------------------------------------------------------------------------


def _make_fake_view(view_id: str = "v1", table_id: str = "t1") -> View:
    return View(
        id=view_id,
        table_id=table_id,
        name="Test View",
        view_type=ViewType.grid,
        config={},
    )


class _FakeService:
    """Minimal service stub for route-level tests.

    Only implements the methods the PATCH /views route touches:
    ``get_view`` (existence + ownership check) and ``update_view``.
    """

    def __init__(self, *, existing_view: View | None = None) -> None:
        self._existing = existing_view
        self.updated_config: dict[str, object] | None = None
        self.update_called = False

    async def get_view(self, view_id: str) -> View | None:
        if self._existing is None or self._existing.id != view_id:
            return None
        return self._existing

    async def get_table(self, table_id: str) -> Any:
        # Ownership check passes — return a table owned by the test user.
        class _T:
            owner_user_id = TEST_USER_ID

        return _T()

    async def update_view(self, view_id: str, **kwargs: object) -> View | None:
        self.update_called = True
        self.updated_config = kwargs.get("config")  # type: ignore[assignment]
        if self._existing is None:
            return None
        config = kwargs.get("config")
        if isinstance(config, dict):
            self._existing = self._existing.model_copy(update={"config": config})
        return self._existing


@pytest.fixture
def fake_service_app() -> FastAPI:
    """App with a fake service on state — bypasses PG entirely."""
    app = FastAPI()
    app.state.bitable_service = _FakeService(existing_view=_make_fake_view())
    app.include_router(bitable_routes.router, prefix="/api/v1")
    app.dependency_overrides[require_bitable_auth] = lambda: {
        "user_id": TEST_USER_ID,
        "username": "testuser",
        "role": "member",
    }
    return app


@pytest.fixture
async def fake_client(fake_service_app: FastAPI) -> httpx.AsyncClient:
    transport = ASGITransport(app=fake_service_app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
        yield c


async def test_patch_view_with_valid_group_by_returns_200(
    fake_client: httpx.AsyncClient,
) -> None:
    """Valid group_by is accepted and persisted (round-trip at the route layer)."""
    config = {"group_by": [{"field_id": "f1", "direction": "asc"}]}
    resp = await fake_client.patch(
        "/api/v1/bitable/views/v1",
        json={"config": config},
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["success"] is True
    assert body["view"]["config"]["group_by"] == config["group_by"]


async def test_patch_view_with_too_many_group_by_returns_422(
    fake_client: httpx.AsyncClient,
) -> None:
    """group_by with >3 fields returns 422 (not 500)."""
    config = {"group_by": [{"field_id": f"f{i}"} for i in range(4)]}
    resp = await fake_client.patch(
        "/api/v1/bitable/views/v1",
        json={"config": config},
    )
    assert resp.status_code == 422
    body = resp.json()
    assert "errors" in body["detail"]


async def test_patch_view_with_invalid_direction_returns_422(
    fake_client: httpx.AsyncClient,
) -> None:
    """Invalid direction value returns 422."""
    resp = await fake_client.patch(
        "/api/v1/bitable/views/v1",
        json={"config": {"group_by": [{"field_id": "f1", "direction": "sideways"}]}},
    )
    assert resp.status_code == 422


async def test_patch_view_with_non_u5_config_returns_200(
    fake_client: httpx.AsyncClient,
) -> None:
    """Non-U5 config keys (filters / sort / hidden_fields) pass through unchanged."""
    resp = await fake_client.patch(
        "/api/v1/bitable/views/v1",
        json={"config": {"hidden_fields": ["f1", "f2"]}},
    )
    assert resp.status_code == 200, resp.text


# ---------------------------------------------------------------------------
# 3. Round-trip via real PG — skipped if PG unavailable
# ---------------------------------------------------------------------------


@pytest.mark.postgres
async def test_round_trip_group_by_via_real_service(bitable_service) -> None:
    """PATCH config with group_by → GET view returns same group_by.

    Requires PostgreSQL. Verifies the config dict survives the JSONB
    round-trip and the route-layer validation does not strip fields.
    """
    from agentkit.bitable.service import BitableService

    assert isinstance(bitable_service, BitableService)
    table = await bitable_service.create_table(name="U5 Round-Trip")
    view = await bitable_service.create_view(table.id, name="V1")

    config = {
        "group_by": [
            {"field_id": "f1", "direction": "asc"},
            {"field_id": "f2", "direction": "desc"},
        ],
    }
    # Validator accepts the config (no exception).
    validate_view_config(config)
    # Service persists it as-is.
    updated = await bitable_service.update_view(view.id, config=config)
    assert updated is not None
    assert updated.config.get("group_by") == config["group_by"]

    # GET view returns the same group_by.
    fetched = await bitable_service.get_view(view.id)
    assert fetched is not None
    assert fetched.config.get("group_by") == config["group_by"]


# ---------------------------------------------------------------------------
# Direct schema construction (covers Literal type errors at the model layer)
# ---------------------------------------------------------------------------


def test_group_by_item_rejects_invalid_direction_via_model() -> None:
    """Constructing GroupByItem with an invalid direction raises ValidationError."""
    with pytest.raises(ValidationError):
        GroupByItem(field_id="f1", direction="sideways")  # type: ignore[arg-type]


def test_view_config_schema_max_length_enforced() -> None:
    """ViewConfigSchema.group_by max_length=3 enforced at the model level."""
    too_many = [{"field_id": f"f{i}"} for i in range(MAX_GROUP_BY_FIELDS + 1)]
    with pytest.raises(ValidationError):
        ViewConfigSchema.model_validate({"group_by": too_many})
