"""Service layer for the feedback submodule."""
from __future__ import annotations

import logging
from typing import Any, Iterable

from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils import timezone

from apps.mel.feedback.models import (
    CourseEvaluation,
    FeedbackChannel,
    FeedbackChannelType,
    FeedbackSeverity,
    FeedbackStatus,
    FeedbackSubmission,
    SatisfactionScore,
    Survey,
    SurveyAnswer,
    SurveyDispatch,
    SurveyQuestion,
    SurveyQuestionType,
    SurveyResponse,
    SurveyStatus,
    TracerResponse,
    TracerStudy,
)
from apps.mel.tracking.models import (
    CorrectiveAction,
    CorrectiveActionStatus,
)

logger = logging.getLogger(__name__)


_AUTO_ACTION_SEVERITIES = {FeedbackSeverity.HIGH, FeedbackSeverity.CRITICAL}


# ---------------------------------------------------------------------------
# Channel helpers
# ---------------------------------------------------------------------------


def ensure_report_review_channel(report) -> FeedbackChannel:
    """Seed a ReportReview channel when a Report is published."""
    subject_ct = ContentType.objects.get_for_model(report.__class__)
    slug = f"report-{report.pk}"
    channel, created = FeedbackChannel.objects.get_or_create(
        slug=slug,
        defaults={
            "type": FeedbackChannelType.REPORT_REVIEW,
            "name": f"Feedback on {report.template.name} — {report.period_label}",
            "subject_type": subject_ct,
            "subject_id": report.pk,
            "token": FeedbackChannel.generate_token(),
            # Public + tokened so the published-report page (WS3) can surface a
            # usable "leave feedback on this report" submit link. Access stays
            # token-gated; is_public only controls whether the link is shown.
            "is_public": True,
        },
    )
    return channel


# ---------------------------------------------------------------------------
# Submission + triage
# ---------------------------------------------------------------------------


@transaction.atomic
def submit_feedback(
    *,
    channel: FeedbackChannel,
    narrative: str,
    submitter_user=None,
    submitter_email: str = "",
    submitter_name: str = "",
    rating: int | None = None,
    severity: str = FeedbackSeverity.LOW,
    submission_key: str = "",
) -> tuple[FeedbackSubmission, bool]:
    """Record a feedback submission. Idempotent when ``submission_key`` is set.

    Returns ``(submission, created)``.
    """
    if submission_key:
        existing = FeedbackSubmission.objects.filter(
            channel=channel, submission_key=submission_key,
        ).first()
        if existing is not None:
            return existing, False

    submission = FeedbackSubmission.objects.create(
        channel=channel,
        submitter_user=submitter_user,
        submitter_email=submitter_email,
        submitter_name=submitter_name,
        narrative=narrative,
        rating=rating,
        severity=severity,
        submission_key=submission_key,
    )
    logger.info(
        "mel.feedback submitted channel=%s severity=%s key=%s",
        channel.slug, severity, submission_key or "—",
    )

    if severity in _AUTO_ACTION_SEVERITIES:
        triage_feedback(submission, triaged_by=None, auto=True)

    return submission, True


@transaction.atomic
def triage_feedback(
    submission: FeedbackSubmission,
    *,
    triaged_by,
    auto: bool = False,
) -> FeedbackSubmission:
    """Mark a submission as triaged. High-severity items auto-create a
    :class:`CorrectiveAction` tied to the channel's subject when present.
    """
    submission.status = FeedbackStatus.TRIAGED
    submission.triaged_at = timezone.now()
    submission.triaged_by = triaged_by
    update_fields = ["status", "triaged_at", "triaged_by"]

    if submission.severity in _AUTO_ACTION_SEVERITIES and submission.corrective_action_id is None:
        subject_type = submission.channel.subject_type
        subject_id = submission.channel.subject_id
        if subject_type is not None and subject_id is not None:
            title = (
                f"Follow-up on {submission.channel.name} "
                f"({submission.get_severity_display()} severity feedback)"
            )
            action = CorrectiveAction.objects.create(
                subject_type=subject_type,
                subject_id=subject_id,
                title=title[:255],
                root_cause=(submission.narrative or "")[:2000],
                action_plan="Review feedback and plan remediation.",
                status=CorrectiveActionStatus.OPEN,
                created_by=triaged_by,
            )
            submission.corrective_action = action
            update_fields.append("corrective_action")
            logger.info(
                "mel.feedback auto-corrective-action pk=%s action=%s",
                submission.pk, action.pk,
            )
    submission.save(update_fields=update_fields)
    return submission


