"""Send screen, message log, reminder settings and delivery webhooks (tasks 4.5-4.8, 5.4).

The send screen always reports the **no-email count** alongside the recipient count. In an
email-only v1 that number is the difference between "800 guests will hear from us" and
"680 will, and 120 need a printed card" — hiding it would let the gap go unnoticed until
the wedding.
"""

import json
import uuid
from collections.abc import Sequence
from datetime import UTC, datetime
from datetime import time as dt_time

from fastapi import (
    APIRouter,
    BackgroundTasks,
    Depends,
    HTTPException,
    Query,
    Request,
    Response,
    status,
)
from pydantic import BaseModel, Field
from sqlalchemy import Row, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.db import get_session
from app.models import Event, Guest, Invitation, MessageJob, ReminderSchedule
from app.models.enums import (
    Channel,
    EventType,
    InvitationStatus,
    MessageJobStatus,
    ReminderAudience,
    TemplatePurpose,
)
from app.services import audit, messaging, planner, providers, scope
from app.services.auth import CurrentAdmin, require
from app.services.policy import Action

router = APIRouter(tags=["admin"])


def _client_ip(request: Request) -> str | None:
    fwd = request.headers.get("x-forwarded-for")
    return fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else None)


# ---------------------------------------------------------------- send screen


class SendPreviewRequest(BaseModel):
    event: EventType
    audience: ReminderAudience = ReminderAudience.ALL
    purpose: TemplatePurpose = TemplatePurpose.INVITE
    tag: str | None = None


class SendPreview(BaseModel):
    total_matched: int
    deliverable: int
    no_email: int
    opted_out: int
    sample_subject: str | None
    sample_body: str | None
    missing_placeholders: list[str]


async def _audience_rows(
    session: AsyncSession,
    admin: CurrentAdmin,
    event_type: EventType,
    audience: ReminderAudience,
    tag: str | None,
) -> Sequence[Row[tuple[Invitation, Guest]]]:
    """The audience a send addresses, already narrowed to the caller's own events.

    The request names an event *type*, and several events can share one (design D12), so
    without this a Host asking for "marriage" would address every marriage in the system.
    Narrowed here, in the one helper the preview and the send both use, so the number on the
    confirmation is the number that receives it.
    """
    stmt = (
        select(Invitation, Guest)
        .join(Guest, Guest.id == Invitation.guest_id)
        .join(Event, Event.id == Invitation.event_id)
        # Published events only, as the reminder planner already does. Every message this
        # audience receives carries the guest's invitation link, and a draft event's link
        # answers 404 — so a draft's guests are not an audience, they are 300 dead links.
        # Applied in the shared helper so the preview's counts and the send agree.
        .where(
            Event.type == event_type,
            Event.is_published.is_(True),
            Guest.is_deleted.is_(False),
            scope.visible_events(admin),
        )
        .options(
            selectinload(Invitation.guest),
            selectinload(Invitation.event).selectinload(Event.wedding),
        )
    )
    if audience is ReminderAudience.ACCEPTED:
        stmt = stmt.where(Invitation.status == InvitationStatus.ACCEPTED)
    elif audience is ReminderAudience.PENDING:
        stmt = stmt.where(
            Invitation.status.in_([InvitationStatus.PENDING, InvitationStatus.OPENED])
        )
    if tag:
        stmt = stmt.where(Guest.group_tag.contains([tag]))
    return (await session.execute(stmt)).all()


@router.post("/admin/send/preview", response_model=SendPreview)
async def preview_send(
    payload: SendPreviewRequest,
    admin: CurrentAdmin = Depends(require(Action.SEND_MESSAGES)),
    session: AsyncSession = Depends(get_session),
) -> SendPreview:
    """Counts and a rendered sample, before anything is queued (FR-4.9)."""
    rows = await _audience_rows(session, admin, payload.event, payload.audience, payload.tag)

    no_email = sum(1 for _i, g in rows if not g.email or g.email_invalid)
    opted_out = sum(1 for _i, g in rows if g.do_not_contact)
    deliverable = sum(
        1 for _i, g in rows if g.email and not g.email_invalid and not g.do_not_contact
    )

    subject = body = None
    missing: list[str] = []
    if rows:
        rendered = await messaging.render_for_invitation(session, rows[0][0], payload.purpose)
        if rendered:
            subject, body, missing = rendered.subject, rendered.text, rendered.missing_placeholders

    return SendPreview(
        total_matched=len(rows),
        deliverable=deliverable,
        no_email=no_email,
        opted_out=opted_out,
        sample_subject=subject,
        sample_body=body,
        missing_placeholders=missing,
    )


