"""Celery tasks for M&EL tracking sweeps and upstream ingest.

The REP ``drain_mel_outbox`` task was removed with the REP LMS. Moodle learning
outcomes are ingested by ``pull_moodle_outcomes`` (added in the Moodle connector),
which reuses the same idempotent ``record_automated_point`` write path.
"""
from __future__ import annotations

import logging

from celery import shared_task
from django.db import transaction  # noqa: F401  (used by other tasks below)
from django.utils import timezone

logger = logging.getLogger(__name__)


@shared_task(name="apps.mel.tracking.pull_moodle_outcomes")
def pull_moodle_outcomes() -> dict:
    """Pull learning outcomes from Moodle Web Services into MEL (idempotent).

    Replaces the retired REP outbox drain. For each Moodle course it records:
      * course completions  → ``moodle-courses-completed``
      * enrolments          → ``moodle-enrolments`` (+ stamps CustomUser.lms_ref)
      * finished quiz attempts → ``moodle-quiz-submissions``
      * recorded grade items → ``moodle-grades-recorded``
      * feedback activities  → CourseEvaluation (source_system="moodle")

    Each DataPoint is idempotent on (indicator, source_module, source_object_id),
    so re-running never double-counts. No-op when the Moodle client isn't
    configured.
    """
    from apps.mel.feedback.services import ingest_course_evaluation
    from apps.mel.indicators.services import record_automated_point
    from apps.mel.tracking.moodle_client import MoodleClient, MoodleError
    from apps.mel.tracking.moodle_indicators import MOODLE_EVENT_INDICATOR_MAP

    client = MoodleClient()
    if not client.configured:
        logger.info("mel.moodle_pull skipped — client not configured")
        return {"configured": False}

    stats = {
        "courses": 0, "completions": 0, "enrolments": 0,
        "quiz_submissions": 0, "grades": 0, "evaluations": 0,
    }
    try:
        courses = client.call("core_course_get_courses") or []
    except MoodleError:
        logger.exception("mel.moodle_pull: core_course_get_courses failed")
        return {"configured": True, "error": "course_list"}

    m = MOODLE_EVENT_INDICATOR_MAP
    for course in courses:
        course_id = course.get("id")
        # Skip the Moodle site "course" (id 1) which is not a real course.
        if not course_id or course_id == 1:
            continue
        stats["courses"] += 1
        course_slug = course.get("idnumber") or course.get("shortname") or str(course_id)

        # Enrolled users are fetched once per course and shared across every
        # per-user sweep below (was previously re-fetched per sweep).
        try:
            users = client.call("core_enrol_get_enrolled_users", courseid=course_id) or []
        except MoodleError:
            logger.exception("mel.moodle_pull: enrolled_users failed course=%s", course_id)
            users = []

        stats["enrolments"] += _pull_course_enrolments(users, course_id, m, record_automated_point)
        stats["completions"] += _pull_course_completions(client, users, course_id, m, record_automated_point)
        stats["quiz_submissions"] += _pull_course_quiz_submissions(client, users, course_id, m, record_automated_point)
        stats["grades"] += _pull_course_grades(client, users, course_id, m, record_automated_point)
        stats["evaluations"] += _pull_course_feedback(client, course_id, course_slug, ingest_course_evaluation)

    logger.info("mel.moodle_pull %s", stats)
    return {"configured": True, **stats}


def _pull_course_enrolments(users, course_id, event_map, record) -> int:
    n = 0
    for u in users:
        uid = u.get("id")
        record(
            indicator_code=event_map["enrolment_created"],
            source_module="moodle",
            source_event="enrolment_created",
            source_object_id=f"enrol:{uid}:{course_id}",
        )
        _link_lms_ref(u.get("email"), uid)
        n += 1
    return n