@transaction.atomic
def transition_corrective_action(
    action: CorrectiveAction,
    *,
    new_status: str,
    actor,
    action_plan: str | None = None,
    due_date=None,
    outcome_note: str | None = None,
) -> CorrectiveAction:
    """Move a CorrectiveAction through its FSM and notify originating submitter on resolution.

    Covers Fix #3 (in-place transition from feedback detail) and Fix #9
    (notify submitter when their feedback's CA is marked DONE).
    """
    previous_status = action.status
    action.status = new_status
    if action_plan is not None:
        action.action_plan = action_plan
    if due_date is not None:
        action.due_date = due_date
    if outcome_note is not None:
        action.outcome_note = outcome_note
    if new_status in {CorrectiveActionStatus.DONE, CorrectiveActionStatus.CANCELLED} and not action.closed_at:
        action.closed_at = timezone.now()
    action.save()

    # Notify submitter when their feedback's CA reaches DONE for the first time.
    if (
        new_status == CorrectiveActionStatus.DONE
        and previous_status != CorrectiveActionStatus.DONE
    ):
        _notify_submitter_resolved(action, resolved_by=actor)
    return action


def _notify_submitter_resolved(action: CorrectiveAction, *, resolved_by) -> None:
    """Send a resolution notification to feedback submitter(s) linked to this CA."""
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification

    submissions = list(
        FeedbackSubmission.objects.filter(corrective_action=action).select_related("submitter_user", "channel")
    )
    if not submissions:
        return
    resolved_message = (
        action.outcome_note.strip()
        or "Thanks for your feedback — the team has resolved the issue you raised."
    )
    for sub in submissions:
        ctx = {
            "feedback_id": sub.pk,
            "channel": sub.channel.name if sub.channel_id else "",
            "outcome": resolved_message,
            "resolver_name": getattr(resolved_by, "get_full_name", lambda: "")() or getattr(resolved_by, "username", ""),
        }
        if sub.submitter_user_id:
            send_notification(
                sub.submitter_user,
                resolved_message,
                verb=Notification.Verb.MEL_FEEDBACK_RESOLVED,
                email_context=ctx,
                data={"submission_id": sub.pk, "corrective_action_id": action.pk},
            )
        elif sub.submitter_email:
            # Anonymous submitter — dispatch email-only via the same machinery,
            # using a dummy recipient is not supported; log for now.
            logger.info(
                "mel.feedback resolution for anonymous submitter email=%s submission=%s",
                sub.submitter_email, sub.pk,
            )


# ---------------------------------------------------------------------------
# Moodle / REP course evaluations
# ---------------------------------------------------------------------------


@transaction.atomic
def ingest_course_evaluation(
    *,
    source_system: str,
    course_slug: str,
    period_label: str,
    course_name: str = "",
    aggregate_score=None,
    response_count: int = 0,
    payload: dict | None = None,
) -> CourseEvaluation:
    """Idempotent insert keyed on (source_system, course_slug, period_label)."""
    row, _ = CourseEvaluation.objects.update_or_create(
        source_system=source_system,
        course_slug=course_slug,
        period_label=period_label,
        defaults={
            "course_name": course_name,
            "aggregate_score": aggregate_score,
            "response_count": response_count,
            "payload": payload or {},
        },
    )
    return row


# ---------------------------------------------------------------------------
# Tracer studies
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# Phase 4.3 — Survey builder services
# ---------------------------------------------------------------------------


def _slugify_unique(base: str) -> str:
    from django.utils.text import slugify

    base_slug = slugify(base)[:120] or "survey"
    slug = base_slug
    n = 1
    while Survey.objects.filter(slug=slug).exists():
        n += 1
        slug = f"{base_slug}-{n}"[:140]
    return slug


