"""Service layer for M&EL reports.

Orchestrates:

* :func:`generate_report` — snapshot upstream data and persist a Report row.
* :func:`render_pdf` / :func:`render_xlsx` / :func:`render_html` — turn a
  Report into a :class:`ReportArtifact`.
* :func:`distribute_report` — enqueue email distribution through
  :mod:`apps.core.notifications`.
* :func:`run_compliance_checks` — emit :class:`ComplianceAlert` rows for
  RED indicators and missing evidence.
* :func:`publish_report` — transition the report to PUBLISHED and fire
  ``mel_report_published`` for Phase 3 feedback hooks.
"""
from __future__ import annotations

import logging
from datetime import date, timedelta
from typing import Iterable

from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models, transaction
from django.template.loader import render_to_string
from django.utils import timezone

from apps.mel.indicators.models import RagStatus
from apps.mel.reports.builders import build_consolidated_data
from apps.mel.reports.models import (
    REPORT_STATUS_TRANSITIONS,
    ArtifactFormat,
    ComplianceAlert,
    ComplianceSeverity,
    DistributionChannel,
    DistributionStatus,
    Report,
    ReportArtifact,
    ReportDistribution,
    ReportStatus,
    ReportTemplate,
)
from apps.mel.reports.signals import mel_report_published

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Period helpers
# ---------------------------------------------------------------------------


def quarter_window(today: date | None = None) -> tuple[str, date, date]:
    """Return (label, start, end) for the current quarter."""
    d = today or timezone.now().date()
    q = (d.month - 1) // 3 + 1
    start_month = (q - 1) * 3 + 1
    start = date(d.year, start_month, 1)
    end_month = start_month + 3
    if end_month > 12:
        end = date(d.year, 12, 31)
    else:
        end = date(d.year, end_month, 1) - timedelta(days=1)
    return f"{d.year}-Q{q}", start, end


# ---------------------------------------------------------------------------
# Generate
# ---------------------------------------------------------------------------


@transaction.atomic
def generate_report(
    *,
    template: ReportTemplate,
    period_label: str,
    period_start: date,
    period_end: date,
    user=None,
    narrative: str = "",
) -> Report:
    """Build a Report snapshot and persist it in DRAFT status.

    Idempotent by ``(template, period_label)``: re-running on the same window
    refreshes the snapshot on an existing row instead of creating duplicates.
    """
    sections = list(template.sections or [])
    if not sections:
        sections = ["indicator_rollup", "activity_summary", "variance_table", "compliance"]

    # C-D3 — carry the drag-drop builder's per-section config (e.g. the chosen
    # indicator_codes) into generation. The wizard section list only records
    # kinds; the ReportSection rows hold the config JSON. First row per kind wins.
    section_configs: dict[str, dict] = {}
    for row in template.section_rows.all():
        if row.kind not in section_configs:
            section_configs[row.kind] = row.config or {}

    data = build_consolidated_data(
        logframe=template.logframe,
        period_label=period_label,
        period_start=period_start,
        period_end=period_end,
        sections=sections,
        section_configs=section_configs,
    )

    report, _ = Report.objects.update_or_create(
        template=template,
        period_label=period_label,
        defaults={
            "period_start": period_start,
            "period_end": period_end,
            "generated_by": user,
            "consolidated_data": data,
            "narrative": narrative,
            "status": ReportStatus.DRAFT,
        },
    )
    # Re-run compliance checks on every regeneration.
    run_compliance_checks(report)
    logger.info(
        "mel.report generated template=%s period=%s id=%s",
        template.slug, period_label, report.pk,
    )
    return report


# ---------------------------------------------------------------------------
# Status transitions
# ---------------------------------------------------------------------------


