"""Tests for bitable REST API routes (U2).

Requires PostgreSQL — marked ``postgres``. Uses ``httpx.AsyncClient`` with
``ASGITransport`` so the async DB engine and the HTTP client share one event
loop (TestClient runs in a separate thread/loop, which breaks asyncpg's
loop-bound connections).
"""

from __future__ import annotations

import json
from typing import Any

import httpx
import pytest
from fastapi import FastAPI
from httpx import ASGITransport

from agentkit.bitable.service import BitableService
from agentkit.server.routes import bitable as bitable_routes
from agentkit.server.routes.bitable import require_bitable_auth

pytestmark = pytest.mark.postgres

TEST_USER_ID = "test-user-id"


def _make_test_user() -> dict[str, Any]:
    return {"user_id": TEST_USER_ID, "username": "testuser", "role": "member"}


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


@pytest.fixture
def app(bitable_service: BitableService) -> FastAPI:
    """Test app with bitable_service on app.state and auth bypassed."""
    app = FastAPI()
    app.state.bitable_service = bitable_service
    app.include_router(bitable_routes.router, prefix="/api/v1")
    app.dependency_overrides[require_bitable_auth] = lambda: _make_test_user()
    return app


@pytest.fixture
def unauth_app(bitable_service: BitableService) -> FastAPI:
    """App without auth override — simulates unauthenticated requests."""
    app = FastAPI()
    app.state.bitable_service = bitable_service
    app.include_router(bitable_routes.router, prefix="/api/v1")
    return app


@pytest.fixture
def no_service_app() -> FastAPI:
    """App without bitable_service on state — simulates uninitialized subsystem."""
    app = FastAPI()
    app.include_router(bitable_routes.router, prefix="/api/v1")
    app.dependency_overrides[require_bitable_auth] = lambda: _make_test_user()
    return app


@pytest.fixture
async def client(app: FastAPI) -> httpx.AsyncClient:
    """Async HTTP client — shares event loop with async DB fixtures."""
    transport = ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
        yield c


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


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


# ---------------------------------------------------------------------------
# Auth + service availability
# ---------------------------------------------------------------------------


async def test_create_table_requires_auth(unauth_client: httpx.AsyncClient) -> None:
    """No auth → 401."""
    resp = await unauth_client.post("/api/v1/bitable/tables", json={"name": "T"})
    assert resp.status_code == 401


async def test_endpoint_returns_503_when_service_unavailable(
    no_service_client: httpx.AsyncClient,
) -> None:
    """No service on app.state → 503."""
    resp = await no_service_client.post("/api/v1/bitable/tables", json={"name": "T"})
    assert resp.status_code == 503


# ---------------------------------------------------------------------------
# Tables CRUD
# ---------------------------------------------------------------------------