@transaction.atomic
def create_survey(
    *,
    title: str,
    description: str = "",
    trigger: str = "manual",
    audience: dict | None = None,
    response_window_days: int = 14,
    is_anonymous: bool = False,
    questions: Iterable[dict] | None = None,
    created_by=None,
) -> Survey:
    """Create a draft Survey with optional initial questions.

    ``questions`` is a list of dicts: ``{type, text, required, options, page}``.
    """
    survey = Survey.objects.create(
        title=title,
        slug=_slugify_unique(title),
        description=description,
        trigger=trigger,
        audience=audience or {},
        response_window_days=response_window_days,
        is_anonymous=is_anonymous,
        created_by=created_by,
    )
    if questions:
        for ordinal, q in enumerate(questions):
            SurveyQuestion.objects.create(
                survey=survey,
                ordinal=ordinal,
                page=int(q.get("page") or 1),
                type=q.get("type") or SurveyQuestionType.SHORT_ANSWER,
                text=q.get("text") or "",
                help_text=q.get("help_text") or "",
                required=bool(q.get("required")),
                options=q.get("options") or [],
            )
    return survey


@transaction.atomic
def activate_survey(survey: Survey) -> Survey:
    """Provision the backing FeedbackChannel and mark the survey active."""
    if survey.status == SurveyStatus.ACTIVE:
        return survey
    if not survey.questions.exists():
        raise ValueError("Cannot activate a survey with no questions.")
    if survey.channel is None:
        survey.channel = FeedbackChannel.objects.create(
            type=FeedbackChannelType.GENERIC,
            name=survey.title[:255],
            slug=f"survey-{survey.slug}",
            description=survey.description,
            token=FeedbackChannel.generate_token(),
            is_public=False,
            is_active=True,
            opens_at=timezone.now(),
        )
    survey.status = SurveyStatus.ACTIVE
    survey.activated_at = timezone.now()
    survey.save(update_fields=["channel", "status", "activated_at", "updated_at"])
    logger.info("mel.survey activated id=%s slug=%s", survey.pk, survey.slug)
    return survey


def close_survey(survey: Survey) -> Survey:
    survey.status = SurveyStatus.CLOSED
    survey.closed_at = timezone.now()
    survey.save(update_fields=["status", "closed_at", "updated_at"])
    if survey.channel:
        FeedbackChannel.objects.filter(pk=survey.channel_id).update(is_active=False)
    return survey


def survey_dispatch_take_url(dispatch: SurveyDispatch) -> str:
    """Absolute, token-gated URL a recipient uses to take their dispatched survey."""
    from django.urls import reverse

    from apps.core.notifications.emailing import absolute_portal_url

    return absolute_portal_url(
        reverse("mel_feedback:survey_take_dispatch", args=[dispatch.token])
    )


def deliver_dispatch_invite(dispatch: SurveyDispatch) -> None:
    """Notify a survey recipient with their tokenised take link (D-D1).

    In-app + email for authenticated recipients; email-only recipients are
    logged (the notification pipeline needs a User recipient). Failures never
    propagate — delivery must not roll back the dispatch.
    """
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification

    take_url = survey_dispatch_take_url(dispatch)
    message = (
        f"You've been invited to complete the survey "
        f"‘{dispatch.survey.title}’. Please share your feedback."
    )
    email_context = {
        "body": message,
        "action_url": take_url,
        "action_label": "Open the survey",
        "take_url": take_url,
        "survey_title": dispatch.survey.title,
    }
    if dispatch.recipient_id is not None:
        try:
            send_notification(
                dispatch.recipient,
                message,
                verb=Notification.Verb.MEL_SURVEY_INVITE,
                email_context=email_context,
                data={"survey": dispatch.survey.slug, "take_url": take_url},
                action_url=take_url,
            )
        except Exception:  # pragma: no cover — never break a dispatch
            logger.exception("survey dispatch invite failed pk=%s", dispatch.pk)
    else:
        logger.info(
            "mel.survey dispatch invite (email-only) email=%s url=%s",
            dispatch.recipient_email, take_url,
        )