def report_publish_blockers(report: Report, *, require_narrative: bool = True) -> list[str]:
    """M&E SRS Table 66 (E1) — reasons a report may not be finalized.

    "Reports shall not be finalized when mandatory information is missing." A
    report needs a consolidated-data snapshot (the underlying data) and, for a
    user-initiated finalize, a non-empty narrative. ``require_narrative=False``
    returns only the hard data-integrity blockers enforced by the core
    transition (so a snapshot-less report can never be published), leaving the
    editorial narrative requirement to the user-facing publish action.
    """
    blockers: list[str] = []
    if not report.consolidated_data:
        blockers.append("Generate the report data snapshot before finalizing.")
    if require_narrative and not (report.narrative or "").strip():
        blockers.append("Add a narrative before finalizing the report.")
    if require_narrative:
        # M&E SRS Table 65 (A2) — "Indicators lacking supporting evidence
        # shall not be finalized for reporting." Enforced on the user-facing
        # finalize action only (same tier as the narrative gate) so service-
        # level automation and legacy fixtures keep working.
        from apps.mel.indicators.services import evidence_gaps

        scope = report.template.logframe if report.template_id else None
        gaps = evidence_gaps(scope, report.period_label)
        if gaps:
            codes = sorted({dp.indicator.code for dp in gaps})
            blockers.append(
                "Attach supporting evidence for the verified data points of: "
                + ", ".join(codes)
                + "."
            )
        # M&E SRS Table 65 (A3) / Table 66 — "Every below-target indicator shall
        # have an explanation and proposed corrective action before reporting."
        # A RED (below-AMBER) indicator in the snapshot must carry an
        # IndicatorVariance with both a cause analysis and an adjustment.
        from apps.mel.indicators.models import IndicatorVariance

        rollup = (report.consolidated_data or {}).get("indicator_rollup", {})
        below_target = {
            row.get("code")
            for row in rollup.get("rows", [])
            if row.get("rag") == RagStatus.RED and row.get("code")
        }
        if below_target:
            explained = set(
                IndicatorVariance.objects.filter(
                    indicator__code__in=below_target,
                    period_label=report.period_label,
                )
                .exclude(cause_analysis="")
                .exclude(adjustment="")
                .values_list("indicator__code", flat=True)
            )
            unexplained = sorted(below_target - explained)
            if unexplained:
                blockers.append(
                    "Record a variance explanation and corrective action for the "
                    "below-target indicators: " + ", ".join(unexplained) + "."
                )
    return blockers


@transaction.atomic
def transition_report(report: Report, new_status: str) -> Report:
    allowed = REPORT_STATUS_TRANSITIONS.get(report.status, set())
    if new_status == report.status:
        return report
    if new_status not in allowed:
        raise ValidationError(
            f"Cannot transition report {report.pk} from {report.status} → {new_status}."
        )
    # M&E SRS (E1) — never finalize a report with no data snapshot. The narrative
    # completeness nudge is enforced at the user-facing publish view.
    if new_status in {ReportStatus.APPROVED, ReportStatus.PUBLISHED}:
        blockers = report_publish_blockers(report, require_narrative=False)
        if blockers:
            raise ValidationError("Report is incomplete: " + " ".join(blockers))

    report.status = new_status
    update_fields = ["status"]
    now = timezone.now()
    if new_status == ReportStatus.APPROVED:
        report.approved_at = now
        update_fields.append("approved_at")
    if new_status == ReportStatus.PUBLISHED:
        report.published_at = now
        update_fields.append("published_at")
        if not report.share_token:
            report.share_token = Report.generate_share_token()
            update_fields.append("share_token")
    report.save(update_fields=update_fields)

    if new_status == ReportStatus.PUBLISHED:
        transaction.on_commit(lambda: mel_report_published.send(sender=Report, report=report))
    return report


def publish_report(report: Report) -> Report:
    """Convenience wrapper: approve → publish in a single call if allowed."""
    if report.status == ReportStatus.DRAFT:
        transition_report(report, ReportStatus.IN_REVIEW)
    if report.status == ReportStatus.IN_REVIEW:
        transition_report(report, ReportStatus.APPROVED)
    if report.status == ReportStatus.APPROVED:
        transition_report(report, ReportStatus.PUBLISHED)
    return report


@transaction.atomic
def unpublish_report(report: Report, *, user=None, reason: str = "") -> Report:
    """Move a PUBLISHED report back to APPROVED and log the reason (G14).

    The pre-save guard in :mod:`apps.mel.reports.signals` blocks direct edits
    on PUBLISHED rows. Officers run this to unlock a report for narrative or
    snapshot corrections; the reason lands in :class:`AuditLog` so the trail
    survives the eventual republish.
    """
    from apps.core.audit.models import AuditLog

    if report.status != ReportStatus.PUBLISHED:
        raise ValidationError("Only PUBLISHED reports can be unpublished.")
    cleaned = (reason or "").strip()
    if not cleaned:
        raise ValidationError("Unpublish reason is required for the audit log.")

    transition_report(report, ReportStatus.APPROVED)
    AuditLog.objects.create(
        actor=user if (user and getattr(user, "is_authenticated", False)) else None,
        action="UNPUBLISH",
        target_app="mel_reports",
        target_model="Report",
        object_id=str(report.pk),
        object_repr=str(report)[:200],
        changes={"reason": cleaned, "previous_status": ReportStatus.PUBLISHED},
    )
    logger.info("mel.report unpublished pk=%s by=%s", report.pk, getattr(user, "pk", None))
    return report


