"""Signals emitted (and enforced) by the reports submodule.

``mel_report_published`` lets downstream modules (Phase 3 feedback loop) react
when a report transitions to PUBLISHED without coupling to the reports tables.

The pre-save guards (G14) raise ``ValidationError`` when callers attempt to
modify a PUBLISHED report or its artefacts directly. The transition matrix
forbids PUBLISHED → anything except ARCHIVED; the explicit ``unpublish_report``
service in :mod:`apps.mel.reports.services` is the documented way to move a
report back to APPROVED while writing an :class:`AuditLog` row.
"""
from __future__ import annotations

import django.dispatch
from django.core.exceptions import ValidationError
from django.db.models.signals import pre_save
from django.dispatch import receiver

mel_report_published = django.dispatch.Signal()


# Fields a PUBLISHED report can mutate without unpublishing first.
# ``share_token`` rotation/revocation is intentionally allowed so officers can
# disable external access without bouncing the report back to APPROVED.
# ``status`` plus the lifecycle timestamps are implicitly allowed because the
# transition matrix governs the legality of the move out of PUBLISHED, and the
# transition helper stamps ``approved_at`` / ``published_at`` as part of that
# move — see REPORT_STATUS_TRANSITIONS.
_PUBLISHED_REPORT_MUTABLE_FIELDS = frozenset(
    {"share_token", "status", "approved_at", "published_at"}
)


def _diff_fields(old, new, ignore=()):
    diffs = []
    for field in old._meta.concrete_fields:
        name = field.attname
        if name in ignore:
            continue
        if getattr(old, name) != getattr(new, name):
            diffs.append(field.name)
    return diffs


@receiver(pre_save, sender="mel_reports.Report")
def _lock_published_report(sender, instance, **kwargs):
    if not instance.pk:
        return
    try:
        old = sender.objects.get(pk=instance.pk)
    except sender.DoesNotExist:  # pragma: no cover - race; nothing to compare
        return
    from apps.mel.reports.models import ReportStatus

    if old.status != ReportStatus.PUBLISHED:
        return
    # Allow status transitions out of PUBLISHED (matrix handles legality) and
    # share_token mutations. Block edits to narrative, consolidated_data, etc.
    changed = _diff_fields(old, instance, ignore=_PUBLISHED_REPORT_MUTABLE_FIELDS)
    if changed:
        raise ValidationError(
            "Cannot modify published report — use Unpublish first. "
            f"Blocked fields: {', '.join(sorted(changed))}.",
        )


@receiver(pre_save, sender="mel_reports.ReportArtifact")
def _lock_published_artifact(sender, instance, **kwargs):
    if not instance.pk:
        return
    try:
        old = sender.objects.get(pk=instance.pk)
    except sender.DoesNotExist:  # pragma: no cover
        return
    from apps.mel.reports.models import ReportStatus

    report_status = getattr(old.report, "status", None)
    if report_status != ReportStatus.PUBLISHED:
        return
    changed = _diff_fields(old, instance, ignore=("id",))
    if changed:
        raise ValidationError(
            "Cannot modify artefacts on a published report — use Unpublish first. "
            f"Blocked fields: {', '.join(sorted(changed))}.",
        )