async def test_create_table_success(client: httpx.AsyncClient) -> None:
    resp = await client.post(
        "/api/v1/bitable/tables", json={"name": "Orders", "description": "desc"}
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["success"] is True
    assert data["table"]["name"] == "Orders"
    assert data["table"]["description"] == "desc"
    assert "id" in data["table"]


async def test_list_tables_returns_created(client: httpx.AsyncClient) -> None:
    for name in ("A", "B", "C"):
        await client.post("/api/v1/bitable/tables", json={"name": name})
    resp = await client.get("/api/v1/bitable/tables")
    assert resp.status_code == 200
    data = resp.json()
    assert data["success"] is True
    assert len(data["tables"]) == 3
    names = {t["name"] for t in data["tables"]}
    assert names == {"A", "B", "C"}


async def test_get_table_404_when_missing(client: httpx.AsyncClient) -> None:
    resp = await client.get("/api/v1/bitable/tables/nonexistent-id")
    assert resp.status_code == 404


async def test_update_table_success(client: httpx.AsyncClient) -> None:
    create_resp = await client.post("/api/v1/bitable/tables", json={"name": "Old"})
    table_id = create_resp.json()["table"]["id"]
    resp = await client.patch(f"/api/v1/bitable/tables/{table_id}", json={"name": "New"})
    assert resp.status_code == 200
    assert resp.json()["table"]["name"] == "New"


async def test_delete_table_success(client: httpx.AsyncClient) -> None:
    create_resp = await client.post("/api/v1/bitable/tables", json={"name": "T"})
    table_id = create_resp.json()["table"]["id"]
    resp = await client.delete(f"/api/v1/bitable/tables/{table_id}")
    assert resp.status_code == 200
    assert resp.json()["success"] is True
    # Verify gone
    assert (await client.get(f"/api/v1/bitable/tables/{table_id}")).status_code == 404


# ---------------------------------------------------------------------------
# Fields CRUD + dependency check (409)
# ---------------------------------------------------------------------------


async def test_create_field_success(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    resp = await client.post(
        f"/api/v1/bitable/tables/{table_id}/fields",
        json={"name": "title", "field_type": "text", "owner": "agent"},
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["field"]["name"] == "title"
    assert data["field"]["field_type"] == "text"
    assert data["field"]["owner"] == "agent"


async def test_list_fields(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    for name in ("f1", "f2"):
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": name, "field_type": "text"},
        )
    resp = await client.get(f"/api/v1/bitable/tables/{table_id}/fields")
    assert resp.status_code == 200
    assert len(resp.json()["fields"]) == 2


async def test_delete_field_no_deps(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    field_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "f", "field_type": "text"},
        )
    ).json()["field"]["id"]
    resp = await client.delete(f"/api/v1/bitable/fields/{field_id}")
    assert resp.status_code == 200


async def test_delete_field_returns_409_when_referenced_by_formula(
    client: httpx.AsyncClient,
) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    source_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "src", "field_type": "number"},
        )
    ).json()["field"]["id"]
    await client.post(
        f"/api/v1/bitable/tables/{table_id}/fields",
        json={
            "name": "calc",
            "field_type": "formula",
            "config": {"formula_expr": f"={source_id} * 2"},
        },
    )
    resp = await client.delete(f"/api/v1/bitable/fields/{source_id}")
    assert resp.status_code == 409
    detail = resp.json()["detail"]
    assert "dependencies" in detail
    assert "formula_fields" in detail["dependencies"]


async def test_delete_field_force_cascades(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    source_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "src", "field_type": "number", "owner": "agent"},
        )
    ).json()["field"]["id"]
    # Create a record with the source field
    await client.post(
        f"/api/v1/bitable/tables/{table_id}/records",
        json={"records": [{source_id: 42}]},
    )
    resp = await client.delete(f"/api/v1/bitable/fields/{source_id}?force=true")
    assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Records CRUD + cursor pagination
# ---------------------------------------------------------------------------


async def test_create_records_batch(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    resp = await client.post(
        f"/api/v1/bitable/tables/{table_id}/records",
        json={"records": [{"a": 1}, {"a": 2}, {"a": 3}]},
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["count"] == 3
    assert len(data["records"]) == 3


async def test_list_records_cursor_pagination(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    await client.post(
        f"/api/v1/bitable/tables/{table_id}/records",
        json={"records": [{"i": i} for i in range(5)]},
    )
    # Page 1
    resp = await client.get(f"/api/v1/bitable/tables/{table_id}/records?limit=2")
    assert resp.status_code == 200
    data = resp.json()
    assert len(data["records"]) == 2
    assert data["next_cursor"] is not None
    # Page 2
    resp2 = await client.get(
        f"/api/v1/bitable/tables/{table_id}/records?limit=2&cursor={data['next_cursor']}"
    )
    data2 = resp2.json()
    assert len(data2["records"]) == 2
    # No overlap
    ids1 = {r["id"] for r in data["records"]}
    ids2 = {r["id"] for r in data2["records"]}
    assert ids1.isdisjoint(ids2)


async def test_list_records_with_filters(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    num_field_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "amt", "field_type": "number", "owner": "agent"},
        )
    ).json()["field"]["id"]
    await client.post(
        f"/api/v1/bitable/tables/{table_id}/records",
        json={"records": [{num_field_id: 10}, {num_field_id: 50}, {num_field_id: 100}]},
    )
    filters = json.dumps([{"field_id": num_field_id, "op": "gt", "value": 40}])
    resp = await client.get(
        f"/api/v1/bitable/tables/{table_id}/records", params={"filters": filters}
    )
    assert resp.status_code == 200
    data = resp.json()
    assert len(data["records"]) == 2  # 50 and 100


async def test_update_record(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    record_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/records", json={"records": [{"a": 1}]}
        )
    ).json()["records"][0]["id"]
    resp = await client.patch(f"/api/v1/bitable/records/{record_id}", json={"values": {"a": 99}})
    assert resp.status_code == 200
    assert resp.json()["record"]["values"]["a"] == 99


