"""Tests for OutlookSyncProvider — bidirectional Outlook Calendar sync (U7).

All Microsoft Graph API interactions are mocked via the ``client_factory``
injection point. No real HTTP calls are made.
"""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Any

import pytest

from agentkit.calendar.db import (
    get_event_by_external_id,
    init_calendar_db,
    insert_event,
    list_events,
)
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig
from agentkit.calendar.sync.outlook_provider import OutlookSyncProvider

USER_ID = "user-1"


# ---------------------------------------------------------------------------
# Mock helpers
# ---------------------------------------------------------------------------


class MockResponse:
    """Minimal mock of an httpx.Response."""

    def __init__(self, status_code: int = 200, json_data: Any = None) -> None:
        self.status_code = status_code
        self._json = json_data
        self.text = json.dumps(json_data) if json_data is not None else ""

    def json(self) -> Any:
        return self._json if self._json is not None else {}

    def raise_for_status(self) -> None:
        if self.status_code >= 400:
            raise RuntimeError(f"HTTP {self.status_code}")


class MockOutlookClient:
    """Mock httpx.AsyncClient — records requests, returns queued responses."""

    def __init__(self) -> None:
        self.requests: list[dict[str, Any]] = []
        self._responses: list[MockResponse] = []

    def add_response(self, response: MockResponse) -> None:
        self._responses.append(response)

    async def request(
        self,
        method: str,
        url: str,
        *,
        headers: Any = None,
        json: Any = None,
        params: Any = None,
        data: Any = None,
    ) -> MockResponse:
        self.requests.append(
            {
                "method": method,
                "url": url,
                "headers": headers,
                "json": json,
                "params": params,
                "data": data,
            }
        )
        if self._responses:
            return self._responses.pop(0)
        return MockResponse(status_code=200, json_data={})

    async def aclose(self) -> None:
        pass


def make_outlook_event(
    eid: str = "outlook-1",
    subject: str = "Outlook Event",
    start: str = "2026-07-01T10:00:00",
    end: str = "2026-07-01T11:00:00",
    is_all_day: bool = False,
    description: str = "",
    location: str = "",
    recurrence: dict[str, Any] | None = None,
    last_modified: str = "2026-06-01T00:00:00Z",
) -> dict[str, Any]:
    """Build a minimal Graph event JSON dict."""
    event: dict[str, Any] = {
        "id": eid,
        "subject": subject,
        "start": {"dateTime": start, "timeZone": "UTC"},
        "end": {"dateTime": end, "timeZone": "UTC"},
        "isAllDay": is_all_day,
        "lastModifiedDateTime": last_modified,
    }
    if description:
        event["body"] = {"contentType": "Text", "content": description}
    if location:
        event["location"] = {"displayName": location}
    if recurrence:
        event["recurrence"] = recurrence
    return event


def make_config(
    config_id: str = "config-1",
    user_id: str = USER_ID,
    provider: str = "outlook",
    last_sync: str | None = None,
    sync_token: str | None = None,
    access_token: str = "test-token",
    refresh_token: str = "test-refresh",
) -> ExternalCalendarConfig:
    return ExternalCalendarConfig(
        id=config_id,
        user_id=user_id,
        provider=provider,
        credentials=json.dumps(
            {
                "access_token": access_token,
                "refresh_token": refresh_token,
                "client_id": "test-client-id",
                "expires_at": "2026-12-31T00:00:00+00:00",
            }
        ),
        last_sync=last_sync,
        sync_token=sync_token,
    )


def make_local_event(
    event_id: str = "evt-1",
    title: str = "Local Event",
    external_id: str | None = None,
    last_modified: str = "2026-01-01T00:00:00+00:00",
    rrule: str | None = None,
) -> CalendarEvent:
    return CalendarEvent(
        id=event_id,
        user_id=USER_ID,
        title=title,
        start_time="2026-07-01T10:00:00+00:00",
        end_time="2026-07-01T11:00:00+00:00",
        external_id=external_id,
        external_provider="outlook" if external_id else None,
        last_modified=last_modified,
        created_at=last_modified,
        rrule=rrule,
    )


