"""Cron-fired scheduled / periodic / memory dispatch for Immich trackers.

The Immich provider exposes three notification slots that fire on a wall-clock
schedule rather than in response to album changes:

* ``scheduled_assets_message`` — random asset selection at fixed times of day
* ``periodic_summary_message`` — album stats summary at fixed times of day
* ``memory_mode_message`` — "On This Day" memories at fixed times of day

The fire times live on the tracker's default ``TrackingConfig`` as comma-
separated ``HH:MM`` strings (``scheduled_times`` / ``periodic_times`` /
``memory_times``) interpreted in the app-level IANA timezone
(``AppSetting.timezone``). The scheduler module wires the cron jobs; this
module owns the dispatch flow once a job fires.

Note on per-link tracking-config overrides: schedule *times* come from the
tracker's default config — a per-link override may disable the slot for that
link (via ``{kind}_enabled``) but cannot shift its fire time. Consistent with
the test-dispatch path in ``manual_dispatch``.
"""

from __future__ import annotations

import logging
from datetime import datetime
from typing import Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from notify_bridge_core.models.events import EventType
from notify_bridge_core.notifications.dispatcher import (
    NotificationDispatcher,
    TargetConfig,
)

from ..database.engine import get_engine
from ..database.models import (
    EventLog,
    NotificationTracker,
    ServiceProvider,
    TemplateSlot,
    TrackingConfig,
)
from .dispatch_helpers import (
    GateReason,
    apply_tracking_display_filters,
    evaluate_event_gate,
    get_app_timezone,
    load_link_data,
    resolve_provider_credential,
)
from .manual_dispatch import build_immich_dispatch_events

_LOGGER = logging.getLogger(__name__)

ScheduledKind = Literal["scheduled", "periodic", "memory"]

# Reasons a scheduled cron fire can end up producing no notification. We write
# these to EventLog.details.skip_reason so users can see *why* a 09:00 memory
# didn't arrive, rather than silently treating the fire as if it never happened.
_SKIP_REASON_TRACKER_DISABLED = "tracker_disabled"
_SKIP_REASON_NOT_IMMICH = "not_immich_provider"
_SKIP_REASON_KIND_DISABLED = "kind_disabled_on_default_config"
_SKIP_REASON_NO_LINKS = "no_enabled_links"
_SKIP_REASON_NO_EVENT = "provider_returned_no_event"
_SKIP_REASON_EMPTY_PAYLOAD = "zero_assets_matched"
_SKIP_REASON_NO_TARGETS = "no_targets_after_filtering"
_SKIP_REASON_INTERVAL_NOT_DUE = "interval_not_due"


def _periodic_due_today(default_tc: TrackingConfig, app_tz: str) -> bool:
    """Return True iff today (in app tz) falls on the periodic schedule.

    The schedule is ``start_date + N * interval_days`` for non-negative N.
    The cron job fires every day at the configured ``periodic_times`` — this
    gate skips the dispatch on days that aren't a multiple of the interval
    from the start date, so a user-configured ``interval_days=3`` fires every
    third day rather than every day.

    Defensive: an interval ≤ 1 is treated as "fire daily"; an unparseable
    ``periodic_start_date`` is treated as "fire" so a bad config doesn't
    silently stop all summaries (operators see notifications and can fix it).
    """
    interval = max(1, int(getattr(default_tc, "periodic_interval_days", 1) or 1))
    if interval <= 1:
        return True
    raw_start = getattr(default_tc, "periodic_start_date", "") or ""
    try:
        start = datetime.strptime(raw_start, "%Y-%m-%d").date()
    except (ValueError, TypeError):
        _LOGGER.warning(
            "Invalid periodic_start_date %r; treating periodic as due today",
            raw_start,
        )
        return True
    try:
        tz = ZoneInfo(app_tz)
    except (ZoneInfoNotFoundError, ValueError):
        tz = ZoneInfo("UTC")
    today = datetime.now(tz).date()
    delta_days = (today - start).days
    if delta_days < 0:
        return False
    return delta_days % interval == 0


async def _log_skip(
    tracker_id: int,
    kind: ScheduledKind,
    reason: str,
    *,
    tracker_user_id: int | None = None,
    tracker_name: str = "",
    provider_id: int | None = None,
    provider_name: str = "",
) -> None:
    """Persist an EventLog row for a skipped scheduled fire.

    Separate from the success-path log (which records targets dispatched) so
    operators and users can filter "why didn't this fire" from "what was sent".
    ``event_type`` mirrors the success path's value; the skip is disambiguated
    by ``details.status == "skipped"``.
    """
    engine = get_engine()
    async with AsyncSession(engine) as session:
        session.add(EventLog(
            user_id=tracker_user_id,
            tracker_id=tracker_id,
            tracker_name=tracker_name,
            provider_id=provider_id,
            provider_name=provider_name,
            event_type=EventType.SCHEDULED_MESSAGE.value,
            collection_id="",
            collection_name="",
            assets_count=0,
            details={
                "kind": kind,
                "trigger": "cron",
                "status": "skipped",
                "skip_reason": reason,
            },
        ))
        await session.commit()