# ---------------------------------------------------------------------------
# Render — PDF / XLSX / HTML
# ---------------------------------------------------------------------------


def _save_artifact(report: Report, *, fmt: str, data: bytes, extension: str) -> ReportArtifact:
    checksum = ReportArtifact.checksum_for(data)
    filename = f"{report.template.slug}-{report.period_label}.{extension}"
    artifact, _ = ReportArtifact.objects.update_or_create(
        report=report,
        format=fmt,
        defaults={
            "checksum": checksum,
            "size_bytes": len(data),
        },
    )
    artifact.file.save(filename, ContentFile(data), save=True)
    return artifact


def render_pdf(report: Report) -> ReportArtifact:
    """Render ``report`` to PDF bytes via WeasyPrint and persist as artifact."""
    from apps.core.utils.pdf import render_pdf_from_html

    html = render_html(report)
    data = render_pdf_from_html(html)
    return _save_artifact(report, fmt=ArtifactFormat.PDF, data=data, extension="pdf")


def render_html(report: Report) -> str:
    """Render the print-mode HTML body used for PDF conversion and the
    standalone HTML artefact.

    The dashboard view includes the Tailwind-styled ``_report_body.html``
    directly from the page template instead — this function is for WeasyPrint
    and archive purposes only, so it uses the inline-styled print variant.
    """
    return render_to_string(
        "reports/_report_body_print.html",
        {"report": report, "data": report.consolidated_data},
    )


def render_xlsx(report: Report) -> ReportArtifact:
    """Render indicator_rollup + variance_table to an XLSX workbook."""
    from openpyxl import Workbook
    from io import BytesIO

    wb = Workbook()
    wb.remove(wb.active)

    data = report.consolidated_data or {}
    rollup = data.get("indicator_rollup", {}).get("rows", [])
    variances = data.get("variance_table", {}).get("rows", [])

    ws = wb.create_sheet("Indicators")
    ws.append(["Code", "Name", "Level", "Unit", "Actual", "Target", "Baseline", "Percent", "RAG"])
    for row in rollup:
        ws.append([
            row.get("code"),
            row.get("name"),
            row.get("level"),
            row.get("unit"),
            row.get("actual"),
            row.get("target"),
            row.get("baseline"),
            row.get("percent"),
            row.get("rag"),
        ])

    ws_v = wb.create_sheet("Variance")
    ws_v.append(["Code", "Name", "Target", "Actual", "Variance %", "Cause", "Adjustment"])
    for row in variances:
        ws_v.append([
            row.get("code"),
            row.get("name"),
            row.get("target"),
            row.get("actual"),
            row.get("variance_pct"),
            row.get("cause_analysis"),
            row.get("adjustment"),
        ])

    buf = BytesIO()
    wb.save(buf)
    return _save_artifact(report, fmt=ArtifactFormat.XLSX, data=buf.getvalue(), extension="xlsx")


def render_html_artifact(report: Report) -> ReportArtifact:
    """Persist the HTML body as a distinct artefact (archive-friendly)."""
    html = render_html(report)
    return _save_artifact(
        report, fmt=ArtifactFormat.HTML, data=html.encode("utf-8"), extension="html",
    )


