"""Tests for calendar trigger provider."""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

import pytest

from noteflow.domain.triggers.entities import TriggerSource
from noteflow.infrastructure.triggers.calendar import (
    CalendarEvent,
    CalendarProvider,
    CalendarTriggerSettings,
    parse_calendar_event_config,
)
from tests.conftest import approx_float

# Test constants for non-iterable input validation
NON_ITERABLE_TEST_VALUE = 12345
"""Integer value used to test that non-iterable input returns empty list."""


def _settings(
    *,
    enabled: bool = True,
    weight: float = 0.3,
    lookahead_minutes: int = 5,
    lookbehind_minutes: int = 5,
    events: list[CalendarEvent] | None = None,
) -> CalendarTriggerSettings:
    """Create CalendarTriggerSettings with defaults and overrides."""
    return CalendarTriggerSettings(
        enabled=enabled,
        weight=weight,
        lookahead_minutes=lookahead_minutes,
        lookbehind_minutes=lookbehind_minutes,
        events=events or [],
    )


def _event(
    *,
    minutes_from_now: int = 0,
    duration_minutes: int = 60,
    title: str | None = "Test Meeting",
) -> CalendarEvent:
    """Create a CalendarEvent relative to current time."""
    now = datetime.now(UTC)
    start = now + timedelta(minutes=minutes_from_now)
    end = start + timedelta(minutes=duration_minutes)
    return CalendarEvent(start=start, end=end, title=title)


class TestCalendarProviderProperties:
    """Test CalendarProvider property accessors."""

    def test_source_property(self) -> None:
        """Provider source should be CALENDAR."""
        provider = CalendarProvider(_settings())
        assert provider.source == TriggerSource.CALENDAR, "source should be CALENDAR"

    def test_max_weight_property(self) -> None:
        """Provider max_weight should reflect configured weight."""
        provider = CalendarProvider(_settings(weight=0.5))
        assert provider.max_weight == approx_float(0.5), "max_weight should match settings"

    @pytest.mark.parametrize(
        ("enabled", "expected"),
        [
            pytest.param(True, True, id="enabled-returns-true"),
            pytest.param(False, False, id="disabled-returns-false"),
        ],
    )
    def test_is_enabled_reflects_settings(self, enabled: bool, expected: bool) -> None:
        """is_enabled should reflect settings.enabled."""
        provider = CalendarProvider(_settings(enabled=enabled))
        assert provider.is_enabled() is expected, f"is_enabled should return {expected} when enabled={enabled}"


class TestCalendarProviderGetSignal:
    """Test CalendarProvider.get_signal() behavior."""

    def test_disabled_returns_none(self) -> None:
        """Disabled provider should return None."""
        event = _event(minutes_from_now=2)
        provider = CalendarProvider(_settings(enabled=False, events=[event]))
        assert provider.get_signal() is None, "disabled provider should return None"

    def test_no_events_returns_none(self) -> None:
        """Provider with no events should return None."""
        provider = CalendarProvider(_settings(events=[]))
        assert provider.get_signal() is None, "no events should return None"

    def test_event_in_lookahead_window(self) -> None:
        """Event starting within lookahead window should trigger signal."""
        event = _event(minutes_from_now=3)
        provider = CalendarProvider(_settings(lookahead_minutes=5, events=[event]))

        signal = provider.get_signal()

        assert signal is not None, "Expected signal for event in lookahead window"
        assert signal.source == TriggerSource.CALENDAR, "Signal source should be CALENDAR"
        assert signal.weight == approx_float(0.3), "Signal weight should match configured weight"
        assert signal.app_name == "Test Meeting", "Signal app_name should be event title"

    def test_event_in_lookbehind_window(self) -> None:
        """Event that started recently should trigger signal."""
        event = _event(minutes_from_now=-3, duration_minutes=60)
        provider = CalendarProvider(_settings(lookbehind_minutes=5, events=[event]))

        signal = provider.get_signal()

        assert signal is not None, "recently started event should trigger"
        assert signal.app_name == "Test Meeting", "signal should include event title"

    def test_event_outside_window_returns_none(self) -> None:
        """Event outside lookahead/lookbehind window should not trigger."""
        event = _event(minutes_from_now=20)
        provider = CalendarProvider(
            _settings(lookahead_minutes=5, lookbehind_minutes=5, events=[event])
        )
        assert provider.get_signal() is None, "future event outside window returns None"

    def test_past_event_outside_lookbehind_returns_none(self) -> None:
        """Past event outside lookbehind window should not trigger."""
        event = _event(minutes_from_now=-20, duration_minutes=10)
        provider = CalendarProvider(_settings(lookbehind_minutes=5, events=[event]))
        assert provider.get_signal() is None, "past event outside lookbehind returns None"

    def test_first_matching_event_returned(self) -> None:
        """When multiple events match, first one should be returned."""
        event1 = _event(minutes_from_now=2, title="First Meeting")
        event2 = _event(minutes_from_now=3, title="Second Meeting")
        provider = CalendarProvider(_settings(events=[event1, event2]))

        signal = provider.get_signal()

        assert signal is not None, "signal should be returned for matching events"
        assert signal.app_name == "First Meeting", "first matching event should be returned"

    def test_event_with_none_title(self) -> None:
        """Event without title should still trigger signal."""
        event = _event(minutes_from_now=2, title=None)
        provider = CalendarProvider(_settings(events=[event]))

        signal = provider.get_signal()

        assert signal is not None, "signal should be returned for event without title"
        assert signal.app_name is None, "app_name should be None when event has no title"