def client_factory_from(client: MockOutlookClient):
    """Wrap a mock client in a client_factory callable."""

    def factory() -> MockOutlookClient:
        return client

    return factory


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


@pytest.fixture
def calendar_db_path(tmp_path: Path) -> Path:
    path = tmp_path / "test_calendar.db"
    asyncio.run(init_calendar_db(path))
    return path


# ---------------------------------------------------------------------------
# Pull tests
# ---------------------------------------------------------------------------


async def test_outlook_provider_pull_delta_initial_sync(
    calendar_db_path: Path,
) -> None:
    """Mock returns 3 events → 3 local events created with external_id set."""
    events = [
        make_outlook_event(eid="outlook-1", subject="Event 1"),
        make_outlook_event(eid="outlook-2", subject="Event 2"),
        make_outlook_event(eid="outlook-3", subject="Event 3"),
    ]
    response = MockResponse(
        200,
        {
            "value": events,
            "@odata.deltaLink": (
                "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=initial-token"
            ),
        },
    )
    mock_client = MockOutlookClient()
    mock_client.add_response(response)

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config()

    pulled = await provider.pull_changes(config)

    assert len(pulled) == 3
    db_events = await list_events(USER_ID, db_path=calendar_db_path)
    assert len(db_events) == 3
    titles = {e.title for e in db_events}
    assert titles == {"Event 1", "Event 2", "Event 3"}
    for event in db_events:
        assert event.external_id is not None
        assert event.external_provider == "outlook"
    # Delta token stored for next incremental sync
    assert config.sync_token == "initial-token"


async def test_outlook_provider_pull_delta_incremental(
    calendar_db_path: Path,
) -> None:
    """Config has sync_token → incremental sync. 1 new + 1 updated processed."""
    # Pre-insert a local event that will be updated by the remote
    local = make_local_event(
        event_id="evt-1",
        title="Old Title",
        external_id="outlook-existing",
        last_modified="2026-01-01T00:00:00+00:00",
    )
    await insert_event(local, calendar_db_path)

    new_event = make_outlook_event(eid="outlook-new", subject="New Event")
    updated_event = make_outlook_event(
        eid="outlook-existing",
        subject="Updated Title",
        last_modified="2026-06-15T00:00:00Z",  # newer than local
    )
    response = MockResponse(
        200,
        {
            "value": [new_event, updated_event],
            "@odata.deltaLink": (
                "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=new-token"
            ),
        },
    )
    mock_client = MockOutlookClient()
    mock_client.add_response(response)

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config(sync_token="previous-token")

    pulled = await provider.pull_changes(config)

    assert len(pulled) == 2  # 1 new + 1 updated

    # Verify the request URL contains the delta token (incremental sync)
    assert "$deltaToken=previous-token" in mock_client.requests[0]["url"]

    # New event was created
    new_db = await get_event_by_external_id("outlook-new", "outlook", USER_ID, calendar_db_path)
    assert new_db is not None
    assert new_db.title == "New Event"

    # Existing event was updated (remote was newer)
    updated_db = await get_event_by_external_id(
        "outlook-existing", "outlook", USER_ID, calendar_db_path
    )
    assert updated_db is not None
    assert updated_db.title == "Updated Title"

    # Delta token updated
    assert config.sync_token == "new-token"


# ---------------------------------------------------------------------------
# Push tests
# ---------------------------------------------------------------------------


