"""The tests for the Google Calendar component."""
from __future__ import annotations

from collections.abc import Awaitable, Callable
import datetime
from typing import Any
from unittest.mock import Mock, call, patch

from oauth2client.client import (
    FlowExchangeError,
    OAuth2Credentials,
    OAuth2DeviceCodeError,
)
import pytest

from homeassistant.components.google import (
    DOMAIN,
    SERVICE_ADD_EVENT,
    SERVICE_SCAN_CALENDARS,
)
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, State
from homeassistant.util.dt import utcnow

from .conftest import (
    CALENDAR_ID,
    TEST_API_ENTITY,
    TEST_API_ENTITY_NAME,
    TEST_YAML_ENTITY,
    TEST_YAML_ENTITY_NAME,
    ApiResult,
    ComponentSetup,
    YieldFixture,
)

from tests.common import async_fire_time_changed

# Typing helpers
HassApi = Callable[[], Awaitable[dict[str, Any]]]

CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)


@pytest.fixture
async def code_expiration_delta() -> datetime.timedelta:
    """Fixture for code expiration time, defaulting to the future."""
    return datetime.timedelta(minutes=3)


@pytest.fixture
async def mock_code_flow(
    code_expiration_delta: datetime.timedelta,
) -> YieldFixture[Mock]:
    """Fixture for initiating OAuth flow."""
    with patch(
        "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
    ) as mock_flow:
        mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
        mock_flow.return_value.interval = CODE_CHECK_INTERVAL
        yield mock_flow


@pytest.fixture
async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
    """Fixture for mocking out the exchange for credentials."""
    with patch(
        "oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
    ) as mock:
        yield mock


@pytest.fixture
async def mock_notification() -> YieldFixture[Mock]:
    """Fixture for capturing persistent notifications."""
    with patch("homeassistant.components.persistent_notification.create") as mock:
        yield mock


async def fire_alarm(hass, point_in_time):
    """Fire an alarm and wait for callbacks to run."""
    with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
        async_fire_time_changed(hass, point_in_time)
        await hass.async_block_till_done()


@pytest.mark.parametrize("config", [{}])
async def test_setup_config_empty(
    hass: HomeAssistant,
    component_setup: ComponentSetup,
    mock_notification: Mock,
):
    """Test setup component with an empty configuruation."""
    assert await component_setup()

    mock_notification.assert_not_called()

    assert not hass.states.get(TEST_YAML_ENTITY)


def assert_state(actual: State | None, expected: State | None) -> None:
    """Assert that the two states are equal."""
    if actual is None:
        assert actual == expected
        return
    assert actual.entity_id == expected.entity_id
    assert actual.state == expected.state
    assert actual.attributes == expected.attributes


async def test_init_success(
    hass: HomeAssistant,
    mock_code_flow: Mock,
    mock_exchange: Mock,
    mock_notification: Mock,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_calendars_yaml: None,
    component_setup: ComponentSetup,
) -> None:
    """Test successful creds setup."""
    mock_calendars_list({"items": [test_api_calendar]})
    assert await component_setup()

    # Run one tick to invoke the credential exchange check
    now = utcnow()
    await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)

    state = hass.states.get(TEST_YAML_ENTITY)
    assert state
    assert state.name == TEST_YAML_ENTITY_NAME
    assert state.state == STATE_OFF

    mock_notification.assert_called()
    assert "We are all setup now" in mock_notification.call_args[0][1]


async def test_code_error(
    hass: HomeAssistant,
    mock_code_flow: Mock,
    component_setup: ComponentSetup,
    mock_notification: Mock,
) -> None:
    """Test loading the integration with no existing credentials."""

    with patch(
        "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
        side_effect=OAuth2DeviceCodeError("Test Failure"),
    ):
        assert await component_setup()

    assert not hass.states.get(TEST_YAML_ENTITY)

    mock_notification.assert_called()
    assert "Error: Test Failure" in mock_notification.call_args[0][1]


@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
async def test_expired_after_exchange(
    hass: HomeAssistant,
    mock_code_flow: Mock,
    component_setup: ComponentSetup,
    mock_notification: Mock,
) -> None:
    """Test loading the integration with no existing credentials."""

    assert await component_setup()

    now = utcnow()
    await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)

    assert not hass.states.get(TEST_YAML_ENTITY)

    mock_notification.assert_called()
    assert (
        "Authentication code expired, please restart Home-Assistant and try again"
        in mock_notification.call_args[0][1]
    )


async def test_exchange_error(
    hass: HomeAssistant,
    mock_code_flow: Mock,
    component_setup: ComponentSetup,
    mock_notification: Mock,
) -> None:
    """Test an error while exchanging the code for credentials."""

    with patch(
        "oauth2client.client.OAuth2WebServerFlow.step2_exchange",
        side_effect=FlowExchangeError(),
    ):
        assert await component_setup()

        now = utcnow()
        await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)

    assert not hass.states.get(TEST_YAML_ENTITY)

    mock_notification.assert_called()
    assert "In order to authorize Home-Assistant" in mock_notification.call_args[0][1]