def render_docx(report: Report) -> ReportArtifact:
    """Render ``report`` to a Word (.docx) document (MEI014).

    Mirrors :func:`render_xlsx` / :func:`render_pdf`: a title + period header,
    the executive summary narrative, the indicator rollup table, and the
    consolidated sections (variance, activity, outputs, outcome, impact,
    budget, cross-system, compliance).
    """
    from io import BytesIO

    from docx import Document as DocxDocument
    from docx.shared import Pt

    data = report.consolidated_data or {}
    doc = DocxDocument()

    doc.add_heading(report.template.name, level=0)
    period = doc.add_paragraph()
    period.add_run(
        f"{report.period_label} · "
        f"{report.period_start:%d %b %Y} – {report.period_end:%d %b %Y}"
    ).italic = True

    def _section_heading(text: str) -> None:
        doc.add_heading(text, level=1)

    def _add_table(headers: list[str], rows: list[list]) -> None:
        table = doc.add_table(rows=1, cols=len(headers))
        table.style = "Light Grid Accent 1"
        hdr = table.rows[0].cells
        for i, head in enumerate(headers):
            hdr[i].text = str(head)
            for para in hdr[i].paragraphs:
                for run in para.runs:
                    run.bold = True
        for row in rows:
            cells = table.add_row().cells
            for i, val in enumerate(row):
                cells[i].text = "" if val is None else str(val)

    def _fmt(val, dash="—"):
        return dash if val is None else val

    # Executive summary --------------------------------------------------
    if report.narrative:
        _section_heading("Executive summary")
        for line in report.narrative.splitlines() or [report.narrative]:
            doc.add_paragraph(line)

    # Indicator rollup ---------------------------------------------------
    rollup = data.get("indicator_rollup") or {}
    if rollup.get("rows"):
        _section_heading("Indicator rollup")
        counts = rollup.get("counts", {}) or {}
        doc.add_paragraph(
            f"Green {counts.get('green', 0)} · Amber {counts.get('amber', 0)} · "
            f"Red {counts.get('red', 0)} · Unknown {counts.get('unknown', 0)}"
        )
        _add_table(
            ["Code", "Indicator", "Level", "Actual", "Target", "%", "RAG"],
            [
                [
                    r.get("code"), r.get("name"), r.get("level"),
                    _fmt(r.get("actual")), _fmt(r.get("target")),
                    _fmt(r.get("percent")), str(r.get("rag", "")).upper(),
                ]
                for r in rollup["rows"]
            ],
        )

    # Variance table -----------------------------------------------------
    variance = data.get("variance_table") or {}
    if variance.get("rows"):
        _section_heading("Variance analysis")
        _add_table(
            ["Code", "Name", "Target", "Actual", "Variance %", "Cause", "Adjustment"],
            [
                [
                    v.get("code"), v.get("name"), _fmt(v.get("target")),
                    _fmt(v.get("actual")), _fmt(v.get("variance_pct")),
                    v.get("cause_analysis") or "", v.get("adjustment") or "",
                ]
                for v in variance["rows"]
            ],
        )

    # Activity summary ---------------------------------------------------
    acts = data.get("activity_summary") or {}
    if acts.get("rows"):
        _section_heading("Activity summary")
        _add_table(
            ["Activity", "Status", "Scheduled start", "Scheduled end", "Deviation"],
            [
                [
                    a.get("name"), a.get("status"), a.get("scheduled_start"),
                    a.get("scheduled_end"), "Yes" if a.get("deviation_flag") else "No",
                ]
                for a in acts["rows"]
            ],
        )

    # Output snapshot ----------------------------------------------------
    outputs = data.get("output_snapshot") or {}
    if outputs.get("rows"):
        _section_heading("Output deliverables")
        _add_table(
            ["Deliverable", "Unit", "Target", "Achieved", "%", "Status", "Due"],
            [
                [
                    o.get("title"), o.get("unit"), _fmt(o.get("target_quantity")),
                    _fmt(o.get("achieved_quantity")), _fmt(o.get("percent_achieved")),
                    o.get("status"), _fmt(o.get("due_date")),
                ]
                for o in outputs["rows"]
            ],
        )

    # Outcome assessment -------------------------------------------------
    outcome = data.get("outcome_assessment") or {}
    if outcome.get("rows"):
        _section_heading("Outcome assessment")
        for oa in outcome["rows"]:
            doc.add_heading(oa.get("outcome_row") or "Outcome", level=2)
            meta_bits = [b for b in [oa.get("logframe"), oa.get("period_label"),
                                     (f"Assessed by {oa['assessor']}" if oa.get("assessor") else None)] if b]
            if meta_bits:
                doc.add_paragraph(" · ".join(str(b) for b in meta_bits)).italic = True
            if oa.get("contribution_analysis"):
                doc.add_paragraph().add_run("Contribution analysis").bold = True
                doc.add_paragraph(oa["contribution_analysis"])
            if oa.get("recommendations"):
                doc.add_paragraph().add_run("Recommendations").bold = True
                doc.add_paragraph(oa["recommendations"])
            if oa.get("indicators"):
                _add_table(
                    ["Code", "Indicator", "Actual", "Target", "RAG"],
                    [
                        [
                            ind.get("code"), ind.get("name"), _fmt(ind.get("actual")),
                            _fmt(ind.get("target")), str(ind.get("rag", "")).upper(),
                        ]
                        for ind in oa["indicators"]
                    ],
                )
            if oa.get("metrics"):
                _add_table(
                    ["Metric", "Value"],
                    [[k, v] for k, v in oa["metrics"].items()],
                )

    # Impact evaluation --------------------------------------------------
    impact = data.get("impact_evaluation") or {}
    if impact.get("rows"):
        _section_heading("Impact evaluation")
        for ev in impact["rows"]:
            doc.add_heading(ev.get("title") or "Impact evaluation", level=2)
            meta_bits = [b for b in [ev.get("logframe"), ev.get("evaluator"),
                                     (f"Completed {ev['completed_on']}" if ev.get("completed_on") else None)] if b]
            if meta_bits:
                doc.add_paragraph(" · ".join(str(b) for b in meta_bits)).italic = True
            for label, key in [("Objectives", "objectives"), ("Counterfactual", "counterfactual"),
                               ("Findings", "findings"), ("Recommendations", "recommendations")]:
                if ev.get(key):
                    doc.add_paragraph().add_run(label).bold = True
                    doc.add_paragraph(ev[key])
            d = ev.get("cohens_d")
            if d:
                doc.add_paragraph().add_run("Effect size (Cohen's d)").bold = True
                _add_table(
                    ["d", "Magnitude", "Treatment mean (n)", "Control mean (n)", "Pooled SD"],
                    [[
                        d.get("value"), d.get("magnitude"),
                        f"{d.get('mean_treatment')} (n={d.get('n_treatment')})",
                        f"{d.get('mean_control')} (n={d.get('n_control')})",
                        d.get("pooled_std"),
                    ]],
                )

    # Budget variance ----------------------------------------------------
    bv = data.get("budget_variance") or {}
    if bv.get("installed"):
        _section_heading(f"Budget variance — {bv.get('budget_name', '')}")
        var = bv.get("variance")
        if var:
            doc.add_paragraph(
                f"Pacing {var.get('direction')} · {var.get('gap_pct')} pp"
            )
        if bv.get("categories"):
            _add_table(
                ["Category", "Budgeted", "Expended", "Utilisation", "RAG"],
                [
                    [
                        c.get("label"), _fmt(c.get("budgeted")), _fmt(c.get("expended")),
                        _fmt(c.get("utilisation")), str(c.get("rag", "")).upper(),
                    ]
                    for c in bv["categories"]
                ],
            )

    # RIMS funding -------------------------------------------------------
    funding = data.get("rims_funding_summary") or {}
    if funding.get("installed") and funding.get("rows"):
        _section_heading("RIMS funding")
        _add_table(
            ["Grant ID", "Call", "Status", "Amount", "Disbursed", "Currency"],
            [
                [
                    r.get("award_id"), r.get("title"), r.get("status"),
                    _fmt(r.get("amount")), _fmt(r.get("disbursed")), r.get("currency"),
                ]
                for r in funding["rows"]
            ],
        )

    # REP completions ----------------------------------------------------
    rep = data.get("rep_completions_summary") or {}
    if rep.get("installed") and rep.get("rows"):
        _section_heading("REP completions")
        _add_table(
            ["Programme", "Completions"],
            [[r.get("programme_title"), r.get("completions")] for r in rep["rows"]],
        )

    # Repository outputs -------------------------------------------------
    reposum = data.get("repository_outputs_summary") or {}
    if reposum.get("installed") and reposum.get("rows"):
        _section_heading("Repository outputs")
        _add_table(
            ["Title", "Grant", "Status"],
            [
                [
                    d.get("title"), d.get("grant_reference") or "—",
                    "Closed grant" if d.get("grant_closed") else "Active",
                ]
                for d in reposum["rows"]
            ],
        )

    # Compliance ---------------------------------------------------------
    comp = data.get("compliance") or {}
    if comp.get("rows"):
        _section_heading("Compliance alerts")
        _add_table(
            ["Severity", "Code", "Message"],
            [
                [str(a.get("severity", "")).upper(), a.get("code"), a.get("message")]
                for a in comp["rows"]
            ],
        )

    buf = BytesIO()
    doc.save(buf)
    return _save_artifact(report, fmt=ArtifactFormat.WORD, data=buf.getvalue(), extension="docx")


