import logging
from datetime import timedelta

from celery import shared_task
from django.contrib.auth import get_user_model
from django.utils import timezone

from apps.core.notifications.emailing import rims_email_context
from apps.core.notifications.models import Notification
from apps.core.notifications.services import bulk_notify
from apps.core.permissions.roles import UserRole
from apps.rims.grants.models import Application, GrantCall, InterviewSchedule
from django.urls import reverse

logger = logging.getLogger(__name__)
User = get_user_model()

# Applicant / beneficiary personas that should hear about new calls (PRD §6.1 broad discovery).
CALL_ALERT_ROLES = (
    UserRole.SCHOLAR,
    UserRole.LEARNER,
    UserRole.ALUMNI,
    UserRole.ENTREPRENEUR,
    UserRole.REVIEWER,
)


@shared_task
def notify_call_published(call_id: int):
    """Notify potential applicants when a call is published."""
    call = GrantCall.objects.filter(pk=call_id).first()
    if not call:
        return
    qs = User.objects.filter(is_active=True, role__in=CALL_ALERT_ROLES)
    users = list(qs[:2000])
    ctx = rims_email_context(
        subject=f"New call: {call.title}",
        headline="A new funding call is open",
        action_path=reverse("rims_grants:call_detail", kwargs={"slug": call.slug}),
        action_label="View call",
        preheader=f"Closes {call.closes_at.date()} — {call.title}",
    )
    bulk_notify(
        users,
        f"New call published: {call.title} (closes {call.closes_at.date()}).",
        verb=Notification.Verb.CALL_PUBLISHED,
        email_context=ctx,
    )
    logger.info("notify_call_published: queued notifications for call_id=%s count=%s", call_id, len(users))


@shared_task
def remind_grant_call_deadlines():
    """PRD §5.1 FRFA-CS023/CS025 — remind in-progress applicants at the
    intervals configured on the call's ``CallNotificationConfig``.

    For each PUBLISHED call:
      1. Read intervals from ``CallNotificationConfig.get_reminder_intervals()``
         (defaults to ``[7, 1]`` when no row exists or the list is empty).
      2. For each interval ``N``, find DRAFT applicants whose call closes
         within ``[now + N - 12h, now + N + 12h]`` — the ±12h window catches
         the daily cron without double-sending when the beat runs near boundary.
      3. Skip if a ``DEADLINE_REMINDER`` notification was already sent for this
         (call, applicant) pair in the last 24h — prevents same-day duplicates
         when two intervals overlap.
    """
    from apps.rims.grants.models import CallNotificationConfig

    now = timezone.now()
    horizon_days = 30
    calls = GrantCall.objects.filter(
        status=GrantCall.Status.PUBLISHED,
        closes_at__lte=now + timedelta(days=horizon_days),
        closes_at__gte=now,
    )
    for call in calls:
        cfg = CallNotificationConfig.get_for_call(call)
        intervals = cfg.get_reminder_intervals()
        if not intervals:
            continue
        ctx = rims_email_context(
            subject=f"Deadline approaching: {call.title}",
            headline="Finish your application",
            action_path=reverse("rims_grants:call_apply", kwargs={"slug": call.slug}),
            action_label="Continue application",
            preheader=f"Submit before {call.closes_at.date()}.",
        )
        for interval in intervals:
            window_centre = now + timedelta(days=interval)
            window_start = window_centre - timedelta(hours=12)
            window_end = window_centre + timedelta(hours=12)
            if not (window_start <= call.closes_at <= window_end):
                continue
            drafts = list(
                Application.objects.filter(
                    call=call, status=Application.Status.DRAFT
                ).select_related("applicant")
            )
            sent_for = 0
            for app in drafts:
                # Dedup: skip if a deadline reminder was sent to this applicant
                # for this call in the last 24h.
                recent_exists = Notification.objects.filter(
                    recipient=app.applicant,
                    verb=Notification.Verb.DEADLINE_REMINDER,
                    created_at__gte=now - timedelta(hours=24),
                    message__icontains=call.title,
                ).exists()
                if recent_exists:
                    continue
                bulk_notify(
                    [app.applicant],
                    (
                        f"Reminder ({interval}d): finish and submit your application to "
                        f"“{call.title}”. Deadline: {call.closes_at.date()}."
                    ),
                    verb=Notification.Verb.DEADLINE_REMINDER,
                    email_context=ctx,
                )
                sent_for += 1
            logger.info(
                "remind_grant_call_deadlines: call=%s interval=%sd notified=%s skipped=%s",
                call.slug,
                interval,
                sent_for,
                len(drafts) - sent_for,
            )