async def test_outlook_provider_push_creates_remote_event(
    calendar_db_path: Path,
) -> None:
    """Local event with no external_id → POST creates remote, ID stored."""
    local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None)
    await insert_event(local, calendar_db_path)

    mock_client = MockOutlookClient()
    mock_client.add_response(
        MockResponse(201, {"id": "remote-uid-1", "subject": "New Local Event"})
    )

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config()

    events = await list_events(USER_ID, db_path=calendar_db_path)
    pushed = await provider.push_changes(config, events)

    assert len(pushed) == 1
    assert pushed[0].external_id == "remote-uid-1"
    assert pushed[0].external_provider == "outlook"

    # Verify POST was called to /me/events
    req = mock_client.requests[0]
    assert req["method"] == "POST"
    assert "/me/events" in req["url"]
    assert req["json"]["subject"] == "New Local Event"

    # DB updated with external_id
    db_event = await get_event_by_external_id("remote-uid-1", "outlook", USER_ID, calendar_db_path)
    assert db_event is not None


async def test_outlook_provider_push_updates_remote_event(
    calendar_db_path: Path,
) -> None:
    """Local event with external_id → PATCH updates remote."""
    local = make_local_event(
        event_id="evt-1",
        title="Updated Local Event",
        external_id="existing-uid",
        last_modified="2026-06-01T00:00:00+00:00",
    )
    await insert_event(local, calendar_db_path)

    mock_client = MockOutlookClient()
    mock_client.add_response(MockResponse(200, {"id": "existing-uid"}))

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config()

    events = await list_events(USER_ID, db_path=calendar_db_path)
    pushed = await provider.push_changes(config, events)

    assert len(pushed) == 1
    assert pushed[0].external_id == "existing-uid"

    # Verify PATCH was called to /me/events/{id}
    req = mock_client.requests[0]
    assert req["method"] == "PATCH"
    assert "/me/events/existing-uid" in req["url"]
    assert req["json"]["subject"] == "Updated Local Event"


# ---------------------------------------------------------------------------
# Token refresh test
# ---------------------------------------------------------------------------


async def test_outlook_token_refresh_on_401(calendar_db_path: Path) -> None:
    """Mock 401 → refresh token used → request retried with new token."""
    mock_client = MockOutlookClient()
    # 1. First GET → 401 (token expired)
    mock_client.add_response(MockResponse(401, {"error": "token expired"}))
    # 2. Token refresh POST → 200 with new tokens
    mock_client.add_response(
        MockResponse(
            200,
            {
                "access_token": "new-token",
                "refresh_token": "new-refresh",
                "expires_in": 3600,
            },
        )
    )
    # 3. Retry GET → 200 with events
    mock_client.add_response(
        MockResponse(
            200,
            {
                "value": [make_outlook_event(eid="outlook-1", subject="Refreshed Event")],
                "@odata.deltaLink": (
                    "https://graph.microsoft.com/v1.0/me/calendarView/delta"
                    "?$deltaToken=after-refresh"
                ),
            },
        )
    )

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config(access_token="expired-token")

    pulled = await provider.pull_changes(config)

    assert len(pulled) == 1
    assert pulled[0].title == "Refreshed Event"

    # 3 requests: GET (401), POST (refresh), GET (retry)
    assert len(mock_client.requests) == 3
    assert mock_client.requests[0]["method"] == "GET"
    assert mock_client.requests[1]["method"] == "POST"
    assert mock_client.requests[2]["method"] == "GET"

    # Token refresh hit the Azure AD token endpoint
    assert "login.microsoftonline.com" in mock_client.requests[1]["url"]
    assert mock_client.requests[1]["data"]["grant_type"] == "refresh_token"
    assert mock_client.requests[1]["data"]["refresh_token"] == "test-refresh"

    # Credentials updated in config
    creds = json.loads(config.credentials)
    assert creds["access_token"] == "new-token"
    assert creds["refresh_token"] == "new-refresh"

    # Retry used the new access token
    retry_headers = mock_client.requests[2]["headers"]
    assert retry_headers["Authorization"] == "Bearer new-token"


# ---------------------------------------------------------------------------
# Conflict test
# ---------------------------------------------------------------------------