def render_all(report: Report, *, formats: Iterable[str] | None = None) -> list[ReportArtifact]:
    """Render the requested artefact formats (default: PDF + XLSX + Word + HTML).

    ``formats`` (C-D10) narrows output to the caller's selection — e.g. a
    ScheduledReport's ``formats`` list. Unknown format keys are ignored; an
    empty/None selection renders the full default set. PDF/Word rendering falls
    back gracefully when native deps are missing — the other artefacts still
    produce.
    """
    # Normalise + default. XLSX/HTML render inline; PDF/Word are wrapped because
    # they depend on optional native libraries.
    wanted = {str(f).lower() for f in formats} if formats else None

    def _want(fmt: str) -> bool:
        return wanted is None or fmt in wanted

    artifacts: list[ReportArtifact] = []
    if _want(ArtifactFormat.XLSX):
        artifacts.append(render_xlsx(report))
    if _want(ArtifactFormat.HTML):
        artifacts.append(render_html_artifact(report))
    if _want(ArtifactFormat.WORD):
        try:
            artifacts.append(render_docx(report))
        except Exception as exc:  # pragma: no cover - environment-specific
            logger.warning("mel.report Word render failed for %s: %s", report.pk, exc)
    if _want(ArtifactFormat.PDF):
        try:
            artifacts.append(render_pdf(report))
        except Exception as exc:  # pragma: no cover - environment-specific
            logger.warning("mel.report PDF render failed for %s: %s", report.pk, exc)
    return artifacts