async def test_delete_single_record(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    record_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/records", json={"records": [{"a": 1}]}
        )
    ).json()["records"][0]["id"]
    resp = await client.delete(f"/api/v1/bitable/records/{record_id}")
    assert resp.status_code == 200
    # Verify gone
    resp2 = await client.get(f"/api/v1/bitable/tables/{table_id}/records")
    assert len(resp2.json()["records"]) == 0


# ---------------------------------------------------------------------------
# Upsert endpoint (KTD8)
# ---------------------------------------------------------------------------


async def test_upsert_inserts_then_updates(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    pk_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "id", "field_type": "text", "owner": "agent"},
        )
    ).json()["field"]["id"]
    data_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "data", "field_type": "text", "owner": "agent"},
        )
    ).json()["field"]["id"]
    await client.patch(f"/api/v1/bitable/tables/{table_id}", json={"primary_key_field_id": pk_id})

    # First: insert
    resp = await client.post(
        f"/api/v1/bitable/tables/{table_id}/upsert",
        json={
            "records": [{pk_id: "r1", data_id: "v1"}],
            "primary_key_field_id": pk_id,
        },
    )
    assert resp.status_code == 200
    assert resp.json()["inserted"] == 1
    assert resp.json()["updated"] == 0

    # Second: update
    resp2 = await client.post(
        f"/api/v1/bitable/tables/{table_id}/upsert",
        json={
            "records": [{pk_id: "r1", data_id: "v2"}],
            "primary_key_field_id": pk_id,
        },
    )
    assert resp2.status_code == 200
    assert resp2.json()["inserted"] == 0
    assert resp2.json()["updated"] == 1

    # Verify value
    records = (await client.get(f"/api/v1/bitable/tables/{table_id}/records")).json()["records"]
    assert len(records) == 1
    assert records[0]["values"][data_id] == "v2"


async def test_upsert_preserves_user_columns(client: httpx.AsyncClient) -> None:
    """KTD8 via API: upsert updates agent columns, user columns untouched."""
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    pk_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "id", "field_type": "text", "owner": "agent"},
        )
    ).json()["field"]["id"]
    agent_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "agent_data", "field_type": "text", "owner": "agent"},
        )
    ).json()["field"]["id"]
    user_id = (
        await client.post(
            f"/api/v1/bitable/tables/{table_id}/fields",
            json={"name": "user_data", "field_type": "text", "owner": "user"},
        )
    ).json()["field"]["id"]
    await client.patch(f"/api/v1/bitable/tables/{table_id}", json={"primary_key_field_id": pk_id})

    # Insert with both agent and user values
    await client.post(
        f"/api/v1/bitable/tables/{table_id}/upsert",
        json={
            "records": [{pk_id: "r1", agent_id: "a1", user_id: "u1"}],
            "primary_key_field_id": pk_id,
        },
    )
    # Manually set user column (simulating user edit via PATCH)
    records = (await client.get(f"/api/v1/bitable/tables/{table_id}/records")).json()["records"]
    rec_id = records[0]["id"]
    await client.patch(
        f"/api/v1/bitable/records/{rec_id}",
        json={"values": {**records[0]["values"], user_id: "USER_EDITED"}},
    )

    # Second upsert: tries to change user column — should be ignored
    await client.post(
        f"/api/v1/bitable/tables/{table_id}/upsert",
        json={
            "records": [{pk_id: "r1", agent_id: "a2", user_id: "SHOULD_NOT_APPLY"}],
            "primary_key_field_id": pk_id,
        },
    )

    records = (await client.get(f"/api/v1/bitable/tables/{table_id}/records")).json()["records"]
    assert len(records) == 1
    assert records[0]["values"][agent_id] == "a2"  # updated
    assert records[0]["values"][user_id] == "USER_EDITED"  # preserved