class TestEventOverlapWindow:
    """Test CalendarProvider._event_overlaps_window() logic."""

    @pytest.mark.parametrize(
        ("event_offset", "event_duration", "lookahead", "lookbehind"),
        [
            pytest.param(2, 60, 5, 5, id="starts-in-lookahead"),
            pytest.param(-2, 60, 5, 5, id="started-in-lookbehind"),
            pytest.param(-3, 60, 5, 5, id="ongoing-meeting"),
            pytest.param(-5, 10, 5, 5, id="ends-exactly-at-window-start"),
            pytest.param(5, 60, 5, 5, id="starts-exactly-at-window-end"),
            pytest.param(-100, 200, 5, 5, id="spans-entire-window"),
        ],
    )
    def test_overlap_triggers_signal(
        self,
        event_offset: int,
        event_duration: int,
        lookahead: int,
        lookbehind: int,
    ) -> None:
        """Test scenarios where event overlaps window and should trigger signal."""
        event = _event(minutes_from_now=event_offset, duration_minutes=event_duration)
        provider = CalendarProvider(
            _settings(
                lookahead_minutes=lookahead,
                lookbehind_minutes=lookbehind,
                events=[event],
            )
        )

        signal = provider.get_signal()

        assert signal is not None, "Expected signal to be triggered for overlapping event"

    @pytest.mark.parametrize(
        ("event_offset", "event_duration", "lookahead", "lookbehind"),
        [
            pytest.param(-70, 60, 5, 5, id="ended-before-lookbehind"),
            pytest.param(20, 60, 5, 5, id="starts-after-lookahead"),
        ],
    )
    def test_no_overlap_returns_none(
        self,
        event_offset: int,
        event_duration: int,
        lookahead: int,
        lookbehind: int,
    ) -> None:
        """Test scenarios where event does not overlap window and should return None."""
        event = _event(minutes_from_now=event_offset, duration_minutes=event_duration)
        provider = CalendarProvider(
            _settings(
                lookahead_minutes=lookahead,
                lookbehind_minutes=lookbehind,
                events=[event],
            )
        )

        signal = provider.get_signal()

        assert signal is None, "Expected no signal for non-overlapping event"


class TestTimezoneHandling:
    """Test timezone handling in CalendarProvider."""

    def test_naive_datetime_treated_as_utc(self) -> None:
        """Naive datetime should be treated as UTC."""
        now = datetime.now(UTC)
        naive_start = now.replace(tzinfo=None) + timedelta(minutes=2)
        naive_end = naive_start + timedelta(minutes=60)

        event = CalendarEvent(start=naive_start, end=naive_end, title="Naive Meeting")
        provider = CalendarProvider(_settings(events=[event]))

        signal = provider.get_signal()

        assert signal is not None, "naive datetime event should trigger"

    def test_utc_datetime_works(self) -> None:
        """UTC datetime should work correctly."""
        now = datetime.now(UTC)
        event = CalendarEvent(
            start=now + timedelta(minutes=2),
            end=now + timedelta(minutes=62),
            title="UTC Meeting",
        )
        provider = CalendarProvider(_settings(events=[event]))

        signal = provider.get_signal()

        assert signal is not None, "UTC datetime event should trigger"