@transaction.atomic
def dispatch_survey(
    survey: Survey,
    *,
    recipients: Iterable | None = None,
    recipient_emails: Iterable[str] | None = None,
    trigger_event: str = "",
) -> list[SurveyDispatch]:
    """Create one :class:`SurveyDispatch` per recipient. Idempotent on
    ``(survey, recipient or recipient_email, trigger_event)``.

    Newly created dispatches are delivered with their tokenised take link so
    recipients can actually reach the survey (D-D1).
    """
    if survey.status != SurveyStatus.ACTIVE:
        raise ValueError("Only ACTIVE surveys can be dispatched.")
    from datetime import timedelta

    due_at = timezone.now() + timedelta(days=int(survey.response_window_days or 14))
    out: list[SurveyDispatch] = []
    for user in recipients or []:
        existing = SurveyDispatch.objects.filter(
            survey=survey,
            recipient=user,
            trigger_event=trigger_event,
        ).first()
        if existing is not None:
            out.append(existing)
            continue
        dispatch = SurveyDispatch.objects.create(
            survey=survey,
            recipient=user,
            recipient_email=getattr(user, "email", "") or "",
            token=SurveyDispatch.generate_token(),
            trigger_event=trigger_event,
            due_at=due_at,
        )
        deliver_dispatch_invite(dispatch)
        out.append(dispatch)
    for email in recipient_emails or []:
        if not email:
            continue
        existing = SurveyDispatch.objects.filter(
            survey=survey,
            recipient_email=email,
            recipient__isnull=True,
            trigger_event=trigger_event,
        ).first()
        if existing is not None:
            out.append(existing)
            continue
        dispatch = SurveyDispatch.objects.create(
            survey=survey,
            recipient=None,
            recipient_email=email,
            token=SurveyDispatch.generate_token(),
            trigger_event=trigger_event,
            due_at=due_at,
        )
        deliver_dispatch_invite(dispatch)
        out.append(dispatch)
    return out


@transaction.atomic
def collect_response(
    survey: Survey,
    *,
    answers: dict[int, Any],
    dispatch: SurveyDispatch | None = None,
    respondent=None,
    respondent_email: str = "",
    complete: bool = True,
    response: SurveyResponse | None = None,
) -> SurveyResponse:
    """Persist (or update) a SurveyResponse + answers.

    ``answers`` maps question_id → JSON-serialisable value. Marks the response
    completed when ``complete`` is true, dispatch as completed, and creates
    a backing :class:`FeedbackSubmission` so triage receivers fire.

    Callers may pass an explicit in-progress ``response`` to reuse across a
    multi-page take (channel-token takes have no dispatch to key on, so the
    view keys the in-progress response by session — D-D4).
    """
    if response is None and dispatch is not None:
        response = SurveyResponse.objects.filter(
            survey=survey, dispatch=dispatch
        ).first()
    if response is None:
        response = SurveyResponse.objects.create(
            survey=survey,
            dispatch=dispatch,
            respondent=respondent,
            respondent_email=respondent_email or (getattr(respondent, "email", "") or ""),
        )
    questions = {q.pk: q for q in survey.questions.all()}
    for qid, value in (answers or {}).items():
        q = questions.get(int(qid))
        if q is None:
            continue
        free_text = ""
        if isinstance(value, dict):
            free_text = str(value.get("free_text") or "")
            value_payload = {"value": value.get("value")}
        else:
            value_payload = {"value": value}
            if isinstance(value, str) and q.type in {
                SurveyQuestionType.ESSAY,
                SurveyQuestionType.SHORT_ANSWER,
            }:
                free_text = value
        SurveyAnswer.objects.update_or_create(
            response=response,
            question=q,
            defaults={"value": value_payload, "free_text": free_text},
        )
    if complete:
        response.status = SurveyResponse.Status.COMPLETED
        response.completed_at = timezone.now()
        if response.submission_id is None and survey.channel_id is not None:
            narrative = _summarise_response(response)
            submission, _ = submit_feedback(
                channel=survey.channel,
                narrative=narrative,
                submitter_user=respondent,
                submitter_email=response.respondent_email,
                rating=_extract_average_rating(response),
                submission_key=f"survey:{response.pk}",
            )
            response.submission = submission
        response.save(update_fields=["status", "completed_at", "submission"])
        if dispatch is not None:
            dispatch.status = SurveyDispatch.Status.COMPLETED
            dispatch.completed_at = response.completed_at
            dispatch.save(update_fields=["status", "completed_at"])
    return response