def _pull_course_completions(client, users, course_id, event_map, record) -> int:
    from apps.mel.tracking.moodle_client import MoodleError

    n = 0
    for u in users:
        uid = u.get("id")
        try:
            status = client.call(
                "core_completion_get_course_completion_status", courseid=course_id, userid=uid
            )
        except MoodleError:
            continue
        completion = (status or {}).get("completionstatus") or {}
        if completion.get("completed"):
            record(
                indicator_code=event_map["course_completed"],
                source_module="moodle",
                source_event="course_completed",
                source_object_id=f"completion:{uid}:{course_id}",
            )
            n += 1
    return n


def _pull_course_quiz_submissions(client, users, course_id, event_map, record) -> int:
    """One DataPoint per finished quiz attempt (keyed by attempt id)."""
    from apps.mel.tracking.moodle_client import MoodleError

    try:
        payload = client.call("mod_quiz_get_quizzes_by_courses", courseids=[course_id])
    except MoodleError:
        return 0
    quizzes = (payload or {}).get("quizzes") or []
    n = 0
    for quiz in quizzes:
        quiz_id = quiz.get("id")
        if not quiz_id:
            continue
        for u in users:
            uid = u.get("id")
            try:
                res = client.call(
                    "mod_quiz_get_user_attempts", quizid=quiz_id, userid=uid, status="finished"
                )
            except MoodleError:
                continue
            for attempt in (res or {}).get("attempts") or []:
                state = attempt.get("state")
                if state and state != "finished":
                    continue
                attempt_id = attempt.get("id")
                if not attempt_id:
                    continue
                record(
                    indicator_code=event_map["quiz_submitted"],
                    source_module="moodle",
                    source_event="quiz_submitted",
                    source_object_id=f"quizattempt:{attempt_id}",
                )
                n += 1
    return n


def _pull_course_grades(client, users, course_id, event_map, record) -> int:
    """One DataPoint per recorded grade item (non-null raw grade), keyed by
    (user, grade item)."""
    from apps.mel.tracking.moodle_client import MoodleError

    n = 0
    for u in users:
        uid = u.get("id")
        try:
            payload = client.call(
                "gradereport_user_get_grade_items", courseid=course_id, userid=uid
            )
        except MoodleError:
            continue
        for usergrade in (payload or {}).get("usergrades") or []:
            for item in usergrade.get("gradeitems") or []:
                if item.get("graderaw") is None:
                    continue
                item_id = item.get("id")
                if not item_id:
                    continue
                record(
                    indicator_code=event_map["grade_recorded"],
                    source_module="moodle",
                    source_event="grade_recorded",
                    source_object_id=f"grade:{uid}:{item_id}",
                )
                n += 1
    return n


def _pull_course_feedback(client, course_id, course_slug, ingest) -> int:
    from apps.mel.tracking.moodle_client import MoodleError

    try:
        payload = client.call("mod_feedback_get_feedbacks_by_courses", courseids=[course_id])
    except MoodleError:
        return 0
    feedbacks = (payload or {}).get("feedbacks") or []
    n = 0
    for fb in feedbacks:
        try:
            analysis = client.call("mod_feedback_get_analysis", feedbackid=fb.get("id"))
        except MoodleError:
            continue
        response_count = int((analysis or {}).get("completedcount") or 0)
        ingest(
            source_system="moodle",
            course_slug=course_slug,
            course_name=fb.get("name") or course_slug,
            period_label=str(timezone.now().year),
            aggregate_score=None,
            response_count=response_count,
            payload=analysis or {},
        )
        n += 1
    return n


def _link_lms_ref(email: str | None, moodle_user_id) -> None:
    """Stamp CustomUser.lms_ref for a matching email when it isn't set yet."""
    if not email or not moodle_user_id:
        return
    from django.contrib.auth import get_user_model

    User = get_user_model()
    (
        User.objects.filter(email__iexact=email, lms_ref__isnull=True)
        .update(lms_ref=moodle_user_id)
    )


