"""Tests for Calendar plugin — calendars, entries, links, subtasks, bulk, kanban, CSV, ICS, resources, recurrence, sharing.

Tests all 29 acceptance criteria.
"""

from __future__ import annotations

import pytest

from tests.conftest import ORIGIN_HEADER, login_client

# ─── AC1: List calendars ───


@pytest.mark.asyncio
async def test_ac1_list_calendars_empty(calendar_authed_client):
    """AC1: GET /api/v1/calendars → 200 + calendar list (empty)."""
    client, _ = calendar_authed_client
    resp = await client.get("/api/v1/calendars", headers=ORIGIN_HEADER)
    assert resp.status_code == 200
    assert resp.json() == []


@pytest.mark.asyncio
async def test_ac1_list_calendars(calendar_authed_client):
    """AC1: GET /api/v1/calendars → 200 + calendar list with items."""
    client, _ = calendar_authed_client
    resp = await client.post(
        "/api/v1/calendars",
        json={"name": "My Calendar", "color": "#FF0000", "type": "personal"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    resp = await client.get("/api/v1/calendars", headers=ORIGIN_HEADER)
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 1
    assert data[0]["name"] == "My Calendar"
    assert data[0]["color"] == "#FF0000"


# ─── AC2: Create calendar ───


@pytest.mark.asyncio
async def test_ac2_create_calendar(calendar_authed_client):
    """AC2: POST /api/v1/calendars → 201, calendar created."""
    client, _ = calendar_authed_client
    resp = await client.post(
        "/api/v1/calendars",
        json={"name": "Work Calendar", "color": "#00FF00", "type": "team"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    data = resp.json()
    assert data["name"] == "Work Calendar"
    assert data["color"] == "#00FF00"
    assert data["type"] == "team"
    assert "id" in data
    assert "owner_id" in data


# ─── AC3: Update calendar ───


@pytest.mark.asyncio
async def test_ac3_update_calendar(calendar_authed_client):
    """AC3: PATCH /api/v1/calendars/{id} → 200, updated."""
    client, _ = calendar_authed_client
    resp = await client.post(
        "/api/v1/calendars",
        json={"name": "Old Name"},
        headers=ORIGIN_HEADER,
    )
    cal_id = resp.json()["id"]
    resp = await client.patch(
        f"/api/v1/calendars/{cal_id}",
        json={"name": "New Name", "color": "#0000FF"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["name"] == "New Name"
    assert data["color"] == "#0000FF"


# ─── AC4: Delete calendar ───


@pytest.mark.asyncio
async def test_ac4_delete_calendar(calendar_authed_client):
    """AC4: DELETE /api/v1/calendars/{id} → 204, cascade delete entries."""
    client, _ = calendar_authed_client
    resp = await client.post(
        "/api/v1/calendars",
        json={"name": "To Delete"},
        headers=ORIGIN_HEADER,
    )
    cal_id = resp.json()["id"]

    # Create an entry in the calendar
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Meeting",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201

    # Delete calendar
    resp = await client.delete(f"/api/v1/calendars/{cal_id}", headers=ORIGIN_HEADER)
    assert resp.status_code == 204

    # Verify calendar is gone
    resp = await client.get("/api/v1/calendars", headers=ORIGIN_HEADER)
    assert resp.status_code == 200
    assert len(resp.json()) == 0

    # Verify entries are also soft-deleted
    resp = await client.get(
        "/api/v1/calendar/entries?start=2026-06-01&end=2026-08-01",
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    assert len(resp.json()) == 0


# ─── AC5: Share calendar ───


@pytest.mark.asyncio
async def test_ac5_share_calendar(calendar_authed_client):
    """AC5: POST /api/v1/calendars/{id}/share → 200, calendar shared."""
    client, seed = calendar_authed_client
    resp = await client.post(
        "/api/v1/calendars",
        json={"name": "Shared Cal"},
        headers=ORIGIN_HEADER,
    )
    cal_id = resp.json()["id"]
    viewer_id = str(seed["viewer_a"].id)
    resp = await client.post(
        f"/api/v1/calendars/{cal_id}/share",
        json={"user_id": viewer_id, "permission": "read"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["permission"] == "read"
    assert data["user_id"] == viewer_id


# ─── AC6: Get permissions ───


@pytest.mark.asyncio
async def test_ac6_get_permissions(calendar_authed_client):
    """AC6: GET /api/v1/calendars/{id}/permissions → 200 + permission list."""
    client, seed = calendar_authed_client
    resp = await client.post(
        "/api/v1/calendars",
        json={"name": "Perm Cal"},
        headers=ORIGIN_HEADER,
    )
    cal_id = resp.json()["id"]
    viewer_id = str(seed["viewer_a"].id)
    editor_id = str(seed["editor_a"].id)
    await client.post(
        f"/api/v1/calendars/{cal_id}/share",
        json={"user_id": viewer_id, "permission": "read"},
        headers=ORIGIN_HEADER,
    )
    await client.post(
        f"/api/v1/calendars/{cal_id}/share",
        json={"user_id": editor_id, "permission": "write"},
        headers=ORIGIN_HEADER,
    )
    resp = await client.get(f"/api/v1/calendars/{cal_id}/permissions", headers=ORIGIN_HEADER)
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 2


# ─── AC7: List entries ───


@pytest.mark.asyncio
async def test_ac7_list_entries(calendar_authed_client):
    """AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "June Meeting",
            "start_at": "2026-06-15T10:00:00",
            "end_at": "2026-06-15T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "August Meeting",
            "start_at": "2026-08-15T10:00:00",
            "end_at": "2026-08-15T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    resp = await client.get(
        "/api/v1/calendar/entries?start=2026-06-01T00:00:00&end=2026-07-01T00:00:00",
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 1
    assert data[0]["title"] == "June Meeting"


# ─── AC8: Create appointment ───


@pytest.mark.asyncio
async def test_ac8_create_appointment(calendar_authed_client):
    """AC8: POST /api/v1/calendar/entries (appointment) → 201, with start_at/end_at."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Team Standup",
            "start_at": "2026-07-01T09:00:00",
            "end_at": "2026-07-01T09:30:00",
            "location": "Room A",
        },
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    data = resp.json()
    assert data["entry_type"] == "appointment"
    assert data["title"] == "Team Standup"
    assert data["start_at"] is not None
    assert data["end_at"] is not None
    assert data["location"] == "Room A"


# ─── AC9: Create task ───


@pytest.mark.asyncio
async def test_ac9_create_task(calendar_authed_client):
    """AC9: POST /api/v1/calendar/entries (task) → 201, with due_date/priority/status."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "task",
            "title": "Write report",
            "due_date": "2026-07-15T17:00:00",
            "priority": "high",
            "status": "open",
        },
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    data = resp.json()
    assert data["entry_type"] == "task"
    assert data["title"] == "Write report"
    assert data["priority"] == "high"
    assert data["status"] == "open"
    assert data["due_date"] is not None


# ─── AC10: Get entry detail ───


@pytest.mark.asyncio
async def test_ac10_get_entry_detail(calendar_authed_client):
    """AC10: GET /api/v1/calendar/entries/{id} → 200 + entry detail with links+subtasks."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Detail Test",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    resp = await client.get(f"/api/v1/calendar/entries/{entry_id}", headers=ORIGIN_HEADER)
    assert resp.status_code == 200
    data = resp.json()
    assert data["title"] == "Detail Test"
    assert data["links"] == []
    assert data["subtasks"] == []


# ─── AC11: Update entry (drag&drop) ───


@pytest.mark.asyncio
async def test_ac11_update_entry_dragdrop(calendar_authed_client):
    """AC11: PATCH /api/v1/calendar/entries/{id} → 200, updated (drag&drop)."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Move Me",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    resp = await client.patch(
        f"/api/v1/calendar/entries/{entry_id}",
        json={"start_at": "2026-07-02T14:00:00", "end_at": "2026-07-02T15:00:00"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert "2026-07-02" in data["start_at"]
    assert "14:00" in data["start_at"]


# ─── AC12: Update entry status ───


@pytest.mark.asyncio
async def test_ac12_update_entry_status(calendar_authed_client):
    """AC12: PATCH /api/v1/calendar/entries/{id} status=done → 200."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "task",
            "title": "Complete Me",
            "due_date": "2026-07-15T17:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    resp = await client.patch(
        f"/api/v1/calendar/entries/{entry_id}",
        json={"status": "done"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    assert resp.json()["status"] == "done"


# ─── AC13: Delete entry ───


@pytest.mark.asyncio
async def test_ac13_delete_entry(calendar_authed_client):
    """AC13: DELETE /api/v1/calendar/entries/{id} → 204."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Delete Me",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    resp = await client.delete(f"/api/v1/calendar/entries/{entry_id}", headers=ORIGIN_HEADER)
    assert resp.status_code == 204
    resp = await client.get(f"/api/v1/calendar/entries/{entry_id}", headers=ORIGIN_HEADER)
    assert resp.status_code == 404


# ─── AC14: Link entry ───


@pytest.mark.asyncio
async def test_ac14_link_entry(calendar_authed_client):
    """AC14: POST /api/v1/calendar/entries/{id}/link → 200, linked."""
    client, seed = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Linked Meeting",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    company_id = str(seed["company_a"].id)
    resp = await client.post(
        f"/api/v1/calendar/entries/{entry_id}/link",
        json={"entity_type": "contact", "entity_id": company_id},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["entity_type"] == "contact"
    assert data["entity_id"] == company_id


# ─── AC15: Create subtask ───


@pytest.mark.asyncio
async def test_ac15_create_subtask(calendar_authed_client):
    """AC15: POST /api/v1/calendar/entries/{id}/subtasks → 201, subtask created."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "task",
            "title": "Parent Task",
            "due_date": "2026-07-15T17:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    resp = await client.post(
        f"/api/v1/calendar/entries/{entry_id}/subtasks",
        json={"title": "Sub item 1"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    data = resp.json()
    assert data["title"] == "Sub item 1"
    assert data["completed"] is False


# ─── AC16: Toggle subtask ───


@pytest.mark.asyncio
async def test_ac16_toggle_subtask(calendar_authed_client):
    """AC16: PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id} → 200, toggled."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "task",
            "title": "Parent Task",
            "due_date": "2026-07-15T17:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    resp = await client.post(
        f"/api/v1/calendar/entries/{entry_id}/subtasks",
        json={"title": "Toggle Me"},
        headers=ORIGIN_HEADER,
    )
    sub_id = resp.json()["id"]
    resp = await client.patch(
        f"/api/v1/calendar/entries/{entry_id}/subtasks/{sub_id}",
        json={"completed": True},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    assert resp.json()["completed"] is True


# ─── AC17: Bulk action ───


@pytest.mark.asyncio
async def test_ac17_bulk_status_change(calendar_authed_client):
    """AC17: POST /api/v1/calendar/entries/bulk → 200, bulk status change."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    entry_ids = []
    for i in range(3):
        resp = await client.post(
            "/api/v1/calendar/entries",
            json={
                "calendar_id": cal_id,
                "entry_type": "task",
                "title": f"Task {i}",
                "due_date": "2026-07-15T17:00:00",
            },
            headers=ORIGIN_HEADER,
        )
        entry_ids.append(resp.json()["id"])
    resp = await client.post(
        "/api/v1/calendar/entries/bulk",
        json={"entry_ids": entry_ids, "action": "done"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["action"] == "done"
    assert data["affected"] == 3


# ─── AC18: Kanban board ───


@pytest.mark.asyncio
async def test_ac18_kanban(calendar_authed_client):
    """AC18: GET /api/v1/calendar/kanban → 200 + tasks grouped by status."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "task",
            "title": "Open Task",
            "due_date": "2026-07-15T17:00:00",
            "status": "open",
        },
        headers=ORIGIN_HEADER,
    )
    await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "task",
            "title": "Done Task",
            "due_date": "2026-07-15T17:00:00",
            "status": "done",
        },
        headers=ORIGIN_HEADER,
    )
    resp = await client.get("/api/v1/calendar/kanban", headers=ORIGIN_HEADER)
    assert resp.status_code == 200
    data = resp.json()
    assert "open" in data
    assert "in_progress" in data
    assert "done" in data
    assert "cancelled" in data
    assert len(data["open"]) == 1
    assert len(data["done"]) == 1
    assert len(data["in_progress"]) == 0


# ─── AC19: CSV export ───


@pytest.mark.asyncio
async def test_ac19_csv_export(calendar_authed_client):
    """AC19: GET /api/v1/calendar/entries/export?format=csv → 200 + CSV."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Export Test",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    resp = await client.get("/api/v1/calendar/entries/export?format=csv", headers=ORIGIN_HEADER)
    assert resp.status_code == 200
    text = resp.text
    assert "id" in text
    assert "Export Test" in text


# ─── AC20: ICS feed valid token ───


@pytest.mark.asyncio
async def test_ac20_ics_feed_valid_token(calendar_authed_client):
    """AC20: GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid → 200 + text/calendar."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "ICS Event",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    # First call without token generates token and returns 401
    resp = await client.get(f"/api/v1/calendar/{cal_id}/ics-feed", headers=ORIGIN_HEADER)
    assert resp.status_code == 401
    # Should have no token, so 401


@pytest.mark.asyncio
async def test_ac20_ics_feed_with_token(calendar_authed_client):
    """AC20: GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid → 200 + text/calendar."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "ICS Event",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    # First call without token generates token and returns 401
    resp = await client.get(f"/api/v1/calendar/{cal_id}/ics-feed", headers=ORIGIN_HEADER)
    assert resp.status_code == 401

    # Get the token from the calendar
    resp = await client.get("/api/v1/calendars", headers=ORIGIN_HEADER)
    cal_data = resp.json()[0]
    token = cal_data["ics_token"]
    assert token is not None

    # Call with valid token
    resp = await client.get(
        f"/api/v1/calendar/{cal_id}/ics-feed?token={token}",
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    assert "text/calendar" in resp.headers.get("content-type", "")
    assert "BEGIN:VCALENDAR" in resp.text
    assert "ICS Event" in resp.text


# ─── AC21: ICS feed invalid token ───


@pytest.mark.asyncio
async def test_ac21_ics_feed_invalid_token(calendar_authed_client):
    """AC21: GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid → 401."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.get(
        f"/api/v1/calendar/{cal_id}/ics-feed?token=invalidtoken",
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 401


# ─── AC22: ICS import ───


@pytest.mark.asyncio
async def test_ac22_ics_import(calendar_authed_client):
    """AC22: POST /api/v1/calendar/import mit .ics file → 200 + import result."""
    client, _ = calendar_authed_client
    resp = await client.post(
        "/api/v1/calendars", json={"name": "Import Cal"}, headers=ORIGIN_HEADER
    )
    cal_id = resp.json()["id"]
    ics_content = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//EN
BEGIN:VEVENT
UID:test1@test.com
DTSTART:20260701T100000Z
DTEND:20260701T110000Z
SUMMARY:Imported Meeting
DESCRIPTION:Test description
LOCATION:Room B
END:VEVENT
BEGIN:VEVENT
UID:test2@test.com
DTSTART:20260702T140000Z
DTEND:20260702T150000Z
SUMMARY:Second Event
END:VEVENT
END:VCALENDAR
"""
    resp = await client.post(
        f"/api/v1/calendar/import?calendar_id={cal_id}",
        files={"file": ("test.ics", ics_content, "text/calendar")},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["entries_created"] == 2


# ─── AC23: Resource creation (admin only) ───


@pytest.mark.asyncio
async def test_ac23_create_resource_admin(calendar_authed_client):
    """AC23: POST /api/v1/resources → 201 (admin only)."""
    client, _ = calendar_authed_client
    resp = await client.post(
        "/api/v1/resources",
        json={"name": "Meeting Room A", "type": "room"},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    data = resp.json()
    assert data["name"] == "Meeting Room A"
    assert data["type"] == "room"


@pytest.mark.asyncio
async def test_ac23_create_resource_non_admin(calendar_app, db_session):
    """AC23: POST /api/v1/resources → 403 for non-admin."""
    from httpx import ASGITransport, AsyncClient

    from tests.conftest import seed_tenant_and_users

    await seed_tenant_and_users(db_session)
    transport = ASGITransport(app=calendar_app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        await login_client(c, "viewer@tenanta.com")
        # Install + activate plugin
        await c.post("/api/v1/plugins/calendar/install", headers=ORIGIN_HEADER)
        await c.post("/api/v1/plugins/calendar/activate", headers=ORIGIN_HEADER)
        resp = await c.post(
            "/api/v1/resources",
            json={"name": "Projector", "type": "equipment"},
            headers=ORIGIN_HEADER,
        )
        assert resp.status_code == 403


# ─── AC24: Book resource ───


@pytest.mark.asyncio
async def test_ac24_book_resource(calendar_authed_client):
    """AC24: POST /api/v1/calendar/entries/{id}/book-resource → 200."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Booking Test",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/resources",
        json={"name": "Room X", "type": "room"},
        headers=ORIGIN_HEADER,
    )
    resource_id = resp.json()["id"]
    resp = await client.post(
        f"/api/v1/calendar/entries/{entry_id}/book-resource",
        json={"resource_id": resource_id},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    assert data["resource_id"] == resource_id
    assert data["entry_id"] == entry_id


# ─── AC25: Book resource conflict ───


@pytest.mark.asyncio
async def test_ac25_book_resource_conflict(calendar_authed_client):
    """AC25: POST /api/v1/calendar/entries/{id}/book-resource (conflict) → 409."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/resources",
        json={"name": "Room Y", "type": "room"},
        headers=ORIGIN_HEADER,
    )
    resource_id = resp.json()["id"]

    # First booking
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "First Booking",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry1_id = resp.json()["id"]
    await client.post(
        f"/api/v1/calendar/entries/{entry1_id}/book-resource",
        json={"resource_id": resource_id},
        headers=ORIGIN_HEADER,
    )

    # Second overlapping booking → 409
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Conflicting Booking",
            "start_at": "2026-07-01T10:30:00",
            "end_at": "2026-07-01T11:30:00",
        },
        headers=ORIGIN_HEADER,
    )
    entry2_id = resp.json()["id"]
    resp = await client.post(
        f"/api/v1/calendar/entries/{entry2_id}/book-resource",
        json={"resource_id": resource_id},
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 409


# ─── AC26: Recurrence weekly ───


@pytest.mark.asyncio
async def test_ac26_recurrence_weekly(calendar_authed_client):
    """AC26: Recurrence: weekly entry generates correct occurrences for date range query."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Weekly Standup",
            "start_at": "2026-06-02T09:00:00",  # Tuesday
            "end_at": "2026-06-02T09:30:00",
            "recurrence": {"pattern": "weekly"},
        },
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    # Query range covering 3 weeks
    resp = await client.get(
        "/api/v1/calendar/entries?start=2026-06-01T00:00:00&end=2026-06-22T00:00:00",
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    # Should have occurrences for June 2, 9, 16 (3 Tuesdays)
    assert len(data) == 3


# ─── AC27: Recurrence exception ───


@pytest.mark.asyncio
async def test_ac27_recurrence_exception(calendar_authed_client):
    """AC27: Recurrence: exception date excluded from occurrences."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Daily Sync",
            "start_at": "2026-06-01T09:00:00",
            "end_at": "2026-06-01T09:30:00",
            "recurrence": {
                "pattern": "daily",
                "exceptions": ["2026-06-02"],
            },
        },
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    resp = await client.get(
        "/api/v1/calendar/entries?start=2026-06-01T00:00:00&end=2026-06-05T00:00:00",
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 200
    data = resp.json()
    # June 1, 3, 4 → 3 occurrences (June 2 excluded)
    assert len(data) == 3


# ─── AC28: Reminder ARQ job ───


@pytest.mark.asyncio
async def test_ac28_reminder_scheduled(calendar_authed_client):
    """AC28: Reminder: ARQ job scheduled when reminder JSONB set."""
    client, _ = calendar_authed_client
    resp = await client.post("/api/v1/calendars", json={"name": "Cal"}, headers=ORIGIN_HEADER)
    cal_id = resp.json()["id"]
    resp = await client.post(
        "/api/v1/calendar/entries",
        json={
            "calendar_id": cal_id,
            "entry_type": "appointment",
            "title": "Reminded Meeting",
            "start_at": "2026-07-01T10:00:00",
            "end_at": "2026-07-01T11:00:00",
            "reminder": {"value": 15, "unit": "minutes", "channel": "in_app"},
        },
        headers=ORIGIN_HEADER,
    )
    assert resp.status_code == 201
    data = resp.json()
    assert data["reminder"] is not None
    assert data["reminder"]["value"] == 15
    assert data["reminder"]["channel"] == "in_app"


# ─── AC29: Calendar share read-only ───


@pytest.mark.asyncio
async def test_ac29_share_readonly_cannot_edit(calendar_app, db_session):
    """AC29: Calendar share: user with read permission can view, cannot edit (403)."""
    from httpx import ASGITransport, AsyncClient

    from tests.conftest import seed_tenant_and_users

    seed = await seed_tenant_and_users(db_session)
    transport = ASGITransport(app=calendar_app)
    async with AsyncClient(transport=transport, base_url="http://test") as admin_c:
        await login_client(admin_c, "admin@tenanta.com")
        await admin_c.post("/api/v1/plugins/calendar/install", headers=ORIGIN_HEADER)
        await admin_c.post("/api/v1/plugins/calendar/activate", headers=ORIGIN_HEADER)

        # Create calendar as admin
        resp = await admin_c.post(
            "/api/v1/calendars",
            json={"name": "Shared Cal"},
            headers=ORIGIN_HEADER,
        )
        cal_id = resp.json()["id"]

        # Share with viewer as read-only
        viewer_id = str(seed["viewer_a"].id)
        await admin_c.post(
            f"/api/v1/calendars/{cal_id}/share",
            json={"user_id": viewer_id, "permission": "read"},
            headers=ORIGIN_HEADER,
        )

        # Create an entry as admin
        resp = await admin_c.post(
            "/api/v1/calendar/entries",
            json={
                "calendar_id": cal_id,
                "entry_type": "appointment",
                "title": "Shared Event",
                "start_at": "2026-07-01T10:00:00",
                "end_at": "2026-07-01T11:00:00",
            },
            headers=ORIGIN_HEADER,
        )
        entry_id = resp.json()["id"]

    # Now login as viewer
    async with AsyncClient(transport=transport, base_url="http://test") as viewer_c:
        await login_client(viewer_c, "viewer@tenanta.com")

        # Can view entries
        resp = await viewer_c.get("/api/v1/calendar/entries", headers=ORIGIN_HEADER)
        assert resp.status_code == 200
        assert len(resp.json()) >= 1

        # Cannot edit entry (no write permission)
        resp = await viewer_c.patch(
            f"/api/v1/calendar/entries/{entry_id}",
            json={"title": "Hacked"},
            headers=ORIGIN_HEADER,
        )
        assert resp.status_code == 403


# ─── AC30: Private entry visibility ───


@pytest.mark.asyncio
async def test_ac30_private_entry_visibility(calendar_app, db_session):
    """AC30: Private subtype: only owner+admin can see entry."""
    from httpx import ASGITransport, AsyncClient

    from tests.conftest import seed_tenant_and_users

    await seed_tenant_and_users(db_session)
    transport = ASGITransport(app=calendar_app)

    # Admin creates a private entry
    async with AsyncClient(transport=transport, base_url="http://test") as admin_c:
        await login_client(admin_c, "admin@tenanta.com")
        await admin_c.post("/api/v1/plugins/calendar/install", headers=ORIGIN_HEADER)
        await admin_c.post("/api/v1/plugins/calendar/activate", headers=ORIGIN_HEADER)

        resp = await admin_c.post(
            "/api/v1/calendars",
            json={"name": "Cal"},
            headers=ORIGIN_HEADER,
        )
        cal_id = resp.json()["id"]

        resp = await admin_c.post(
            "/api/v1/calendar/entries",
            json={
                "calendar_id": cal_id,
                "entry_type": "appointment",
                "title": "Private Meeting",
                "subtype": "private",
                "start_at": "2026-07-01T10:00:00",
                "end_at": "2026-07-01T11:00:00",
            },
            headers=ORIGIN_HEADER,
        )
        entry_id = resp.json()["id"]

        # Admin can see private entries
        resp = await admin_c.get("/api/v1/calendar/entries", headers=ORIGIN_HEADER)
        assert resp.status_code == 200
        titles = [e["title"] for e in resp.json()]
        assert "Private Meeting" in titles

    # Viewer cannot see private entries
    async with AsyncClient(transport=transport, base_url="http://test") as viewer_c:
        await login_client(viewer_c, "viewer@tenanta.com")
        resp = await viewer_c.get("/api/v1/calendar/entries", headers=ORIGIN_HEADER)
        assert resp.status_code == 200
        titles = [e["title"] for e in resp.json()]
        assert "Private Meeting" not in titles

        # Direct access to private entry → 403
        resp = await viewer_c.get(f"/api/v1/calendar/entries/{entry_id}", headers=ORIGIN_HEADER)
        assert resp.status_code == 403
