"""Reminder planner (tasks 5.2, design D6, spec reminders).

Runs hourly and is idempotent, which is what lets a worker that was down for a day catch
up on its next tick with no recovery logic. Two rules do the heavy lifting:

* A wave whose send time has already passed is **skipped, never sent late**. A reminder
  saying "2 days to go" that arrives the morning after is worse than no reminder (FR-6.7).
* Jobs are inserted with ON CONFLICT DO NOTHING against the idempotency key, so running
  the planner twice over the same wave produces nothing the second time.
"""

import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo

from sqlalchemy import ColumnElement, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.config import get_settings
from app.models import Event, Guest, Invitation, MessageJob, MessageTemplate, ReminderSchedule
from app.models.enums import (
    Channel,
    InvitationStatus,
    Locale,
    MessageJobStatus,
    ReminderAudience,
    TemplatePurpose,
)
from app.services import messaging

logger = logging.getLogger(__name__)

#: Recorded on jobs dropped because the event moved. Distinguishable in the message log
#: from a guest who simply has no email — different problem, different fix.
SKIP_DATE_MOVED_PAST = "event date changed; this wave is now in the past"

#: offset_days -> the template purpose that wave should use.
OFFSET_PURPOSE = {
    15: TemplatePurpose.REMINDER_15,
    7: TemplatePurpose.REMINDER_7,
    2: TemplatePurpose.REMINDER_2,
}


@dataclass
class WavePlan:
    event_slug: str
    offset_days: int
    send_at: datetime
    recipients: int
    no_email: int
    created: int
    skipped_past: bool


def compute_send_at(
    starts_at: datetime, offset_days: int, local_time: object, timezone: str
) -> datetime:
    """`starts_at` minus `offset_days`, pinned to the configured local time, returned as UTC.

    Done in the event's timezone rather than the server's: 10:00 must mean 10:00 in Dhaka
    no matter where this process runs.
    """
    tz = ZoneInfo(timezone)
    local_start = starts_at.astimezone(tz)
    target_date = (local_start - timedelta(days=offset_days)).date()
    send_local = datetime.combine(target_date, local_time, tzinfo=tz)  # type: ignore[arg-type]
    return send_local.astimezone(UTC)


def _audience_filter(audience: ReminderAudience) -> ColumnElement[bool]:
    if audience is ReminderAudience.ACCEPTED:
        return Invitation.status == InvitationStatus.ACCEPTED
    if audience is ReminderAudience.PENDING:
        return Invitation.status.in_([InvitationStatus.PENDING, InvitationStatus.OPENED])
    return Invitation.status.notin_([InvitationStatus.CANCELLED, InvitationStatus.EXPIRED])


async def _template_for(
    session: AsyncSession, purpose: TemplatePurpose, locale: Locale
) -> uuid.UUID | None:
    template_id: uuid.UUID | None = await session.scalar(
        select(MessageTemplate.id).where(
            MessageTemplate.channel == Channel.EMAIL,
            MessageTemplate.purpose == purpose,
            MessageTemplate.locale == locale,
            MessageTemplate.is_active.is_(True),
        )
    )
    return template_id


async def plan_event_waves(
    session: AsyncSession, event: Event, *, now: datetime | None = None, dry_run: bool = False
) -> list[WavePlan]:
    """Plan every enabled wave for one event."""
    settings = get_settings()
    moment = now or datetime.now(UTC)
    timezone = event.wedding.timezone or settings.timezone

    schedules = await session.scalars(
        select(ReminderSchedule).where(
            ReminderSchedule.event_id == event.id, ReminderSchedule.is_enabled.is_(True)
        )
    )

    plans: list[WavePlan] = []
    for schedule in schedules:
        send_at = compute_send_at(
            event.starts_at, schedule.offset_days, schedule.send_at_local_time, timezone
        )

        if send_at <= moment:
            # Set up late, or this wave already fired. Never send it after the fact.
            plans.append(
                WavePlan(
                    event_slug=event.slug,
                    offset_days=schedule.offset_days,
                    send_at=send_at,
                    recipients=0,
                    no_email=0,
                    created=0,
                    skipped_past=True,
                )
            )
            continue

        rows = (
            await session.execute(
                select(Invitation, Guest)
                .join(Guest, Guest.id == Invitation.guest_id)
                .where(
                    Invitation.event_id == event.id,
                    Guest.is_deleted.is_(False),
                    _audience_filter(schedule.audience),
                )
            )
        ).all()

        purpose = OFFSET_PURPOSE.get(schedule.offset_days, TemplatePurpose.CUSTOM)
        created = 0
        no_email = 0

        for invitation, guest in rows:
            # v1 is email-only. Counting the unreachable is what stops them being a silent
            # gap in the host's mental model (spec messaging).
            if not guest.email or guest.do_not_contact or guest.email_invalid:
                no_email += 1
                continue
            if dry_run:
                created += 1
                continue
            template_id = await _template_for(session, purpose, guest.preferred_locale)
            if await messaging.enqueue(
                session,
                invitation_id=invitation.id,
                template_id=template_id,
                schedule_id=schedule.id,
                scheduled_for=send_at,
            ):
                created += 1

        plans.append(
            WavePlan(
                event_slug=event.slug,
                offset_days=schedule.offset_days,
                send_at=send_at,
                recipients=len(rows),
                no_email=no_email,
                created=created,
                skipped_past=False,
            )
        )

    return plans