async def test_existing_token(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_yaml: None,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_notification: Mock,
) -> None:
    """Test setup with an existing token file."""
    mock_calendars_list({"items": [test_api_calendar]})
    assert await component_setup()

    state = hass.states.get(TEST_YAML_ENTITY)
    assert state
    assert state.name == TEST_YAML_ENTITY_NAME
    assert state.state == STATE_OFF

    mock_notification.assert_not_called()


@pytest.mark.parametrize(
    "token_scopes", ["https://www.googleapis.com/auth/calendar.readonly"]
)
async def test_existing_token_missing_scope(
    hass: HomeAssistant,
    token_scopes: list[str],
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_yaml: None,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_notification: Mock,
    mock_code_flow: Mock,
    mock_exchange: Mock,
) -> None:
    """Test setup where existing token does not have sufficient scopes."""
    mock_calendars_list({"items": [test_api_calendar]})
    assert await component_setup()

    # Run one tick to invoke the credential exchange check
    now = utcnow()
    await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
    assert len(mock_exchange.mock_calls) == 1

    state = hass.states.get(TEST_YAML_ENTITY)
    assert state
    assert state.name == TEST_YAML_ENTITY_NAME
    assert state.state == STATE_OFF

    # No notifications on success
    mock_notification.assert_called()
    assert "We are all setup now" in mock_notification.call_args[0][1]


@pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]])
async def test_calendar_yaml_missing_required_fields(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    calendars_config: list[dict[str, Any]],
    mock_calendars_yaml: None,
    mock_notification: Mock,
) -> None:
    """Test setup with a missing schema fields, ignores the error and continues."""
    assert await component_setup()

    assert not hass.states.get(TEST_YAML_ENTITY)

    mock_notification.assert_not_called()


@pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]])
async def test_invalid_calendar_yaml(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    calendars_config: list[dict[str, Any]],
    mock_calendars_yaml: None,
    mock_notification: Mock,
) -> None:
    """Test setup with missing entity id fields fails to setup the integration."""

    # Integration fails to setup
    assert not await component_setup()

    assert not hass.states.get(TEST_YAML_ENTITY)

    mock_notification.assert_not_called()


async def test_calendar_yaml_error(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_notification: Mock,
) -> None:
    """Test setup with yaml file not found."""

    mock_calendars_list({"items": [test_api_calendar]})

    with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
        assert await component_setup()

    assert not hass.states.get(TEST_YAML_ENTITY)
    assert hass.states.get(TEST_API_ENTITY)


@pytest.mark.parametrize(
    "google_config_track_new,calendars_config,expected_state",
    [
        (
            None,
            [],
            State(
                TEST_API_ENTITY,
                STATE_OFF,
                attributes={
                    "offset_reached": False,
                    "friendly_name": TEST_API_ENTITY_NAME,
                },
            ),
        ),
        (
            True,
            [],
            State(
                TEST_API_ENTITY,
                STATE_OFF,
                attributes={
                    "offset_reached": False,
                    "friendly_name": TEST_API_ENTITY_NAME,
                },
            ),
        ),
        (False, [], None),
    ],
    ids=["default", "True", "False"],
)
async def test_track_new(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_calendars_yaml: None,
    expected_state: State,
) -> None:
    """Test behavior of configuration.yaml settings for tracking new calendars not in the config."""

    mock_calendars_list({"items": [test_api_calendar]})
    assert await component_setup()

    state = hass.states.get(TEST_API_ENTITY)
    assert_state(state, expected_state)


@pytest.mark.parametrize("calendars_config", [[]])
async def test_found_calendar_from_api(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_yaml: None,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
) -> None:
    """Test finding a calendar from the API."""

    mock_calendars_list({"items": [test_api_calendar]})
    assert await component_setup()

    state = hass.states.get(TEST_API_ENTITY)
    assert state
    assert state.name == TEST_API_ENTITY_NAME
    assert state.state == STATE_OFF

    # No yaml config loaded that overwrites the entity name
    assert not hass.states.get(TEST_YAML_ENTITY)


@pytest.mark.parametrize(
    "calendars_config_track,expected_state",
    [
        (
            True,
            State(
                TEST_YAML_ENTITY,
                STATE_OFF,
                attributes={
                    "offset_reached": False,
                    "friendly_name": TEST_YAML_ENTITY_NAME,
                },
            ),
        ),
        (False, None),
    ],
)
async def test_calendar_config_track_new(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_yaml: None,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    calendars_config_track: bool,
    expected_state: State,
) -> None:
    """Test calendar config that overrides whether or not a calendar is tracked."""

    mock_calendars_list({"items": [test_api_calendar]})
    assert await component_setup()

    state = hass.states.get(TEST_YAML_ENTITY)
    assert_state(state, expected_state)