async def test_outlook_conflict_last_write_wins(calendar_db_path: Path) -> None:
    """Both sides modified, local is newer → local wins, local not updated."""
    local = make_local_event(
        event_id="evt-1",
        title="Local Updated",
        external_id="outlook-1",
        last_modified="2026-06-15T00:00:00+00:00",
    )
    await insert_event(local, calendar_db_path)

    remote = make_outlook_event(
        eid="outlook-1",
        subject="Remote Older",
        last_modified="2026-06-01T00:00:00Z",  # older than local
    )
    response = MockResponse(200, {"value": [remote]})
    mock_client = MockOutlookClient()
    mock_client.add_response(response)

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config()

    pulled = await provider.pull_changes(config)

    # Local wins → no update, pulled is empty
    assert len(pulled) == 0
    db_event = await get_event_by_external_id("outlook-1", "outlook", USER_ID, calendar_db_path)
    assert db_event is not None
    assert db_event.title == "Local Updated"


# ---------------------------------------------------------------------------
# RRULE roundtrip test
# ---------------------------------------------------------------------------


async def test_outlook_rrule_roundtrip(calendar_db_path: Path) -> None:
    """Recurring event synced → RRULE preserved in both pull and push."""
    recurrence = {
        "pattern": {
            "type": "weekly",
            "interval": 1,
            "daysOfWeek": ["monday"],
            "numberOfOccurrences": 4,
        },
        "range": {
            "type": "numbered",
            "startDate": "2026-07-01",
            "numberOfOccurrences": 4,
        },
    }
    remote = make_outlook_event(
        eid="outlook-rrule", subject="Weekly Meeting", recurrence=recurrence
    )
    response = MockResponse(
        200,
        {
            "value": [remote],
            "@odata.deltaLink": (
                "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=rrule-token"
            ),
        },
    )
    mock_client = MockOutlookClient()
    mock_client.add_response(response)

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config()

    # Pull: verify RRULE preserved
    pulled = await provider.pull_changes(config)
    assert len(pulled) == 1
    assert pulled[0].rrule is not None
    assert "FREQ=WEEKLY" in pulled[0].rrule
    assert "BYDAY=MO" in pulled[0].rrule
    assert "COUNT=4" in pulled[0].rrule

    # Push: verify recurrence pattern in request body sent to remote
    events = await list_events(USER_ID, db_path=calendar_db_path)
    mock_client2 = MockOutlookClient()
    mock_client2.add_response(
        MockResponse(201, {"id": "outlook-rrule", "subject": "Weekly Meeting"})
    )
    provider2 = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client2)
    )
    await provider2.push_changes(config, events)

    req = mock_client2.requests[0]
    assert req["method"] == "PATCH"
    assert "/me/events/outlook-rrule" in req["url"]
    body = req["json"]
    assert "recurrence" in body
    assert body["recurrence"]["pattern"]["type"] == "weekly"
    assert body["recurrence"]["pattern"]["daysOfWeek"] == ["monday"]
    assert body["recurrence"]["pattern"]["numberOfOccurrences"] == 4


# ---------------------------------------------------------------------------
# test_connection tests
# ---------------------------------------------------------------------------


async def test_outlook_test_connection_success(calendar_db_path: Path) -> None:
    """Mock Graph /me returns user profile → test_connection() returns (True, '')."""
    mock_client = MockOutlookClient()
    mock_client.add_response(MockResponse(200, {"id": "user-id", "displayName": "Test User"}))

    provider = OutlookSyncProvider(
        db_path=calendar_db_path, client_factory=client_factory_from(mock_client)
    )
    config = make_config()

    ok, msg = await provider.test_connection(config)
    assert ok is True
    assert msg == ""

    # Verify GET /me was called
    req = mock_client.requests[0]
    assert req["method"] == "GET"
    assert "/me" in req["url"]