# Maps the dispatch kind to the DB slot name that holds its template.
# The dispatcher keys templates by ``event.event_type.value`` (always
# ``scheduled_message`` here), so we read the right ``TemplateSlot`` row and
# inject it under that single event-type key — same pattern as the test path.
_SLOT_MAP: dict[ScheduledKind, str] = {
    "scheduled": "scheduled_assets_message",
    "periodic": "periodic_summary_message",
    "memory": "memory_mode_message",
}


async def dispatch_scheduled_for_tracker(
    tracker_id: int, kind: ScheduledKind
) -> None:
    """Build the slot's event for ``tracker_id`` and fan out to its links.

    Skips silently when the tracker is disabled, the provider is not Immich,
    the slot is disabled on the tracker's default tracking config, or no link
    has a ``TemplateConfig`` with the corresponding slot row.
    """
    engine = get_engine()
    async with AsyncSession(engine) as session:
        tracker = await session.get(NotificationTracker, tracker_id)
        if not tracker or not tracker.enabled:
            # No user context available (tracker missing/disabled); still log so
            # operators can correlate cron fires that went nowhere.
            await _log_skip(
                tracker_id, kind, _SKIP_REASON_TRACKER_DISABLED,
                tracker_user_id=(tracker.user_id if tracker else None),
                tracker_name=(tracker.name if tracker else ""),
            )
            return
        provider = await session.get(ServiceProvider, tracker.provider_id)
        if not provider or provider.type != "immich":
            await _log_skip(
                tracker_id, kind, _SKIP_REASON_NOT_IMMICH,
                tracker_user_id=tracker.user_id,
                tracker_name=tracker.name or "",
                provider_id=(provider.id if provider else None),
                provider_name=(provider.name if provider else ""),
            )
            return

        default_tc: TrackingConfig | None = None
        if tracker.default_tracking_config_id:
            default_tc = await session.get(
                TrackingConfig, tracker.default_tracking_config_id
            )
        # If the default config disables this kind, nothing to do — schedule
        # rebuild only adds jobs when the flag is set, but a stale job from
        # a previous DB state could still fire one tick before invalidation.
        if default_tc is None or not getattr(default_tc, f"{kind}_enabled", False):
            _LOGGER.debug(
                "Scheduled %s skipped for tracker %d: kind disabled on default config",
                kind, tracker_id,
            )
            await _log_skip(
                tracker_id, kind, _SKIP_REASON_KIND_DISABLED,
                tracker_user_id=tracker.user_id,
                tracker_name=tracker.name or "",
                provider_id=provider.id,
                provider_name=provider.name or provider.type,
            )
            return

        # Snapshot every field we need outside the session — after the
        # ``async with`` exits the instances are detached and lazy-load
        # would fail. Cheaper than re-fetching, safer than touching
        # attributes through a closed session.
        provider_id = provider.id
        provider_config = dict(provider.config)
        provider_name = provider.name or provider.type
        tracker_user_id = tracker.user_id
        tracker_name = tracker.name or ""
        collection_ids = list(tracker.collection_ids or [])

        app_tz = await get_app_timezone(session)

        # Periodic summary respects an N-day interval starting from
        # ``periodic_start_date`` — the cron job fires every day at
        # ``periodic_times``, so without this gate a configured
        # ``interval_days=3`` would still dispatch daily. Other kinds
        # (scheduled/memory) don't have an interval setting.
        if kind == "periodic" and not _periodic_due_today(default_tc, app_tz):
            _LOGGER.info(
                "Periodic for tracker %d: not due today (interval=%s, start=%s)",
                tracker_id,
                getattr(default_tc, "periodic_interval_days", None),
                getattr(default_tc, "periodic_start_date", None),
            )
            await _log_skip(
                tracker_id, kind, _SKIP_REASON_INTERVAL_NOT_DUE,
                tracker_user_id=tracker.user_id,
                tracker_name=tracker.name or "",
                provider_id=provider.id,
                provider_name=provider.name or provider.type,
            )
            return

        link_data = await load_link_data(session, tracker_id)

    if not link_data:
        _LOGGER.info(
            "Scheduled %s for tracker %d: no enabled links, skipping",
            kind, tracker_id,
        )
        await _log_skip(
            tracker_id, kind, _SKIP_REASON_NO_LINKS,
            tracker_user_id=tracker_user_id,
            tracker_name=tracker_name,
            provider_id=provider_id,
            provider_name=provider_name,
        )
        return

    # Resolve mode + build events via the shared helper (same decision logic
    # the test-dispatch path uses). "per_collection" fans out one event per
    # album; "combined" pools assets into a single event. ``collection_mode``
    # is threaded through to EventLog.details so operators can see *which*
    # mode a fire used when auditing behaviour.
    collection_mode = (
        "combined" if kind == "periodic"
        else getattr(default_tc, f"{kind}_collection_mode", "combined") or "combined"
    )
    events = await build_immich_dispatch_events(
        provider_config=provider_config,
        provider_name=provider_name,
        tracker_name=tracker_name,
        collection_ids=collection_ids,
        kind=kind,
        tracking_config=default_tc,
    )

    if not events:
        # All albums yielded 0 matching assets (per_collection), or the single
        # combined build produced nothing. Log the same skip reason used for
        # the legacy single-event path so operators see a consistent signal.
        reason = (
            _SKIP_REASON_NO_EVENT if kind == "periodic" else _SKIP_REASON_EMPTY_PAYLOAD
        )
        _LOGGER.info(
            "Scheduled %s for tracker %d: no events to dispatch (mode=%s)",
            kind, tracker_id, collection_mode,
        )
        await _log_skip(
            tracker_id, kind, reason,
            tracker_user_id=tracker_user_id,
            tracker_name=tracker_name,
            provider_id=provider_id,
            provider_name=provider_name,
        )
        return

    slot_name = _SLOT_MAP[kind]

    # Lazy import to break the watcher↔scheduler↔scheduled_dispatch cycle.
    from .watcher import _get_telegram_caches
    from .http_session import get_http_session

    url_cache, asset_cache = await _get_telegram_caches()
    http_session = await get_http_session()
    dispatcher = NotificationDispatcher(
        url_cache=url_cache, asset_cache=asset_cache, session=http_session,
    )

    any_sent = False
    for event in events:
        # Target config assembly depends on the event for quiet-hours /
        # event_allowed_by_config, which inspects event timestamp. Per-event
        # rebuilding also lets a per-link override disable one kind while
        # keeping others live.
        # Group target configs by TrackingConfig identity so each unique TC
        # gets its own ``apply_tracking_display_filters`` pass before dispatch.
        groups: dict[int, tuple[TrackingConfig | None, list[TargetConfig]]] = {}
        async with AsyncSession(engine) as session:
            for ld in link_data:
                tc = ld["tracking_config"] or default_tc
                tmpl = ld["template_config"]
                if tc is not None:
                    if not getattr(tc, f"{kind}_enabled", True):
                        continue
                    # Scheduled / periodic / memory dispatches are wall-clock
                    # by nature — a "good morning" delivered at 3 pm is wrong,
                    # so quiet hours = drop (not defer) for these kinds. The
                    # other gate (per-event-type flag) still applies.
                    if not evaluate_event_gate(event, tc, app_tz).allowed:
                        continue
                if tmpl is None:
                    continue

                slot_rows = (await session.exec(
                    select(TemplateSlot).where(
                        TemplateSlot.config_id == tmpl.id,
                        TemplateSlot.slot_name == slot_name,
                    )
                )).all()
                if not slot_rows:
                    continue
                locale_map = {s.locale: s.template for s in slot_rows}
                template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}

                target_cfg = TargetConfig(
                    type=ld["target_type"],
                    config=ld["target_config"],
                    template_slots=template_slots,
                    date_format=tmpl.date_format,
                    date_only_format=(
                        tmpl.date_only_format or "%d.%m.%Y"
                    ),
                    provider_api_key=resolve_provider_credential(provider_config),
                    provider_internal_url=provider_config.get("url", ""),
                    provider_external_url=provider_config.get("external_domain", ""),
                    receivers=ld["receivers"],
                )
                key = id(tc) if tc is not None else 0
                if key not in groups:
                    groups[key] = (tc, [])
                groups[key][1].append(target_cfg)

        if not groups:
            _LOGGER.info(
                "Scheduled %s for tracker %d (collection=%r): no targets after filtering",
                kind, tracker_id, event.collection_name,
            )
            continue

        total_targets = sum(len(tg[1]) for tg in groups.values())
        _LOGGER.info(
            "Dispatching scheduled %s for tracker %d (collection=%r) to %d link(s) across %d group(s)",
            kind, tracker_id, event.collection_name, total_targets, len(groups),
        )
        results: list = []
        dispatched_any = False
        for tc, target_configs in groups.values():
            if not target_configs:
                continue
            shaped_event = apply_tracking_display_filters(event, tc)
            if shaped_event is None:
                continue
            results.extend(await dispatcher.dispatch(shaped_event, target_configs))
            dispatched_any = True
        if not dispatched_any:
            continue
        any_sent = True

        successes = sum(1 for r in results if isinstance(r, dict) and r.get("success"))
        async with AsyncSession(engine) as session:
            session.add(EventLog(
                user_id=tracker_user_id,
                tracker_id=tracker_id,
                tracker_name=tracker_name,
                provider_id=provider_id,
                provider_name=provider_name,
                event_type=event.event_type.value,
                collection_id=event.collection_id,
                collection_name=event.collection_name,
                assets_count=event.added_count or 0,
                details={
                    "kind": kind,
                    "slot": slot_name,
                    "trigger": "cron",
                    "timezone": app_tz,
                    "collection_mode": collection_mode,
                    "status": "sent",
                    "targets_dispatched": total_targets,
                    "targets_succeeded": successes,
                },
            ))
            await session.commit()

    if not any_sent:
        # All events produced zero targets after filtering (quiet hours, etc.).
        await _log_skip(
            tracker_id, kind, _SKIP_REASON_NO_TARGETS,
            tracker_user_id=tracker_user_id,
            tracker_name=tracker_name,
            provider_id=provider_id,
            provider_name=provider_name,
        )