def _summarise_response(response: SurveyResponse) -> str:
    """Build a one-paragraph summary of a response for FeedbackSubmission.narrative."""
    parts: list[str] = []
    for ans in response.answers.select_related("question").order_by("question__ordinal"):
        v = ans.free_text or (ans.value or {}).get("value")
        if v in (None, "", []):
            continue
        # Render Yes/No question answers as words, not the raw "true"/"false"
        # tokens the take-form radios submit (D-D11c).
        if ans.question.type == SurveyQuestionType.BOOLEAN:
            v = "Yes" if _coerce_bool(v) else "No"
        parts.append(f"Q: {ans.question.text[:140]}\nA: {v}")
    return "\n\n".join(parts) or "(no narrative answers)"


def _extract_average_rating(response: SurveyResponse) -> int | None:
    ratings: list[int] = []
    for ans in response.answers.select_related("question"):
        if ans.question.type != SurveyQuestionType.RATING:
            continue
        try:
            ratings.append(int((ans.value or {}).get("value")))
        except (TypeError, ValueError):
            continue
    if not ratings:
        return None
    return int(round(sum(ratings) / len(ratings)))


def _coerce_bool(value: Any) -> bool:
    """Interpret a stored boolean-question answer.

    Take-form radios submit the *strings* ``"true"`` / ``"false"``, and
    ``bool("false")`` is truthy — so a plain ``bool()`` would count every
    answer as "Yes". Normalise common truthy tokens instead.
    """
    if isinstance(value, str):
        return value.strip().lower() in {"true", "1", "yes", "y", "on"}
    return bool(value)