# ---------------------------------------------------------------------------
# Views CRUD
# ---------------------------------------------------------------------------


async def test_create_view_success(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    resp = await client.post(
        f"/api/v1/bitable/tables/{table_id}/views",
        json={"name": "Grid View", "view_type": "grid", "config": {}},
    )
    assert resp.status_code == 200
    assert resp.json()["view"]["name"] == "Grid View"
    assert resp.json()["view"]["view_type"] == "grid"


async def test_list_views(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    for name in ("v1", "v2"):
        await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": name})
    resp = await client.get(f"/api/v1/bitable/tables/{table_id}/views")
    assert resp.status_code == 200
    assert len(resp.json()["views"]) == 2


async def test_update_view(client: httpx.AsyncClient) -> None:
    table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
        "id"
    ]
    view_id = (
        await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "Old"})
    ).json()["view"]["id"]
    resp = await client.patch(f"/api/v1/bitable/views/{view_id}", json={"name": "New"})
    assert resp.status_code == 200
    assert resp.json()["view"]["name"] == "New"


# ---------------------------------------------------------------------------
# Formula validation (U5b)
# ---------------------------------------------------------------------------


async def test_validate_formula_valid(client: httpx.AsyncClient) -> None:
    """Valid formula returns valid=true."""
    resp = await client.post(
        "/api/v1/bitable/fields/validate-formula",
        json={"formula": "1 + 2"},
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["valid"] is True
    assert "error" not in data


async def test_validate_formula_with_field_ref(client: httpx.AsyncClient) -> None:
    """Formula with field reference is valid syntax."""
    resp = await client.post(
        "/api/v1/bitable/fields/validate-formula",
        json={"formula": "{field_abc} + 1"},
    )
    assert resp.status_code == 200
    assert resp.json()["valid"] is True


async def test_validate_formula_with_function(client: httpx.AsyncClient) -> None:
    """Formula with built-in function is valid."""
    resp = await client.post(
        "/api/v1/bitable/fields/validate-formula",
        json={"formula": "SUM({f1}) + AVG({f2})"},
    )
    assert resp.status_code == 200
    assert resp.json()["valid"] is True


async def test_validate_formula_syntax_error(client: httpx.AsyncClient) -> None:
    """Syntax error returns valid=false with error message."""
    resp = await client.post(
        "/api/v1/bitable/fields/validate-formula",
        json={"formula": "1 +"},
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["valid"] is False
    assert "error" in data


async def test_validate_formula_security_error(client: httpx.AsyncClient) -> None:
    """Dangerous constructs (import) are rejected."""
    resp = await client.post(
        "/api/v1/bitable/fields/validate-formula",
        json={"formula": "__import__('os')"},
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["valid"] is False
    assert "error" in data


async def test_validate_formula_unknown_function(client: httpx.AsyncClient) -> None:
    """Unknown function is rejected."""
    resp = await client.post(
        "/api/v1/bitable/fields/validate-formula",
        json={"formula": "UNKNOWN_FUNC(1)"},
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["valid"] is False
    assert "error" in data


async def test_validate_formula_requires_auth(unauth_client: httpx.AsyncClient) -> None:
    """No auth → 401."""
    resp = await unauth_client.post(
        "/api/v1/bitable/fields/validate-formula",
        json={"formula": "1 + 2"},
    )
    assert resp.status_code == 401