async def plan_all(
    session: AsyncSession, *, now: datetime | None = None, dry_run: bool = False
) -> list[WavePlan]:
    """Plan waves for every published, future event. Safe to run every hour forever."""
    moment = now or datetime.now(UTC)

    events = await session.scalars(
        select(Event)
        .where(Event.is_published.is_(True), Event.starts_at > moment)
        .options(selectinload(Event.wedding))
    )

    plans: list[WavePlan] = []
    for event in events:
        plans.extend(await plan_event_waves(session, event, now=moment, dry_run=dry_run))

    created = sum(p.created for p in plans)
    if created:
        logger.info("planner queued %d reminder job(s) across %d wave(s)", created, len(plans))
    return plans


@dataclass
class ReplanResult:
    """What a date change did to the already-queued reminders."""

    rescheduled: int
    cancelled_past: int
    plans: list[WavePlan]

    @property
    def broadcast_recommended(self) -> bool:
        """A date change that reached nobody yet needs no announcement; one where guests
        have already accepted does. The caller decides — this only flags it."""
        return self.rescheduled > 0 or self.cancelled_past > 0


async def replan_event(
    session: AsyncSession, event: Event, *, now: datetime | None = None
) -> ReplanResult:
    """Re-point queued reminder jobs at the event's new date (task 5.5).

    Deleting and re-planning would look simpler but is wrong: the idempotency key is
    `invitation:schedule:channel`, so a re-plan after a delete could race the sender and
    a re-plan without one would hit ON CONFLICT DO NOTHING and silently keep the *old*
    send time. Updating the queued rows in place keeps the key stable and the constraint
    doing its job.

    Only `queued` rows are touched. A reminder that has already gone out cannot be
    unsent, and rewriting its row would destroy the record that it was.
    """
    moment = now or datetime.now(UTC)
    timezone = event.wedding.timezone or get_settings().timezone

    schedules = list(
        await session.scalars(select(ReminderSchedule).where(ReminderSchedule.event_id == event.id))
    )

    rescheduled = cancelled = 0
    for schedule in schedules:
        send_at = compute_send_at(
            event.starts_at, schedule.offset_days, schedule.send_at_local_time, timezone
        )
        job_ids = (
            select(MessageJob.id)
            .join(Invitation, Invitation.id == MessageJob.invitation_id)
            .where(
                MessageJob.schedule_id == schedule.id,
                MessageJob.status == MessageJobStatus.QUEUED,
                Invitation.event_id == event.id,
            )
            .scalar_subquery()
        )

        if send_at <= moment or not schedule.is_enabled:
            # The new date puts this wave in the past. Skipping is the same rule the
            # planner applies to a late setup: never send "2 days to go" after the fact.
            result = await session.execute(
                update(MessageJob)
                .where(MessageJob.id.in_(job_ids))
                .values(status=MessageJobStatus.SKIPPED, skip_reason=SKIP_DATE_MOVED_PAST)
                .returning(MessageJob.id)
            )
            cancelled += len(result.all())
            continue

        result = await session.execute(
            update(MessageJob)
            .where(MessageJob.id.in_(job_ids))
            .values(scheduled_for=send_at)
            .returning(MessageJob.id)
        )
        rescheduled += len(result.all())

    # Guests invited since the original planning still need jobs for the surviving waves.
    plans = await plan_event_waves(session, event, now=moment)

    logger.info(
        "replan %s: %d job(s) moved, %d dropped as past, %d new",
        event.slug,
        rescheduled,
        cancelled,
        sum(p.created for p in plans),
    )
    return ReplanResult(rescheduled=rescheduled, cancelled_past=cancelled, plans=plans)
