import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone


def _make_mock_session():
    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=False)
    mock_session.add = MagicMock()
    mock_session.commit = AsyncMock()
    mock_session.refresh = AsyncMock()
    return mock_session


def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
                     caldav_uid="", color="", duration_minutes=60):
    e = MagicMock()
    e.id = id
    e.user_id = user_id
    e.uid = uid
    e.title = title
    e.caldav_uid = caldav_uid
    e.color = color
    e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
    e.duration_minutes = duration_minutes
    # end_dt is derived; mirror the property's behavior on the mock so
    # service code that reads `event.end_dt` gets a sensible value.
    if duration_minutes is None:
        e.end_dt = None
    else:
        from datetime import timedelta
        e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
    e.all_day = False
    e.description = ""
    e.location = ""
    e.recurrence = None
    e.project_id = None
    e.to_dict.return_value = {
        "id": id, "uid": uid, "title": title,
        "caldav_uid": caldav_uid, "color": color,
        "start_dt": e.start_dt.isoformat(),
        "end_dt": e.end_dt.isoformat() if e.end_dt else None,
        "duration_minutes": duration_minutes,
    }
    return e


@pytest.mark.asyncio
async def test_create_event_stores_to_db():
    mock_session = _make_mock_session()
    with patch("fabledassistant.services.events.async_session") as mock_cls, \
         patch("fabledassistant.services.events.asyncio.create_task") as mock_task:
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import create_event
        result = await create_event(
            user_id=1,
            title="Dentist",
            start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc),
        )
        assert mock_session.add.called
        assert mock_session.commit.called
        # CalDAV push background task should be scheduled
        assert mock_task.called


@pytest.mark.asyncio
async def test_find_events_by_query_returns_ilike_results():
    mock_event = _make_mock_event(title="Team Meeting")
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalars.return_value.all.return_value = [mock_event]
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls:
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import find_events_by_query
        results = await find_events_by_query(user_id=1, query="meeting")
        assert len(results) == 1
        assert results[0].title == "Team Meeting"


@pytest.mark.asyncio
async def test_list_events_returns_events_in_range():
    mock_event = _make_mock_event()
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalars.return_value.all.return_value = [mock_event]
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls:
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import list_events
        results = await list_events(
            user_id=1,
            date_from=datetime(2026, 3, 1, tzinfo=timezone.utc),
            date_to=datetime(2026, 3, 31, tzinfo=timezone.utc),
        )
        assert len(results) == 1


@pytest.mark.asyncio
async def test_delete_event_fires_caldav_push_when_uid_set():
    mock_event = _make_mock_event(caldav_uid="sync-uid")
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalar_one_or_none.return_value = mock_event
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls, \
         patch("fabledassistant.services.events.asyncio.create_task") as mock_task:
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import delete_event
        await delete_event(user_id=1, event_id=1)
        # Push task fired because caldav_uid is set
        assert mock_task.called


@pytest.mark.asyncio
async def test_update_event_fires_caldav_push():
    mock_event = _make_mock_event(caldav_uid="sync-uid")
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalar_one_or_none.return_value = mock_event
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls, \
         patch("fabledassistant.services.events.asyncio.create_task") as mock_task:
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import update_event
        await update_event(user_id=1, event_id=1, title="Updated Title")
        assert mock_task.called


# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────


def test_normalize_duration_from_end_dt():
    """end_dt sugar converts to a positive minute count anchored on start."""
    from fabledassistant.services.events import _normalize_duration
    start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
    end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
    assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90


def test_normalize_duration_zero_is_valid_point_event():
    """end_dt == start_dt → duration 0. The point-with-zero-duration case
    is rare but legal (e.g. an instant marker); the duration model treats
    it the same as duration None for display purposes."""
    from fabledassistant.services.events import _normalize_duration
    same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
    assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0


def test_normalize_duration_rejects_end_before_start():
    """The exact 2026-04-29 prod failure: end 32 days before start.
    The duration model makes this inexpressible at the schema level
    via a CHECK constraint, but write-path callers still get a
    helpful ValueError if they construct an inconsistent (start, end)
    pair via the end_dt sugar."""
    from fabledassistant.services.events import _normalize_duration
    start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
    end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
    with pytest.raises(ValueError, match="at or after start_dt"):
        _normalize_duration(
            start_dt=start, end_dt=end_before, duration_minutes=None,
        )


def test_normalize_duration_rejects_negative_duration():
    """Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
    constraint at the service boundary so callers get a clean error
    rather than a constraint violation from psycopg."""
    from fabledassistant.services.events import _normalize_duration
    start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
    with pytest.raises(ValueError, match="must be >= 0"):
        _normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)