# ---------------------------------------------------------------------------
# Distribution
# ---------------------------------------------------------------------------


def _recipient_users(report: Report) -> Iterable:
    return report.template.default_recipients.all()


def _report_feedback_path(report: Report) -> str:
    """Relative public-submit URL for this report's review channel, or "".

    The channel is seeded by the feedback ``mel_report_published`` receiver
    (slug ``report-<pk>``). Read-only lookup; best-effort (the channel may not
    be committed yet under ATOMIC_REQUESTS, in which case we simply omit it).
    """
    try:
        from django.urls import reverse

        from apps.mel.feedback.models import FeedbackChannel

        channel = FeedbackChannel.objects.filter(
            slug=f"report-{report.pk}", is_active=True,
        ).first()
        if channel:
            return reverse("mel_feedback:public_submit", kwargs={"token": channel.token})
    except Exception:  # pragma: no cover - feedback app optional
        pass
    return ""


def mel_recipient_users():
    """Users eligible as scheduled-report recipients (C-D5).

    Scopes the picker to MEL-relevant roles plus any staff member, instead of
    the raw first-200-by-email slice that hid ``mel_officer`` and most staff.
    """
    from django.contrib.auth import get_user_model
    from django.db.models import Q

    from apps.core.permissions.roles import UserRole

    roles = [
        UserRole.MEL_OFFICER,
        UserRole.ADMIN,
        UserRole.SYSTEM_ADMIN,
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.GRANTS_MANAGER,
    ]
    return (
        get_user_model()
        .objects.filter(is_active=True)
        .filter(Q(role__in=roles) | Q(is_staff=True))
        .order_by("email")
    )


@transaction.atomic
def distribute_report(report: Report, *, extra_emails: Iterable[str] | None = None) -> int:
    """Create ReportDistribution rows and queue email notifications.

    Returns the number of distributions *queued* (not necessarily delivered;
    outbound email is async via the core notifications tasks).
    """
    from apps.core.notifications.emailing import rims_email_context
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification

    queued = 0
    report_path = f"/mel/reports/{report.pk}/"
    message = f"Report '{report.template.name}' for {report.period_label} is now published."
    # C-D7 — surface the report-review feedback channel (seeded on publish) so
    # recipients can pass the link on to donors/partners for structured feedback.
    feedback_path = _report_feedback_path(report)
    if feedback_path:
        message = f"{message} Collect feedback: {feedback_path}"
    for user in _recipient_users(report):
        dist, _ = ReportDistribution.objects.get_or_create(
            report=report,
            recipient_user=user,
            channel=DistributionChannel.EMAIL,
        )
        # Use the core notifications pipeline (writes Notification + enqueues email).
        ctx = rims_email_context(
            subject=f"IILMP report — {report.template.name}",
            headline=report.template.name,
            action_path=report_path,
            action_label="Open report",
        )
        # C-D6/E-3 — a clickable in-app notification: action_url routes to the
        # report detail page (which surfaces exports + the feedback link).
        send_notification(
            user, message, verb=Notification.Verb.MEL_REPORT_PUBLISHED,
            email_context=ctx, action_url=report_path,
        )
        dist.status = DistributionStatus.SENT
        dist.sent_at = timezone.now()
        dist.save(update_fields=["status", "sent_at"])
        queued += 1

    for email in (extra_emails or []):
        ReportDistribution.objects.get_or_create(
            report=report,
            recipient_email=email,
            channel=DistributionChannel.EMAIL,
            defaults={"status": DistributionStatus.PENDING},
        )
        queued += 1

    # G18 — fan out to webhooks (template-specific OR global, active only).
    _fan_out_to_webhooks(report)

    logger.info("mel.report distribute pk=%s queued=%s", report.pk, queued)
    return queued