@shared_task
def remind_award_acknowledgements(grace_days: int = 7, max_reminders: int = 3):
    """PRD §5.1 FRFA-AA005 — nag awardees who have not acknowledged the award
    letter. Reminders are bucketed: send once per day until the awardee
    acknowledges or `max_reminders` have been sent, whichever comes first."""
    from apps.rims.grants.models import Award
    from django.urls import reverse as _reverse

    cutoff = timezone.now() - timedelta(days=grace_days)
    pending = Award.objects.filter(
        acknowledged_at__isnull=True,
        status=Award.Status.ACTIVE,
        awarded_at__lte=cutoff,
        acknowledgement_reminders_sent__lt=max_reminders,
    ).select_related("application__applicant", "application__call")
    sent = 0
    for award in pending:
        try:
            applicant = award.application.applicant
            applicant_url = _reverse(
                "rims_grants:application_detail", kwargs={"pk": award.application_id}
            )
            ctx = rims_email_context(
                subject=f"Acknowledge your award: {award.application.call.title}",
                headline="Please acknowledge receipt of your award letter",
                action_path=applicant_url,
                action_label="Acknowledge award",
                preheader=f"Award issued on {award.awarded_at.date()}.",
            )
            bulk_notify(
                [applicant],
                (
                    f"Reminder: please acknowledge receipt of your award for "
                    f"“{award.application.call.title}”."
                ),
                verb=Notification.Verb.APPLICATION_STATUS,
                email_context=ctx,
            )
            award.acknowledgement_reminders_sent = award.acknowledgement_reminders_sent + 1
            award.save(update_fields=["acknowledgement_reminders_sent"])
            sent += 1
        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "remind_award_acknowledgements: failed for award=%s: %s", award.pk, exc
            )
    if sent:
        logger.info("remind_award_acknowledgements: sent %s reminder(s)", sent)
    return sent


@shared_task
def remind_overdue_reviews(warn_days_before: int = 3, overdue_grace_days: int = 1):
    """PRD §5.1 FRFA-AM022–023 — nudge reviewers before and after the review
    deadline. Sends at most one reminder per review per day; the Review model
    does not carry a `last_reminded_at` field yet so this task is best run
    once daily and relies on idempotent email templates."""
    from apps.rims.grants.models import Review

    now = timezone.now()
    warn_cutoff = now + timedelta(days=warn_days_before)
    overdue_cutoff = now - timedelta(days=overdue_grace_days)

    pending = Review.objects.filter(submitted_at__isnull=True, due_at__isnull=False).select_related(
        "reviewer", "application__call"
    )
    reminded = 0
    overdue = 0
    for review in pending:
        if review.due_at > warn_cutoff:
            continue
        is_overdue = review.due_at < now
        if is_overdue and review.due_at < overdue_cutoff:
            overdue += 1
        ctx = rims_email_context(
            subject=(
                "Overdue review" if is_overdue else "Review deadline approaching"
            )
            + f": {review.application.call.title}",
            headline=(
                "Your review is overdue"
                if is_overdue
                else "Your review deadline is approaching"
            ),
            action_path="/rims/grants/reviews/",
            action_label="Open review",
            preheader=f"Due by {review.due_at:%d %b %Y %H:%M}",
        )
        bulk_notify(
            [review.reviewer],
            f"Review deadline {review.due_at:%d %b %Y}: {review.application.call.title}",
            verb=Notification.Verb.DEADLINE_REMINDER,
            email_context=ctx,
        )
        reminded += 1
    if reminded:
        logger.info(
            "remind_overdue_reviews: warned=%s overdue=%s", reminded - overdue, overdue
        )
    return reminded


@shared_task
def expire_awards():
    """PRD §5.1 FRFA-CO003 — auto-trigger close-out when an Award's project end
    date passes without close-out having been initiated.

    Picks every ACTIVE award where ``project_end_date < today`` and:
      1. Flips its status to CLOSEOUT_IN_PROGRESS.
      2. Records ``closeout_ready_at``.
      3. Creates an AwardCloseoutRecord (which fires the FRFA-CO005
         initiation notifications via post_save signal).

    Idempotent: awards already in CLOSEOUT_IN_PROGRESS / CLOSED / CANCELLED
    are skipped. The disbursement-block downstream is enforced by
    ``apps.rims.finance.services.assert_award_allows_claims`` which already
    treats CLOSEOUT_IN_PROGRESS as a hard stop.
    """
    from apps.rims.grants.closeout_helpers import get_or_create_closeout_record
    from apps.rims.grants.models import Award

    today = timezone.now().date()
    expiring = Award.objects.filter(
        status=Award.Status.ACTIVE,
        project_end_date__lt=today,
    ).select_related("application__applicant", "application__call__funding_record")

    flipped = 0
    for award in expiring:
        try:
            award.status = Award.Status.CLOSEOUT_IN_PROGRESS
            if award.closeout_ready_at is None:
                award.closeout_ready_at = timezone.now()
            award.save(update_fields=["status", "closeout_ready_at"])
            get_or_create_closeout_record(award)
            flipped += 1
        except Exception as exc:  # noqa: BLE001
            logger.warning("expire_awards: failed for award=%s error=%s", award.pk, exc)
    if flipped:
        logger.info("expire_awards: flipped %s award(s) into close-out", flipped)
    return flipped