class TestParseCalendarEventConfig:
    """Test parse_calendar_event_config() helper function."""

    def test_none_input_returns_empty(self) -> None:
        """None input should return empty list."""
        assert parse_calendar_event_config(None) == [], "None input should return empty list"

    def test_json_string_single_event(self) -> None:
        """JSON string with single event should parse correctly."""
        json_str = '{"start": "2025-01-01T10:00:00Z", "end": "2025-01-01T11:00:00Z", "title": "Meeting"}'

        events = parse_calendar_event_config(json_str)

        assert len(events) == 1, "should parse single event from JSON"
        assert events[0].title == "Meeting", "event title should match"

    def test_json_string_multiple_events(self) -> None:
        """JSON string with multiple events should parse correctly."""
        json_str = """[
            {"start": "2025-01-01T10:00:00Z", "end": "2025-01-01T11:00:00Z", "title": "Meeting 1"},
            {"start": "2025-01-01T12:00:00Z", "end": "2025-01-01T13:00:00Z", "title": "Meeting 2"}
        ]"""

        events = parse_calendar_event_config(json_str)

        assert len(events) == 2, "should parse both events from JSON array"
        assert events[0].title == "Meeting 1", "first event title should match"
        assert events[1].title == "Meeting 2", "second event title should match"

    def test_invalid_json_returns_empty(self) -> None:
        """Invalid JSON should return empty list."""
        assert parse_calendar_event_config("not valid json") == [], "invalid JSON returns empty"

    def test_dict_input_converted_to_list(self) -> None:
        """Single dict input should be converted to single-item list."""
        event_dict = {
            "start": datetime.now(UTC),
            "end": datetime.now(UTC) + timedelta(hours=1),
            "title": "Direct Dict",
        }

        events = parse_calendar_event_config(event_dict)

        assert len(events) == 1, "dict should convert to single-item list"
        assert events[0].title == "Direct Dict", "event title should match"

    def test_list_of_dicts(self) -> None:
        """List of dicts should parse correctly."""
        now = datetime.now(UTC)
        event_dicts = [
            {"start": now, "end": now + timedelta(hours=1), "title": "Event 1"},
            {"start": now + timedelta(hours=2), "end": now + timedelta(hours=3), "title": "Event 2"},
        ]

        events = parse_calendar_event_config(event_dicts)

        assert len(events) == 2, "should parse both dicts"

    def test_list_of_calendar_events_passthrough(self) -> None:
        """List of CalendarEvent objects should pass through unchanged."""
        now = datetime.now(UTC)
        original_events = [
            CalendarEvent(start=now, end=now + timedelta(hours=1), title="Event 1"),
            CalendarEvent(start=now + timedelta(hours=2), end=now + timedelta(hours=3), title="Event 2"),
        ]

        events = parse_calendar_event_config(original_events)

        assert len(events) == 2, "should return both events"
        assert events[0] is original_events[0], "first event should be same object"
        assert events[1] is original_events[1], "second event should be same object"

    def test_missing_start_or_end_skipped(self) -> None:
        """Events without start or end should be skipped."""
        event_dicts = [
            {"start": datetime.now(UTC), "title": "Missing End"},
            {"end": datetime.now(UTC), "title": "Missing Start"},
            {"start": datetime.now(UTC), "end": datetime.now(UTC) + timedelta(hours=1), "title": "Complete"},
        ]

        events = parse_calendar_event_config(event_dicts)

        assert len(events) == 1, "only complete event should be parsed"
        assert events[0].title == "Complete", "complete event title should match"

    @pytest.mark.parametrize(
        ("date_str", "expected_count"),
        [
            pytest.param("2025-01-01T10:00:00Z", 1, id="iso-with-z-suffix"),
            pytest.param("2025-01-01T10:00:00+00:00", 1, id="iso-with-offset"),
            pytest.param("2025-01-01T10:00:00", 1, id="iso-naive"),
            pytest.param("invalid-date", 0, id="invalid-date-string"),
            pytest.param("", 0, id="empty-string"),
        ],
    )
    def test_datetime_parsing_formats(self, date_str: str, expected_count: int) -> None:
        """Test various datetime string formats."""
        event_dict = {"start": date_str, "end": "2025-01-01T11:00:00Z", "title": "Test"}

        events = parse_calendar_event_config(event_dict)

        assert len(events) == expected_count, f"date format '{date_str}' should parse to {expected_count} events"

    def test_non_iterable_returns_empty(self) -> None:
        """Non-iterable input should return empty list."""
        assert parse_calendar_event_config(NON_ITERABLE_TEST_VALUE) == [], "non-iterable returns empty"