async def test_add_event(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_insert_event: Mock,
) -> None:
    """Test service call that adds an event."""

    assert await component_setup()

    await hass.services.async_call(
        DOMAIN,
        SERVICE_ADD_EVENT,
        {
            "calendar_id": CALENDAR_ID,
            "summary": "Summary",
            "description": "Description",
        },
        blocking=True,
    )
    mock_insert_event.assert_called()
    assert mock_insert_event.mock_calls[0] == call(
        calendarId=CALENDAR_ID,
        body={
            "summary": "Summary",
            "description": "Description",
            "start": {},
            "end": {},
        },
    )


@pytest.mark.parametrize(
    "date_fields,start_timedelta,end_timedelta",
    [
        (
            {"in": {"days": 3}},
            datetime.timedelta(days=3),
            datetime.timedelta(days=4),
        ),
        (
            {"in": {"weeks": 1}},
            datetime.timedelta(days=7),
            datetime.timedelta(days=8),
        ),
    ],
    ids=["in_days", "in_weeks"],
)
async def test_add_event_date_in_x(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_insert_event: Mock,
    date_fields: dict[str, Any],
    start_timedelta: datetime.timedelta,
    end_timedelta: datetime.timedelta,
) -> None:
    """Test service call that adds an event with various time ranges."""

    assert await component_setup()

    await hass.services.async_call(
        DOMAIN,
        SERVICE_ADD_EVENT,
        {
            "calendar_id": CALENDAR_ID,
            "summary": "Summary",
            "description": "Description",
            **date_fields,
        },
        blocking=True,
    )
    mock_insert_event.assert_called()

    now = datetime.datetime.now()
    start_date = now + start_timedelta
    end_date = now + end_timedelta

    assert mock_insert_event.mock_calls[0] == call(
        calendarId=CALENDAR_ID,
        body={
            "summary": "Summary",
            "description": "Description",
            "start": {"date": start_date.date().isoformat()},
            "end": {"date": end_date.date().isoformat()},
        },
    )


async def test_add_event_date(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_list: ApiResult,
    mock_insert_event: Mock,
) -> None:
    """Test service call that sets a date range."""

    assert await component_setup()

    now = utcnow()
    today = now.date()
    end_date = today + datetime.timedelta(days=2)

    await hass.services.async_call(
        DOMAIN,
        SERVICE_ADD_EVENT,
        {
            "calendar_id": CALENDAR_ID,
            "summary": "Summary",
            "description": "Description",
            "start_date": today.isoformat(),
            "end_date": end_date.isoformat(),
        },
        blocking=True,
    )
    mock_insert_event.assert_called()

    assert mock_insert_event.mock_calls[0] == call(
        calendarId=CALENDAR_ID,
        body={
            "summary": "Summary",
            "description": "Description",
            "start": {"date": today.isoformat()},
            "end": {"date": end_date.isoformat()},
        },
    )


async def test_add_event_date_time(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
    mock_insert_event: Mock,
) -> None:
    """Test service call that adds an event with a date time range."""

    assert await component_setup()

    start_datetime = datetime.datetime.now()
    delta = datetime.timedelta(days=3, hours=3)
    end_datetime = start_datetime + delta

    await hass.services.async_call(
        DOMAIN,
        SERVICE_ADD_EVENT,
        {
            "calendar_id": CALENDAR_ID,
            "summary": "Summary",
            "description": "Description",
            "start_date_time": start_datetime.isoformat(),
            "end_date_time": end_datetime.isoformat(),
        },
        blocking=True,
    )
    mock_insert_event.assert_called()

    assert mock_insert_event.mock_calls[0] == call(
        calendarId=CALENDAR_ID,
        body={
            "summary": "Summary",
            "description": "Description",
            "start": {
                "dateTime": start_datetime.isoformat(timespec="seconds"),
                "timeZone": "CST",
            },
            "end": {
                "dateTime": end_datetime.isoformat(timespec="seconds"),
                "timeZone": "CST",
            },
        },
    )


async def test_scan_calendars(
    hass: HomeAssistant,
    mock_token_read: None,
    component_setup: ComponentSetup,
    mock_calendars_list: ApiResult,
    test_api_calendar: dict[str, Any],
) -> None:
    """Test finding a calendar from the API."""

    assert await component_setup()

    calendar_1 = {
        "id": "calendar-id-1",
        "summary": "Calendar 1",
    }
    calendar_2 = {
        "id": "calendar-id-2",
        "summary": "Calendar 2",
    }

    mock_calendars_list({"items": [calendar_1]})
    await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True)
    await hass.async_block_till_done()

    state = hass.states.get("calendar.calendar_1")
    assert state
    assert state.name == "Calendar 1"
    assert state.state == STATE_OFF
    assert not hass.states.get("calendar.calendar_2")

    mock_calendars_list({"items": [calendar_1, calendar_2]})
    await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True)
    await hass.async_block_till_done()

    state = hass.states.get("calendar.calendar_1")
    assert state
    assert state.name == "Calendar 1"
    assert state.state == STATE_OFF
    state = hass.states.get("calendar.calendar_2")
    assert state
    assert state.name == "Calendar 2"
    assert state.state == STATE_OFF
