"""Tests for the quiet-hours deferred-dispatch pipeline.

Covers the four behaviours that distinguish the new feature from the legacy
"drop on quiet hours" code path:

  1. ``quiet_hours_status`` returns the correct UTC end datetime, including
     overnight windows that wrap past midnight.
  2. ``evaluate_event_gate`` distinguishes ``QUIET_HOURS`` (deferrable) from
     ``EVENT_TYPE_DISABLED`` (drop forever).
  3. ``serialize_event`` / ``deserialize_event`` round-trip without losing
     asset metadata.
  4. ``defer_event`` coalesces ``assets_added`` + ``assets_removed`` of the
     same IDs for the same link+collection — the cancellation case that
     motivated the whole feature.
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import Any

import pytest
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine

from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.providers.base import ServiceProviderType


# ---------------------------------------------------------------------------
# Quiet-hours math
# ---------------------------------------------------------------------------

def test_quiet_hours_status_inside_normal_window(monkeypatch: pytest.MonkeyPatch) -> None:
    from notify_bridge_server.services import dispatch_helpers as dh

    # Pretend it's 13:00 UTC inside a 12:00-14:00 window.
    class _FixedDatetime(datetime):
        @classmethod
        def now(cls, tz=None):
            return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc)

    monkeypatch.setattr(dh, "datetime", _FixedDatetime)
    end_at = dh.quiet_hours_status("12:00", "14:00", "UTC")
    assert end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc)


def test_quiet_hours_status_start_equals_end_returns_none() -> None:
    """``00:00-00:00`` is ambiguous (single instant vs always-on); treat as no window.

    Code-review feedback: without this guard, the overnight-window branch would
    interpret it as "always quiet" and silently defer every notification all
    day. The conservative read is that the user misconfigured and we should
    behave as if quiet hours were off.
    """
    from notify_bridge_server.services import dispatch_helpers as dh

    assert dh.quiet_hours_status("00:00", "00:00", "UTC") is None
    assert dh.quiet_hours_status("13:30", "13:30", "UTC") is None


def test_quiet_hours_status_outside_window_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
    from notify_bridge_server.services import dispatch_helpers as dh

    class _FixedDatetime(datetime):
        @classmethod
        def now(cls, tz=None):
            return datetime(2026, 5, 12, 15, 0, tzinfo=timezone.utc)

    monkeypatch.setattr(dh, "datetime", _FixedDatetime)
    assert dh.quiet_hours_status("12:00", "14:00", "UTC") is None


def test_quiet_hours_status_overnight_window_post_midnight(monkeypatch: pytest.MonkeyPatch) -> None:
    """22:00-06:00 window, current time 03:00 → window ends today at 06:00."""
    from notify_bridge_server.services import dispatch_helpers as dh

    class _FixedDatetime(datetime):
        @classmethod
        def now(cls, tz=None):
            return datetime(2026, 5, 12, 3, 0, tzinfo=timezone.utc)

    monkeypatch.setattr(dh, "datetime", _FixedDatetime)
    end_at = dh.quiet_hours_status("22:00", "06:00", "UTC")
    assert end_at == datetime(2026, 5, 12, 6, 0, tzinfo=timezone.utc)


def test_quiet_hours_status_overnight_window_pre_midnight(monkeypatch: pytest.MonkeyPatch) -> None:
    """22:00-06:00 window, current time 23:30 → window ends tomorrow at 06:00."""
    from notify_bridge_server.services import dispatch_helpers as dh

    class _FixedDatetime(datetime):
        @classmethod
        def now(cls, tz=None):
            return datetime(2026, 5, 12, 23, 30, tzinfo=timezone.utc)

    monkeypatch.setattr(dh, "datetime", _FixedDatetime)
    end_at = dh.quiet_hours_status("22:00", "06:00", "UTC")
    assert end_at == datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)


# ---------------------------------------------------------------------------
# Gate enum / outcome
# ---------------------------------------------------------------------------

def _make_event(
    event_type: EventType = EventType.ASSETS_ADDED,
    *,
    added_assets: list[MediaAsset] | None = None,
) -> ServiceEvent:
    return ServiceEvent(
        event_type=event_type,
        provider_type=ServiceProviderType.IMMICH,
        provider_name="test-immich",
        collection_id="col-1",
        collection_name="Album A",
        timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
        added_assets=added_assets or [],
        added_count=len(added_assets or []),
    )


def _make_asset(asset_id: str, *, filename: str | None = None) -> MediaAsset:
    return MediaAsset(
        id=asset_id,
        type=MediaType.IMAGE,
        filename=filename or f"{asset_id}.jpg",
        created_at=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
    )


class _FakeTrackingConfig:
    """Minimal stand-in for TrackingConfig — only the fields the gate reads."""

    def __init__(
        self,
        *,
        quiet_hours_enabled: bool = False,
        quiet_hours_start: str | None = None,
        quiet_hours_end: str | None = None,
        track_assets_added: bool = True,
    ) -> None:
        self.quiet_hours_enabled = quiet_hours_enabled
        self.quiet_hours_start = quiet_hours_start
        self.quiet_hours_end = quiet_hours_end
        self.track_assets_added = track_assets_added
        # The gate's flag map reads every track_* attribute; set the rest to
        # True so it doesn't accidentally block on an unrelated event type.
        for attr in (
            "track_assets_removed", "track_collection_renamed",
            "track_collection_deleted", "track_sharing_changed",
            "track_push", "track_issue_opened", "track_issue_closed",
            "track_issue_commented", "track_pr_opened", "track_pr_closed",
            "track_pr_merged", "track_pr_commented", "track_release_published",
            "track_card_created", "track_card_updated", "track_card_moved",
            "track_card_deleted", "track_card_commented", "track_comment_updated",
            "track_board_created", "track_board_updated", "track_board_deleted",
            "track_list_created", "track_list_updated", "track_list_deleted",
            "track_attachment_created", "track_card_label_added",
            "track_task_completed", "track_scheduled_message",
            "track_webhook_received", "track_ups_online", "track_ups_on_battery",
            "track_ups_low_battery", "track_ups_battery_restored",
            "track_ups_comms_lost", "track_ups_comms_restored",
            "track_ups_replace_battery", "track_ups_overload",
        ):
            setattr(self, attr, True)


def test_gate_quiet_hours_wins_over_event_type_flag(monkeypatch: pytest.MonkeyPatch) -> None:
    from notify_bridge_server.services import dispatch_helpers as dh

    class _FixedDatetime(datetime):
        @classmethod
        def now(cls, tz=None):
            return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc)

    monkeypatch.setattr(dh, "datetime", _FixedDatetime)
    tc = _FakeTrackingConfig(
        quiet_hours_enabled=True,
        quiet_hours_start="12:00",
        quiet_hours_end="14:00",
        # Even with the event-type flag flipped off, quiet hours should be
        # the reported reason — it's the "louder" gate. The downstream defer
        # path treats this as a deferral candidate; flipping the order would
        # silently drop deferrable events when both gates are closed.
        track_assets_added=False,
    )
    outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC")
    assert outcome.reason is dh.GateReason.QUIET_HOURS
    assert outcome.quiet_hours_end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc)


def test_gate_event_type_disabled_when_quiet_hours_off() -> None:
    from notify_bridge_server.services import dispatch_helpers as dh

    tc = _FakeTrackingConfig(quiet_hours_enabled=False, track_assets_added=False)
    outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC")
    assert outcome.reason is dh.GateReason.EVENT_TYPE_DISABLED
    assert outcome.quiet_hours_end_at is None


# ---------------------------------------------------------------------------
# Event payload round-trip
# ---------------------------------------------------------------------------

def test_serialize_deserialize_roundtrips_assets_and_extras() -> None:
    from notify_bridge_server.services import deferred_dispatch as dd

    asset = _make_asset("a1")
    asset.extra = {"city": "Minsk", "is_favorite": True, "rating": 5}
    event = _make_event(added_assets=[asset])
    event.extra = {"people": ["Alice"]}

    payload = dd.serialize_event(event)
    restored = dd.deserialize_event(payload)

    assert restored.event_type is EventType.ASSETS_ADDED
    assert restored.provider_type is ServiceProviderType.IMMICH
    assert restored.collection_id == "col-1"
    assert len(restored.added_assets) == 1
    assert restored.added_assets[0].id == "a1"
    assert restored.added_assets[0].extra["city"] == "Minsk"
    assert restored.extra["people"] == ["Alice"]
    assert restored.timestamp == event.timestamp


# ---------------------------------------------------------------------------
# Coalescing — the add-then-remove cancellation that motivated the design
# ---------------------------------------------------------------------------

@pytest.fixture
async def empty_session():
    """In-memory SQLite session for coalescing tests — no fixtures, just a clean DB."""
    # Importing models here registers them on SQLModel.metadata. We rely on
    # ``DeferredDispatch`` being declared so create_all picks it up.
    from notify_bridge_server.database import models  # noqa: F401 — side effect

    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    async with engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.create_all)
    async with AsyncSession(engine) as session:
        yield session
    await engine.dispose()


@pytest.mark.asyncio
async def test_add_then_remove_same_assets_cancels_pending(empty_session: AsyncSession) -> None:
    """User adds {A, B}, then removes {A, B} — both pending rows should disappear.

    Before this feature this scenario would either spam two late notifications
    ("added" then "removed") or silently drop both. The cancellation path is
    the win that justified the coalescing module.
    """
    from notify_bridge_server.services import deferred_dispatch as dd
    from notify_bridge_server.database.models import DeferredDispatch

    fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
    add_event = _make_event(
        EventType.ASSETS_ADDED,
        added_assets=[_make_asset("A"), _make_asset("B")],
    )
    result = await dd.defer_event(
        empty_session,
        event=add_event,
        user_id=1, tracker_id=1, link_id=1,
        event_log_id=100, fire_at=fire_at,
    )
    await empty_session.commit()
    assert result == "inserted"

    remove_event = ServiceEvent(
        event_type=EventType.ASSETS_REMOVED,
        provider_type=ServiceProviderType.IMMICH,
        provider_name="test-immich",
        collection_id="col-1",
        collection_name="Album A",
        timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc),
        removed_asset_ids=["A", "B"],
        removed_count=2,
    )
    result = await dd.defer_event(
        empty_session,
        event=remove_event,
        user_id=1, tracker_id=1, link_id=1,
        event_log_id=101, fire_at=fire_at,
    )
    await empty_session.commit()

    pending = (await empty_session.exec(
        select(DeferredDispatch).where(DeferredDispatch.status == "pending")
    )).all()
    assert pending == [], "add-then-remove of same IDs should leave the queue empty"


@pytest.mark.asyncio
async def test_add_then_partial_remove_keeps_remainder(empty_session: AsyncSession) -> None:
    """User adds {A, B, C}, then removes {B} — pending row should contain {A, C}."""
    from notify_bridge_server.services import deferred_dispatch as dd
    from notify_bridge_server.database.models import DeferredDispatch

    fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
    await dd.defer_event(
        empty_session,
        event=_make_event(EventType.ASSETS_ADDED, added_assets=[
            _make_asset("A"), _make_asset("B"), _make_asset("C"),
        ]),
        user_id=1, tracker_id=1, link_id=1,
        event_log_id=100, fire_at=fire_at,
    )
    await empty_session.commit()

    remove_event = ServiceEvent(
        event_type=EventType.ASSETS_REMOVED,
        provider_type=ServiceProviderType.IMMICH,
        provider_name="test-immich",
        collection_id="col-1",
        collection_name="Album A",
        timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc),
        removed_asset_ids=["B"],
        removed_count=1,
    )
    await dd.defer_event(
        empty_session,
        event=remove_event,
        user_id=1, tracker_id=1, link_id=1,
        event_log_id=101, fire_at=fire_at,
    )
    await empty_session.commit()

    rows = (await empty_session.exec(
        select(DeferredDispatch).where(DeferredDispatch.status == "pending")
    )).all()
    # Only the assets_added row survives (B subtracted). No assets_removed
    # row because B was just added — its removal is a wash.
    assert len(rows) == 1
    assert rows[0].event_type == "assets_added"
    remaining_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"])
    assert remaining_ids == ["A", "C"]


@pytest.mark.asyncio
async def test_add_then_add_unions_assets(empty_session: AsyncSession) -> None:
    """Two consecutive assets_added events should merge into one pending row."""
    from notify_bridge_server.services import deferred_dispatch as dd
    from notify_bridge_server.database.models import DeferredDispatch

    fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
    await dd.defer_event(
        empty_session,
        event=_make_event(EventType.ASSETS_ADDED, added_assets=[_make_asset("A")]),
        user_id=1, tracker_id=1, link_id=1,
        event_log_id=100, fire_at=fire_at,
    )
    await empty_session.commit()
    await dd.defer_event(
        empty_session,
        event=_make_event(EventType.ASSETS_ADDED, added_assets=[
            _make_asset("B"), _make_asset("C"),
        ]),
        user_id=1, tracker_id=1, link_id=1,
        event_log_id=101, fire_at=fire_at,
    )
    await empty_session.commit()

    rows = (await empty_session.exec(
        select(DeferredDispatch).where(DeferredDispatch.status == "pending")
    )).all()
    assert len(rows) == 1
    merged_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"])
    assert merged_ids == ["A", "B", "C"]


@pytest.mark.asyncio
async def test_non_asset_event_is_not_coalesced(empty_session: AsyncSession) -> None:
    """Two push events for the same repo should both be queued — historical facts."""
    from notify_bridge_server.services import deferred_dispatch as dd
    from notify_bridge_server.database.models import DeferredDispatch

    fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
    for i in range(2):
        push_event = ServiceEvent(
            event_type=EventType.PUSH,
            provider_type=ServiceProviderType.GITEA,
            provider_name="test-gitea",
            collection_id="repo-1",
            collection_name="my/repo",
            timestamp=datetime(2026, 5, 12, 12, i, tzinfo=timezone.utc),
            extra={"commit_sha": f"sha{i}"},
        )
        await dd.defer_event(
            empty_session,
            event=push_event,
            user_id=1, tracker_id=1, link_id=1,
            event_log_id=100 + i, fire_at=fire_at,
        )
        await empty_session.commit()

    rows = (await empty_session.exec(
        select(DeferredDispatch).where(DeferredDispatch.status == "pending")
    )).all()
    # Both rows survive — pushes don't cancel one another.
    assert len(rows) == 2


@pytest.mark.asyncio
async def test_scheduled_message_is_non_deferrable(empty_session: AsyncSession) -> None:
    """``scheduled_message`` is wall-clock — defer_event should refuse to enqueue."""
    from notify_bridge_server.services import deferred_dispatch as dd
    from notify_bridge_server.database.models import DeferredDispatch

    sched_event = ServiceEvent(
        event_type=EventType.SCHEDULED_MESSAGE,
        provider_type=ServiceProviderType.SCHEDULER,
        provider_name="sched",
        collection_id="",
        collection_name="",
        timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
    )
    result = await dd.defer_event(
        empty_session,
        event=sched_event,
        user_id=1, tracker_id=1, link_id=1,
        event_log_id=100,
        fire_at=datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc),
    )
    assert result == "non_deferrable"
    await empty_session.commit()
    rows = (await empty_session.exec(select(DeferredDispatch))).all()
    assert rows == []