def _fan_out_to_webhooks(report: Report) -> int:
    """Enumerate active webhooks for ``report`` and enqueue deliveries."""
    from apps.mel.reports.models import ReportDistributionWebhook
    from apps.mel.reports.tasks import deliver_webhook

    webhooks = ReportDistributionWebhook.objects.filter(is_active=True).filter(
        models.Q(template=report.template) | models.Q(template__isnull=True)
    )
    enqueued = 0
    for hook in webhooks:
        # Tests pass eager_mode via CELERY_TASK_ALWAYS_EAGER; production fires
        # the .delay() path. Either way the unique constraint on
        # ReportWebhookDelivery (webhook, report) blocks duplicates.
        deliver_webhook.delay(hook.pk, report.pk)
        enqueued += 1
    return enqueued


# ---------------------------------------------------------------------------
# Compliance checks
# ---------------------------------------------------------------------------


def notify_compliance_alerts(alerts: list[ComplianceAlert]) -> int:
    """M&E SRS Table 66 — notify responsible personnel when alerts are raised.

    Every new alert notifies the M&E officers; CRITICAL alerts additionally
    escalate to management (programme managers / directors) with the
    escalation verb (Exception Flow E2 — "critical compliance issues shall
    receive immediate escalation").
    """
    from django.contrib.auth import get_user_model
    from django.urls import reverse

    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification
    from apps.core.permissions.roles import UserRole

    if not alerts:
        return 0
    User = get_user_model()
    officers = list(
        User.objects.filter(role=UserRole.MEL_OFFICER, is_active=True)
    )
    management = list(
        User.objects.filter(
            role__in=[UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR],
            is_active=True,
        )
    )
    inbox_url = reverse("mel_reports:compliance_alerts")
    sent = 0
    for alert in alerts:
        recipients = {u.pk: (u, Notification.Verb.MEL_COMPLIANCE_ALERT) for u in officers}
        if alert.severity == ComplianceSeverity.CRITICAL:
            for u in management:
                recipients[u.pk] = (u, Notification.Verb.MEL_COMPLIANCE_ESCALATION)
        for user, verb in recipients.values():
            prefix = (
                "CRITICAL compliance breach"
                if alert.severity == ComplianceSeverity.CRITICAL
                else f"Compliance issue ({alert.get_severity_display().lower()})"
            )
            send_notification(
                user,
                f"{prefix}: {alert.message}",
                verb=verb,
                action_url=inbox_url,
            )
            sent += 1
    return sent


def run_compliance_checks(report: Report) -> list[ComplianceAlert]:
    """Scan the consolidated snapshot for known compliance issues.

    Rules:
      * RED indicators → HIGH severity ``indicator.red`` alert.
      * Budget overspend vs elapsed time (M&E SRS Table 66 — Significant
        Budget Variance / budget-overrun alerts) → HIGH, or CRITICAL when the
        gap breaches twice the category RED threshold.

    Newly created alerts notify the M&E officers; CRITICAL ones escalate to
    management (:func:`notify_compliance_alerts`).
    """
    created: list[ComplianceAlert] = []
    rollup = (report.consolidated_data or {}).get("indicator_rollup", {})
    for row in rollup.get("rows", []):
        if row.get("rag") == RagStatus.RED:
            alert, was_new = ComplianceAlert.objects.get_or_create(
                report=report,
                code=f"indicator.red.{row.get('code')}",
                defaults={
                    "severity": ComplianceSeverity.HIGH,
                    "message": (
                        f"Indicator {row.get('code')} ({row.get('name')}) is RED "
                        f"for {report.period_label} — below AMBER threshold."
                    ),
                },
            )
            if was_new:
                created.append(alert)
    # M&E SRS Table 66 — budget-overrun alerting. The snapshot's budget
    # variance section compares spend ratio against elapsed time.
    budget = (report.consolidated_data or {}).get("budget_variance") or {}
    variance = budget.get("variance") or {}
    if budget.get("installed") and variance.get("direction") == "over":
        gap = float(variance.get("gap_pct") or 0)
        threshold = float(budget.get("rag_threshold_pct") or 15)
        severity = (
            ComplianceSeverity.CRITICAL
            if gap >= 2 * threshold
            else ComplianceSeverity.HIGH
        )
        alert, was_new = ComplianceAlert.objects.get_or_create(
            report=report,
            code=f"budget.overrun.{budget.get('budget_id')}",
            defaults={
                "severity": severity,
                "message": (
                    f"Budget “{budget.get('budget_name')}” is overspent by "
                    f"{gap:.1f} percentage points versus elapsed time for "
                    f"{report.period_label} — management review required "
                    f"before closure."
                ),
            },
        )
        if was_new:
            created.append(alert)
        elif not alert.resolved and alert.severity != severity:
            # M&E SRS Table 66 — on regeneration the overrun may have crossed the
            # 2× threshold (HIGH → CRITICAL); ``defaults`` only apply on create,
            # so escalate the existing open alert and re-notify when it becomes
            # CRITICAL so management is looped in.
            escalated = severity == ComplianceSeverity.CRITICAL
            alert.severity = severity
            alert.save(update_fields=["severity"])
            if escalated:
                created.append(alert)
    notify_compliance_alerts(created)
    return created