def test_normalize_duration_rejects_inconsistent_end_and_duration():
    """If a caller passes both end_dt AND duration_minutes that disagree,
    the inconsistency is surfaced rather than silently picking one."""
    from fabledassistant.services.events import _normalize_duration
    start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
    end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc)  # implies 60 min
    with pytest.raises(ValueError, match="implies 60 minutes"):
        _normalize_duration(
            start_dt=start, end_dt=end, duration_minutes=30,
        )


def test_normalize_duration_none_for_open_ended():
    """Both inputs None → None duration (open-ended event)."""
    from fabledassistant.services.events import _normalize_duration
    start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
    assert _normalize_duration(
        start_dt=start, end_dt=None, duration_minutes=None,
    ) is None


@pytest.mark.asyncio
async def test_create_event_rejects_end_before_start():
    """Service-level rejection — same scenario as the prod bug, surfaced
    cleanly for tool / route callers via ValueError."""
    from fabledassistant.services.events import create_event
    start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
    end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
    with pytest.raises(ValueError, match="at or after start_dt"):
        await create_event(
            user_id=1, title="Bad",
            start_dt=start, end_dt=end_before,
        )


@pytest.mark.asyncio
async def test_update_event_preserves_duration_when_only_start_changes():
    """Sliding semantics: when the user moves an event by changing only
    start_dt, the existing duration_minutes is preserved as-is. The new
    effective end_dt slides forward with the start. This is a behavioral
    upgrade vs. the old end_dt model, where moving start past the
    stored end made the event 'go backward in time'."""
    mock_event = _make_mock_event(duration_minutes=60)  # start 10:00, end 11:00
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalar_one_or_none.return_value = mock_event
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls, \
         patch("fabledassistant.services.events.asyncio.create_task"):
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import update_event
        # Move start to 12:00; effective end becomes 13:00 automatically.
        result = await update_event(
            user_id=1, event_id=1,
            start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
        )
    assert result is not None
    # duration_minutes was NOT touched; mock_event still has 60.
    assert mock_event.duration_minutes == 60


@pytest.mark.asyncio
async def test_update_event_clearing_end_dt_clears_duration():
    """Passing end_dt=None on update is the documented way to clear the
    end (turn a timed event into a point event). The service must
    translate that into duration_minutes=None, not leave the prior
    value in place."""
    mock_event = _make_mock_event(duration_minutes=60)
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalar_one_or_none.return_value = mock_event
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls, \
         patch("fabledassistant.services.events.asyncio.create_task"):
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import update_event
        await update_event(user_id=1, event_id=1, end_dt=None)
    assert mock_event.duration_minutes is None


@pytest.mark.asyncio
async def test_list_events_includes_point_event_in_window():
    """A point event (duration_minutes=None) surfaces when its start
    is in the window. Replaces the prior 'corrupt end_dt' regression
    test — the duration model can't represent that state, but the
    same code path is exercised here for point events."""
    mock_event = _make_mock_event(duration_minutes=None)
    # Point event in the upcoming window
    mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
    mock_event.end_dt = None
    mock_event.to_dict.return_value = {
        "id": 1, "title": "Point",
        "start_dt": mock_event.start_dt.isoformat(),
        "end_dt": None,
        "duration_minutes": None,
    }
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalars.return_value.all.return_value = [mock_event]
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls:
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import list_events
        results = await list_events(
            user_id=1,
            date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
            date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
        )
        assert len(results) == 1
        assert results[0]["id"] == 1


@pytest.mark.asyncio
async def test_list_events_excludes_timed_event_that_already_ended():
    """A timed event whose start + duration is before the window must
    NOT surface. Verifies the Python-side refinement actually works
    against the coarse SQL prefilter."""
    mock_event = _make_mock_event(duration_minutes=60)
    # Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
    mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
    mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
    mock_event.to_dict.return_value = {
        "id": 1, "start_dt": mock_event.start_dt.isoformat(),
        "end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
    }
    mock_session = _make_mock_session()
    mock_result = MagicMock()
    mock_result.scalars.return_value.all.return_value = [mock_event]
    mock_session.execute = AsyncMock(return_value=mock_result)

    with patch("fabledassistant.services.events.async_session") as mock_cls:
        mock_cls.return_value = mock_session
        from fabledassistant.services.events import list_events
        results = await list_events(
            user_id=1,
            date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
            date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
        )
        assert results == []


@pytest.mark.asyncio
async def test_tools_calendar_always_available():
    """Calendar tools must appear in get_tools_for_user even without CalDAV."""
    with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock) as mock_configured, \
         patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock) as mock_setting:
        mock_configured.return_value = False
        mock_setting.return_value = "false"
        from fabledassistant.services.tools import get_tools_for_user
        tools = await get_tools_for_user(user_id=1)
        tool_names = {t["function"]["name"] for t in tools}
        assert "create_event" in tool_names
        assert "list_events" in tool_names
        assert "search_events" in tool_names
        assert "update_event" in tool_names
        assert "delete_event" in tool_names