class SendRequest(SendPreviewRequest):
    scheduled_for: datetime | None = None


class SendResult(BaseModel):
    queued: int
    skipped_no_email: int
    already_queued: int


@router.post("/admin/send", response_model=SendResult)
async def queue_send(
    payload: SendRequest,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.SEND_MESSAGES)),
    session: AsyncSession = Depends(get_session),
) -> SendResult:
    rows = await _audience_rows(session, admin, payload.event, payload.audience, payload.tag)
    when = payload.scheduled_for or datetime.now(tz=UTC)

    queued = skipped = already = 0
    for invitation, guest in rows:
        if not guest.email or guest.email_invalid or guest.do_not_contact:
            skipped += 1
            continue
        created = await messaging.enqueue(
            session,
            invitation_id=invitation.id,
            template_id=None,
            schedule_id=None,
            scheduled_for=when,
        )
        if created:
            queued += 1
        else:
            # An identical direct send is already pending — the idempotency key caught it.
            already += 1

    audit.record(
        session,
        action=audit.Actions.MESSAGE_SEND,
        admin_user_id=admin.id,
        actor_email=admin.email,
        after={
            "event": str(payload.event),
            "audience": str(payload.audience),
            "queued": queued,
            "skipped_no_email": skipped,
        },
        ip=_client_ip(request),
    )
    return SendResult(queued=queued, skipped_no_email=skipped, already_queued=already)


# ---------------------------------------------------------------- message log


class MessageLogEntry(BaseModel):
    id: uuid.UUID
    invitation_id: uuid.UUID
    channel: Channel
    status: MessageJobStatus
    scheduled_for: datetime
    attempts: int
    provider_message_id: str | None
    error_code: str | None
    error_message: str | None
    skip_reason: str | None
    sent_at: datetime | None


class MessageLogPage(BaseModel):
    items: list[MessageLogEntry]
    total: int
    counts_by_status: dict[str, int]


@router.get("/admin/messages", response_model=MessageLogPage)
async def read_message_log(
    job_status: MessageJobStatus | None = None,
    page: int = Query(default=1, ge=1),
    page_size: int = Query(default=50, ge=1, le=200),
    admin: CurrentAdmin = Depends(require(Action.VIEW_DASHBOARD)),
    session: AsyncSession = Depends(get_session),
) -> MessageLogPage:
    # Every job belongs to an invitation, so the log scopes through the same join the rest
    # of the surface uses. Its rows carry the recipient address and the failure reason.
    in_scope = scope.invitation_in_scope(admin)
    stmt = (
        select(MessageJob)
        .join(Invitation, Invitation.id == MessageJob.invitation_id)
        .where(in_scope)
    )
    if job_status:
        stmt = stmt.where(MessageJob.status == job_status)

    total = await session.scalar(select(func.count()).select_from(stmt.subquery())) or 0
    rows = await session.scalars(
        stmt.order_by(MessageJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
    )
    counts = (
        await session.execute(
            select(MessageJob.status, func.count(MessageJob.id))
            .join(Invitation, Invitation.id == MessageJob.invitation_id)
            .where(in_scope)
            .group_by(MessageJob.status)
        )
    ).all()

    return MessageLogPage(
        items=[MessageLogEntry.model_validate(j, from_attributes=True) for j in rows],
        total=int(total),
        counts_by_status={str(s): c for s, c in counts},
    )


class RetryResult(BaseModel):
    retried: int


@router.post("/admin/messages/retry", response_model=RetryResult)
async def retry_failed(
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.SEND_MESSAGES)),
    session: AsyncSession = Depends(get_session),
) -> RetryResult:
    """Re-queue failed jobs (FR-4.10). Attempts reset so the backoff starts fresh."""
    result = await session.execute(
        update(MessageJob)
        .where(
            MessageJob.status == MessageJobStatus.FAILED,
            # Retrying another owner's failed wave would send their guests real mail.
            MessageJob.invitation_id.in_(
                select(Invitation.id).where(scope.invitation_in_scope(admin))
            ),
        )
        .values(
            status=MessageJobStatus.QUEUED,
            attempts=0,
            error_code=None,
            error_message=None,
            scheduled_for=datetime.now(tz=UTC),
        )
        .returning(MessageJob.id)
    )
    count = len(result.all())
    audit.record(
        session,
        action=audit.Actions.MESSAGE_RETRY,
        admin_user_id=admin.id,
        actor_email=admin.email,
        after={"retried": count},
        ip=_client_ip(request),
    )
    return RetryResult(retried=count)