@shared_task(name="apps.mel.tracking.scan_delayed_activities")
def scan_delayed_activities_task() -> int:
    """Celery-beat entry point for :func:`services.scan_delayed_activities`.

    Also sweeps past-due OutputDeliverables into OVERDUE (FRMFL016) so a
    neglected deliverable surfaces without waiting for someone to post
    progress — see :func:`services.scan_overdue_outputs`.
    """
    from apps.mel.tracking.services import (
        scan_delayed_activities,
        scan_overdue_outputs,
    )

    flagged = scan_delayed_activities()
    outputs_flagged = scan_overdue_outputs()
    logger.info(
        "mel.activity.scan flagged=%s outputs_overdue=%s", flagged, outputs_flagged
    )
    return flagged


@shared_task(name="apps.mel.tracking.scan_overdue_outputs")
def scan_overdue_outputs_task() -> int:
    """Standalone Celery-beat entry point for :func:`services.scan_overdue_outputs`.

    Provided so the output sweep can be scheduled independently of the activity
    scan if desired; ``scan_delayed_activities_task`` also runs it.
    """
    from apps.mel.tracking.services import scan_overdue_outputs

    flagged = scan_overdue_outputs()
    logger.info("mel.output.scan overdue_flagged=%s", flagged)
    return flagged


@shared_task(name="apps.mel.tracking.scan_overdue_corrective_actions")
def scan_overdue_corrective_actions_task() -> int:
    """M&E SRS Table 66 (E4) — daily reminder + escalation of overdue corrective
    actions. Celery-beat entry point for
    :func:`services.scan_overdue_corrective_actions`.
    """
    from apps.mel.tracking.services import scan_overdue_corrective_actions

    escalated = scan_overdue_corrective_actions()
    logger.info("mel.corrective.scan escalated=%s", escalated)
    return escalated


@shared_task(name="apps.mel.tracking.flag_stalled_smehub_records")
def flag_stalled_smehub_records_task(stall_threshold_days: int | None = None) -> int:
    """Daily Celery-beat sweep — flag SMEHubTrackingRecord rows with no recent
    milestone activity (FRSME-MEI012).

    Threshold defaults to ``MELConfiguration.smehub_stall_threshold_days``
    when no override is passed (falls back to ``settings.SMEHUB_STALL_THRESHOLD_DAYS``).
    On flagging, also notifies MEL officers + SME admins so the stall surfaces
    out of the dashboard alone.

    Returns the number of newly stalled records.
    """
    from datetime import timedelta

    from django.conf import settings as dj_settings

    from apps.mel.tracking.models import MELConfiguration, SMEHubTrackingRecord
    from apps.mel.tracking.services import notify_newly_stalled

    if stall_threshold_days is None:
        try:
            config = MELConfiguration.get()
            threshold = int(config.smehub_stall_threshold_days)
        except Exception:  # pragma: no cover — DB read failure mid-task
            threshold = int(getattr(dj_settings, "SMEHUB_STALL_THRESHOLD_DAYS", 90))
    else:
        threshold = int(stall_threshold_days)
    cutoff = timezone.now() - timedelta(days=threshold)

    qs = SMEHubTrackingRecord.objects.filter(
        is_stalled=False,
        last_milestone_at__lt=cutoff,
    )
    flagged_records: list = []
    now = timezone.now()
    for record in qs:
        record.is_stalled = True
        record.stall_flagged_at = now
        record.save(update_fields=["is_stalled", "stall_flagged_at", "updated_at"])
        flagged_records.append(record)
    flagged = len(flagged_records)
    if flagged:
        logger.info("smehub-mel.stall flagged=%s threshold_days=%s", flagged, threshold)
        try:
            notify_newly_stalled(flagged_records, threshold_days=threshold)
        except Exception:  # pragma: no cover — notification must not break the sweep
            logger.exception("notify_newly_stalled dispatch failed (flagged=%s)", flagged)
    return flagged