# ---------------------------------------------------------------------------
# Lifecycle convenience
# ---------------------------------------------------------------------------


def generate_render_and_publish(
    *,
    template: ReportTemplate,
    period_label: str,
    period_start: date,
    period_end: date,
    user=None,
    distribute: bool = True,
    formats: Iterable[str] | None = None,
) -> Report:
    """High-level helper for the Celery beat task.

    ``formats`` (C-D10) restricts the rendered artefacts to a schedule's
    configured selection; None renders the full default set.
    """
    report = generate_report(
        template=template,
        period_label=period_label,
        period_start=period_start,
        period_end=period_end,
        user=user,
    )
    render_all(report, formats=formats)
    publish_report(report)
    if distribute:
        distribute_report(report)
    return report


# ---------------------------------------------------------------------------
# Phase 4.4 — Section rendering for the builder UI
# ---------------------------------------------------------------------------


def render_section(section, *, report=None) -> dict:
    """Compute the data payload for a single :class:`ReportSection`.

    The HTMX endpoint uses this to lazy-load each card. Returns a JSON-safe
    dict so the partial template can format it without re-querying the DB.
    """
    kind = section.kind
    config = section.config or {}
    if kind in ("indicator_rollup", "variance_table", "indicator_table"):
        from apps.mel.indicators.models import Indicator

        codes = config.get("indicator_codes") or []
        rows = []
        qs = Indicator.objects.all()
        if codes:
            qs = qs.filter(code__in=codes)
        for ind in qs.select_related("logframe_row__logframe")[:50]:
            rows.append({
                "code": ind.code,
                "name": ind.name,
                "unit": ind.unit,
            })
        # Normalise to the partial's expected discriminator.
        return {"kind": "indicator_table", "rows": rows}
    if kind == "feedback_summary":
        from apps.mel.feedback.models import FeedbackSubmission

        recent = FeedbackSubmission.objects.select_related("channel").order_by("-created_at")[:20]
        return {
            "kind": kind,
            "rows": [
                {
                    "channel": s.channel.name,
                    "severity": s.severity,
                    "narrative": (s.narrative or "")[:240],
                    "created_at": s.created_at.isoformat(),
                }
                for s in recent
            ],
        }
    if kind == "participant_timeline":
        from apps.mel.tracking.models import TrackingMilestone

        rows = (
            TrackingMilestone.objects
            .select_related("record__participant__user")
            .order_by("-occurred_at")[:25]
        )
        return {
            "kind": kind,
            "rows": [
                {
                    "label": r.display_label,
                    "module": r.source_module,
                    "user": r.record.participant.user.email,
                    "occurred_at": r.occurred_at.isoformat(),
                }
                for r in rows
            ],
        }
    if kind == "narrative":
        return {
            "kind": kind,
            "narrative": (report.narrative if report else config.get("narrative", "")) or "",
        }
    if kind == "compliance":
        if report is None:
            return {"kind": kind, "rows": []}
        return {
            "kind": kind,
            "rows": [
                {"code": a.code, "severity": a.severity, "message": a.message}
                for a in report.compliance_alerts.all()
            ],
        }
    return {"kind": kind, "rows": [], "raw": config}