# ---------------------------------------------------------------- reminders


class WavePreview(BaseModel):
    event_slug: str
    offset_days: int
    send_at: datetime
    recipients: int
    no_email: int
    skipped_past: bool


@router.get("/admin/reminders/preview", response_model=list[WavePreview])
async def preview_reminder_waves(
    admin: CurrentAdmin = Depends(require(Action.MANAGE_REMINDERS)),
    session: AsyncSession = Depends(get_session),
) -> list[WavePreview]:
    """Upcoming waves with exact dates and recipient counts (FR-4.11), without queueing."""
    plans = await planner.plan_all(session, dry_run=True)
    if not scope.is_unscoped(admin):
        # `plan_all` is the worker's view and is deliberately system-wide. Narrowed here by
        # slug rather than re-planning, because the planner must keep seeing every event —
        # a Host's dashboard is not allowed to change what actually gets sent.
        visible = set(
            (await session.scalars(select(Event.slug).where(scope.visible_events(admin)))).all()
        )
        plans = [p for p in plans if p.event_slug in visible]
    return [
        WavePreview(
            event_slug=p.event_slug,
            offset_days=p.offset_days,
            send_at=p.send_at,
            recipients=p.created,
            no_email=p.no_email,
            skipped_past=p.skipped_past,
        )
        for p in plans
    ]


class ReminderScheduleWrite(BaseModel):
    offset_days: int = Field(ge=0, le=90)
    send_at_local_time: str = "10:00"
    audience: ReminderAudience = ReminderAudience.ACCEPTED
    is_enabled: bool = True


@router.patch("/admin/reminders/{schedule_id}", status_code=status.HTTP_204_NO_CONTENT)
async def update_reminder_schedule(
    schedule_id: uuid.UUID,
    payload: ReminderScheduleWrite,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.MANAGE_REMINDERS)),
    session: AsyncSession = Depends(get_session),
) -> None:
    schedule = await session.scalar(
        select(ReminderSchedule)
        .join(Event, Event.id == ReminderSchedule.event_id)
        .where(ReminderSchedule.id == schedule_id, scope.visible_events(admin))
    )
    if schedule is None:
        raise scope.not_found()

    schedule.offset_days = payload.offset_days
    schedule.send_at_local_time = dt_time.fromisoformat(payload.send_at_local_time)
    schedule.audience = payload.audience
    schedule.is_enabled = payload.is_enabled

    audit.record(
        session,
        action=audit.Actions.REMINDER_UPDATE,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="reminder_schedule",
        entity_id=schedule.id,
        after=payload.model_dump(mode="json"),
        ip=_client_ip(request),
    )


# ---------------------------------------------------------------- webhooks


@router.post("/webhooks/email", status_code=status.HTTP_204_NO_CONTENT)
async def email_webhook(
    request: Request,
    background: BackgroundTasks,
    session: AsyncSession = Depends(get_session),
) -> Response:
    """Delivery and bounce receipts (FR-6.11).

    The signature is verified before the body is parsed. An unsigned webhook could mark
    real sends delivered, or flag a good address as hard-bounced and silence that guest for
    the rest of the wedding.
    """
    body = await request.body()
    provider = providers.get_provider(Channel.EMAIL)
    if not provider.verify_signature(body, dict(request.headers)):
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")

    try:
        payload = json.loads(body)
    except ValueError:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail="Malformed payload"
        ) from None

    for update_event in provider.parse_webhook(payload):
        job = await session.scalar(
            select(MessageJob).where(
                MessageJob.provider_message_id == update_event.provider_message_id
            )
        )
        if job is None:
            continue

        if update_event.status == "delivered":
            job.status = MessageJobStatus.DELIVERED
            job.delivered_at = datetime.now(tz=UTC)
        else:
            job.status = MessageJobStatus.FAILED
            job.error_code = update_event.reason

        if update_event.hard_bounce:
            # Stop wasting sends on an address that will never accept them (§8.3).
            invitation = await session.get(Invitation, job.invitation_id)
            if invitation and invitation.guest_id:
                guest = await session.get(Guest, invitation.guest_id)
                if guest:
                    guest.email_invalid = True

    return Response(status_code=status.HTTP_204_NO_CONTENT)