@shared_task
def chase_agreements(grace_days: int = 14, max_reminders: int = 3):
    """PRD §5.1 FRFA-AA009 — chase awardees in AGREEMENT_PENDING who have not
    uploaded an accepted signed agreement within ``grace_days`` of being
    awarded. Sends at most ``max_reminders`` per award.

    The model does not carry a per-row reminder counter for agreements yet, so
    we co-opt ``acknowledgement_reminders_sent`` (already used by the award
    letter chase). That is correct: in the real lifecycle, by the time we get
    here the awardee has acknowledged the letter but not returned the
    agreement, and ``acknowledgement_reminders_sent`` will be at 0 again.
    Future split: add a dedicated ``agreement_reminders_sent`` field if both
    chases need to run concurrently.
    """
    from apps.rims.grants.models import Award

    cutoff = timezone.now() - timedelta(days=grace_days)
    pending = Award.objects.filter(
        status=Award.Status.AGREEMENT_PENDING,
        awarded_at__lte=cutoff,
        acknowledgement_reminders_sent__lt=max_reminders,
    ).select_related("application__applicant", "application__call")
    sent = 0
    for award in pending:
        try:
            applicant = award.application.applicant
            applicant_url = reverse(
                "rims_grants:application_detail", kwargs={"pk": award.application_id}
            )
            ctx = rims_email_context(
                subject=f"Signed agreement still required: {award.application.call.title}",
                headline="Please upload your signed agreement",
                action_path=applicant_url,
                action_label="Open application",
                preheader=(
                    "Your award cannot move to active status until the signed agreement is "
                    "uploaded and accepted."
                ),
            )
            bulk_notify(
                [applicant],
                (
                    f"Reminder: please upload your signed agreement for "
                    f'"{award.application.call.title}". Your award is currently in '
                    f"agreement-pending status."
                ),
                verb=Notification.Verb.APPLICATION_STATUS,
                email_context=ctx,
            )
            award.acknowledgement_reminders_sent = award.acknowledgement_reminders_sent + 1
            award.save(update_fields=["acknowledgement_reminders_sent"])
            sent += 1
        except Exception as exc:  # noqa: BLE001
            logger.warning("chase_agreements: failed for award=%s error=%s", award.pk, exc)
    if sent:
        logger.info("chase_agreements: sent %s reminder(s)", sent)
    return sent


@shared_task
def remind_closeout_reports(warn_days_before: int = 7, overdue_grace_days: int = 7):
    """PRD §5.1 FRFA-CO011 — nag awardees in close-out who have not submitted
    a final narrative or financial report.

    For now, "submitted" means at least one row in
    FinalNarrativeReport / FinalFinancialReport linked to the award's close-out
    record. The reminder fires once daily until both reports exist.
    """
    from apps.rims.grants.models import Award, AwardCloseoutRecord

    now = timezone.now()
    awards = Award.objects.filter(
        status=Award.Status.CLOSEOUT_IN_PROGRESS,
    ).select_related("application__applicant", "application__call", "closeout_record")

    sent = 0
    for award in awards:
        closeout: AwardCloseoutRecord | None = getattr(award, "closeout_record", None)
        if closeout is None:
            continue
        has_narrative = closeout.narrative_reports.exists()
        has_financial = closeout.financial_reports.exists()
        if has_narrative and has_financial:
            continue
        # Don't spam from day-zero of close-out — wait for the warn window.
        if award.closeout_ready_at and (now - award.closeout_ready_at) < timedelta(
            days=warn_days_before
        ):
            continue
        applicant = award.application.applicant
        applicant_url = reverse(
            "rims_grants:application_detail", kwargs={"pk": award.application_id}
        )
        missing = []
        if not has_narrative:
            missing.append("final narrative report")
        if not has_financial:
            missing.append("final financial report")
        ctx = rims_email_context(
            subject=f"Close-out reports due: {award.application.call.title}",
            headline="Final reports still required",
            action_path=applicant_url,
            action_label="Open application",
            preheader="Submit your final reports through your application page.",
        )
        bulk_notify(
            [applicant],
            (
                f"Reminder: your award under "
                f'"{award.application.call.title}" is in close-out. The following '
                f"are still outstanding: {', '.join(missing)}."
            ),
            verb=Notification.Verb.DEADLINE_REMINDER,
            email_context=ctx,
        )
        sent += 1
    if sent:
        logger.info("remind_closeout_reports: sent %s reminder(s)", sent)
    return sent