def analyse_responses(survey: Survey) -> dict:
    """Recompute :class:`SatisfactionScore` rows for this survey.

    Returns the latest aggregate dict so views can render directly without
    re-querying the score table.
    """
    SatisfactionScore.objects.filter(survey=survey).delete()
    completed = SurveyResponse.objects.filter(
        survey=survey, status=SurveyResponse.Status.COMPLETED
    )
    from django.db.models import Q

    completed_count = completed.count()
    dispatched = SurveyDispatch.objects.filter(survey=survey).count()
    # A dispatch only counts as a non-response once its window has actually
    # lapsed (or the sweep has flagged it). A just-sent dispatch still inside
    # its response window is neither completed nor a non-response, so a fresh
    # survey no longer reports 100% non-response (D-D11g).
    now = timezone.now()
    non_response = (
        SurveyDispatch.objects.filter(survey=survey)
        .filter(
            Q(status=SurveyDispatch.Status.NON_RESPONDED)
            | Q(
                status__in=[SurveyDispatch.Status.SENT, SurveyDispatch.Status.REMINDED],
                due_at__lt=now,
            )
        )
        .count()
    )
    completion_rate = (completed_count / dispatched * 100) if dispatched else 0
    non_response_rate = (non_response / dispatched * 100) if dispatched else 0

    SatisfactionScore.objects.create(
        survey=survey,
        dimension=SatisfactionScore.Dimension.COUNT,
        value=completed_count,
        sample_size=dispatched,
    )
    SatisfactionScore.objects.create(
        survey=survey,
        dimension=SatisfactionScore.Dimension.COMPLETION_RATE,
        value=round(completion_rate, 2),
        sample_size=dispatched,
    )
    SatisfactionScore.objects.create(
        survey=survey,
        dimension=SatisfactionScore.Dimension.NON_RESPONSE_RATE,
        value=round(non_response_rate, 2),
        sample_size=dispatched,
    )

    per_question: dict[int, dict] = {}
    word_counter: dict[str, int] = {}
    nps_buckets = {"promoters": 0, "passives": 0, "detractors": 0}

    for q in survey.questions.all():
        ratings: list[int] = []
        booleans: list[bool] = []
        choice_counter: dict[str, int] = {}
        nps_values: list[int] = []
        essay_words: list[str] = []
        for ans in q.answers.select_related("response").filter(
            response__status=SurveyResponse.Status.COMPLETED
        ):
            v = (ans.value or {}).get("value")
            if q.type == SurveyQuestionType.RATING and v is not None:
                try:
                    ratings.append(int(v))
                except (TypeError, ValueError):
                    continue
            elif q.type == SurveyQuestionType.NPS and v is not None:
                try:
                    nps_values.append(int(v))
                except (TypeError, ValueError):
                    continue
            elif q.type == SurveyQuestionType.BOOLEAN and v is not None:
                booleans.append(_coerce_bool(v))
            elif q.type == SurveyQuestionType.MULTIPLE_CHOICE and v is not None:
                key = str(v)
                choice_counter[key] = choice_counter.get(key, 0) + 1
            elif q.type in (SurveyQuestionType.SHORT_ANSWER, SurveyQuestionType.ESSAY):
                for raw_word in (ans.free_text or "").lower().split():
                    # Strip surrounding punctuation so "staff." and "staff"
                    # collapse into one word-cloud token (D-D11c).
                    token = raw_word.strip(".,!?;:'\"()[]{}—–-")
                    if token:
                        essay_words.append(token)
        question_payload: dict = {"type": q.type, "n": q.answers.count()}
        if ratings:
            mean = sum(ratings) / len(ratings)
            SatisfactionScore.objects.create(
                survey=survey,
                question=q,
                dimension=SatisfactionScore.Dimension.MEAN_RATING,
                value=round(mean, 2),
                sample_size=len(ratings),
            )
            question_payload["mean_rating"] = round(mean, 2)
        if nps_values:
            promoters = sum(1 for v in nps_values if v >= 9)
            detractors = sum(1 for v in nps_values if v <= 6)
            passives = len(nps_values) - promoters - detractors
            score = ((promoters - detractors) / len(nps_values)) * 100
            SatisfactionScore.objects.create(
                survey=survey,
                question=q,
                dimension=SatisfactionScore.Dimension.NPS,
                value=round(score, 2),
                sample_size=len(nps_values),
                payload={"promoters": promoters, "passives": passives, "detractors": detractors},
            )
            question_payload["nps"] = round(score, 2)
            nps_buckets["promoters"] += promoters
            nps_buckets["passives"] += passives
            nps_buckets["detractors"] += detractors
        if booleans:
            total_bool = len(booleans)
            yes = sum(1 for b in booleans if b)
            no = total_bool - yes
            question_payload["yes_count"] = yes
            question_payload["no_count"] = no
            question_payload["yes_pct"] = round((yes / total_bool) * 100, 1)
            question_payload["no_pct"] = round((no / total_bool) * 100, 1)
        if choice_counter:
            question_payload["choices"] = choice_counter
        if essay_words:
            for word in essay_words:
                if len(word) <= 3:
                    continue
                word_counter[word] = word_counter.get(word, 0) + 1
        per_question[q.pk] = question_payload

    return {
        "completed": completed_count,
        "dispatched": dispatched,
        "completion_rate": round(completion_rate, 2),
        "non_response_rate": round(non_response_rate, 2),
        "nps_buckets": nps_buckets,
        "per_question": per_question,
        "word_cloud": sorted(word_counter.items(), key=lambda kv: -kv[1])[:50],
    }


def import_tracer_responses(study: TracerStudy, rows: Iterable[dict]) -> int:
    """Bulk import tracer responses from a parsed CSV/JSON list.

    Each row should contain at least ``respondent_email``. Returns the count
    of rows ingested.
    """
    count = 0
    for row in rows:
        TracerResponse.objects.create(
            study=study,
            respondent_email=row.get("email", ""),
            respondent_name=row.get("name", ""),
            employment_status=row.get("employment_status", ""),
            employer=row.get("employer", ""),
            role=row.get("role", ""),
            salary_band=row.get("salary_band", ""),
            payload={k: v for k, v in row.items() if k not in {"email", "name"}},
        )
        count += 1
    logger.info("mel.feedback tracer import study=%s count=%s", study.slug, count)
    return count