@shared_task(name="apps.mel.tracking.dispatch_feedback_reminders")
def dispatch_feedback_reminders_task() -> int:
    """FRSME-MEI019 — remind entrepreneurs whose beneficiary survey has been
    open longer than ``SMEHUB_FEEDBACK_REMINDER_AFTER_DAYS`` days, hasn't been
    completed, and hasn't already been reminded.

    Returns the number of reminders sent.
    """
    from datetime import timedelta

    from django.conf import settings as dj_settings

    from apps.mel.tracking.models import SMEHubBeneficiaryFeedback
    from apps.mel.tracking.smehub_feedback import _notify_entrepreneur

    after_days = int(getattr(dj_settings, "SMEHUB_FEEDBACK_REMINDER_AFTER_DAYS", 7))
    cutoff = timezone.now() - timedelta(days=after_days)

    qs = (
        SMEHubBeneficiaryFeedback.objects.filter(
            status=SMEHubBeneficiaryFeedback.Status.SENT,
            dispatched_at__lt=cutoff,
            reminded_at__isnull=True,
        )
        .select_related("tracking_record__entrepreneur__user", "feedback_channel")
    )
    sent = 0
    now = timezone.now()
    for row in qs:
        if row.feedback_channel is None:
            continue
        try:
            _notify_entrepreneur(
                tracking_record=row.tracking_record,
                channel=row.feedback_channel,
                prompt=(
                    "Reminder — we'd still love your feedback on the "
                    f"{row.get_stage_display().lower()} milestone:"
                ),
            )
        except Exception:  # noqa: BLE001 — reminder must never break the sweep
            logger.exception("feedback-reminder dispatch failed for #%s", row.pk)
            continue
        SMEHubBeneficiaryFeedback.objects.filter(pk=row.pk).update(reminded_at=now)
        sent += 1
    if sent:
        logger.info("smehub-mel.feedback_reminders sent=%s", sent)
    return sent


@shared_task(name="apps.mel.tracking.record_feedback_non_responses")
def record_feedback_non_responses_task() -> int:
    """FRSME-MEI019 — flag overdue beneficiary surveys as non-responded
    and bump the data-quality counter on the tracking record.

    Returns the number of rows newly flagged.
    """
    from django.db.models import F

    from apps.mel.tracking.models import (
        SMEHubBeneficiaryFeedback,
        SMEHubTrackingRecord,
    )

    now = timezone.now()
    overdue = SMEHubBeneficiaryFeedback.objects.filter(
        status=SMEHubBeneficiaryFeedback.Status.SENT,
        due_at__isnull=False,
        due_at__lt=now,
    )
    flagged = 0
    for row in overdue:
        SMEHubBeneficiaryFeedback.objects.filter(pk=row.pk).update(
            status=SMEHubBeneficiaryFeedback.Status.NON_RESPONDED,
        )
        SMEHubTrackingRecord.objects.filter(pk=row.tracking_record_id).update(
            feedback_non_response_count=F("feedback_non_response_count") + 1,
            updated_at=now,
        )
        flagged += 1
    if flagged:
        logger.info("smehub-mel.feedback_non_responses flagged=%s", flagged)
    return flagged


@shared_task(name="apps.mel.tracking.recompute_impact_scores")
def recompute_impact_scores_task(period: str = "y1") -> int:
    """Refresh :class:`ImpactScore` for every Participant. Designed for
    nightly Celery beat. Failures per-participant are swallowed."""
    from apps.mel.tracking.models import ImpactScore, Participant
    from apps.mel.tracking.services import compute_impact_score

    valid = {p for p, _ in ImpactScore.Period.choices}
    if period not in valid:
        period = ImpactScore.Period.Y1

    count = 0
    for participant in Participant.objects.all().iterator():
        try:
            compute_impact_score(participant, period=period)
            count += 1
        except Exception:  # pragma: no cover - defensive
            logger.exception("impact recompute failed pk=%s", participant.pk)
    if count:
        logger.info("mel.impact_score recomputed=%s period=%s", count, period)
    return count