@shared_task
def close_expired_calls():
    """PRD §5.1 FRFA-CS031 — auto-close published calls whose submission deadline
    has passed. Runs frequently (beat every 5 minutes). The call FSM transition
    is protected, so re-running the task is idempotent: only PUBLISHED calls with
    closes_at <= now are picked up. Applicant submissions are already blocked
    at the service layer (`submit_application`) once `closes_at` passes, so the
    call-level status flip is purely to reflect lifecycle state to users and to
    trigger close-out downstream work."""
    from apps.rims.grants.services import close_call

    now = timezone.now()
    expired = GrantCall.objects.filter(
        status=GrantCall.Status.PUBLISHED,
        closes_at__lte=now,
    )
    closed = 0
    for call in expired:
        try:
            close_call(call)
        except Exception as exc:  # noqa: BLE001 — do not let one bad call stop the sweep
            logger.warning(
                "close_expired_calls: failed to close call=%s error=%s",
                call.slug,
                exc,
            )
            continue
        closed += 1
        # PRD NFRFA010: lock drafts so they cannot be submitted post-deadline.
        # Applications in DRAFT move to EXPIRED_DRAFT; all other statuses are
        # left alone (submitted work stays in its review lane).
        drafts_expired = 0
        for app in Application.objects.filter(call=call, status=Application.Status.DRAFT):
            try:
                app.expire_draft()
                app.save(update_fields=["status", "updated_at"])
            except Exception as exc:  # noqa: BLE001
                logger.warning(
                    "close_expired_calls: failed to expire draft=%s error=%s",
                    app.pk,
                    exc,
                )
                continue
            drafts_expired += 1
        logger.info(
            "close_expired_calls: closed call=%s (drafts expired=%s)",
            call.slug,
            drafts_expired,
        )
    if closed:
        logger.info("close_expired_calls: closed %s call(s) at %s", closed, now.isoformat())
    return closed


@shared_task
def archive_closed_awards() -> int:
    """PRD §5.1 FRFA-CO015/016/017 — flag closed awards as archived once their
    funding-type-specific ``archive_after_days`` window has elapsed.

    The destructive ``retention_years`` purge (CO017 destructive arm) is
    intentionally **not** implemented here: deletion needs a dry-run mode and
    a legal-review gate to be safe in production. This beat only sets
    ``archived_at`` so auditors / archive views can separate active from
    historical records; archived rows remain fully readable.

    Idempotent: rows that already have ``archived_at`` set are skipped.
    """
    from apps.rims.grants.audit_helper import audit_rims
    from apps.rims.grants.models import Award
    from apps.rims.operations.models import RetentionPolicy

    policies = {p.funding_type: int(p.archive_after_days) for p in RetentionPolicy.objects.all()}
    if not policies:
        return 0

    now = timezone.now()
    candidates = (
        Award.objects.filter(
            status=Award.Status.CLOSED,
            archived_at__isnull=True,
            closed_at__isnull=False,
        )
        .select_related("application__call")
    )
    archived = 0
    for award in candidates:
        call = award.application.call
        call_type = getattr(call, "call_type", None) or "grant"
        days = policies.get(call_type)
        if days is None:
            continue
        if award.closed_at > now - timedelta(days=days):
            continue
        award.archived_at = now
        award.save(update_fields=["archived_at"])
        try:
            audit_rims(
                actor=None,
                action="AWARD_ARCHIVED",
                target_model="Award",
                object_id=award.pk,
                object_repr=str(award),
                changes={
                    "closed_at": award.closed_at.isoformat() if award.closed_at else None,
                    "archive_after_days": days,
                    "call_type": call_type,
                },
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("archive_closed_awards: audit failed for award=%s error=%s", award.pk, exc)
        archived += 1
    if archived:
        logger.info("archive_closed_awards: archived %s closed award(s)", archived)
    return archived


@shared_task
def auto_complete_interviews(grace_minutes: int = 30) -> int:
    """P1 (assessment §3 #11) — flip SCHEDULED InterviewSchedule rows to
    COMPLETED once the interview time has passed by `grace_minutes`.

    Without this beat the reviewer-assignment gate ``InterviewSchedule.status
    == COMPLETED`` waits on a manual flag from the Grants Manager, which
    bottlenecks the downstream workflow when nothing else has changed.
    """
    cutoff = timezone.now() - timedelta(minutes=grace_minutes)
    qs = (
        InterviewSchedule.objects.filter(
            status=InterviewSchedule.Status.SCHEDULED,
            scheduled_for__lt=cutoff,
        )
        .select_related("application__applicant", "application__call")
    )
    flipped = 0
    for interview in qs:
        interview.status = InterviewSchedule.Status.COMPLETED
        interview.save(update_fields=["status", "updated_at"])
        flipped += 1
    if flipped:
        logger.info("auto_complete_interviews: flipped %s interview(s)", flipped)
    return flipped
