"""SME-Hub showcasing services — Subprocess SP4.

Implements PRD FRSME-ISD001 → ISD020. Side-effects (notifications, audit log,
signal emission) live here, never on the view layer (matches the SP1/SP2/SP3
convention).

Public surface:
  Showcase events:
    - create_showcase_event, update_showcase_event, publish_showcase,
      revert_showcase_to_draft, mark_showcase_live, conclude_showcase
  Challenges:
    - create_challenge, publish_challenge, announce_winners
  Applications:
    - apply_to_event, apply_to_challenge, withdraw_application,
      confirm_participants, reject_application
  Catalogue:
    - publish_to_catalogue, update_catalogue_entry
  Attendance / scoring:
    - record_attendance, submit_pitch_score, compute_pitch_ranking,
      compute_event_pitch_ranking
  Deal rooms:
    - open_deal_room, close_deal_room, expire_deal_rooms,
      post_deal_room_message, upload_deal_room_document, formalise_agreement
"""
from __future__ import annotations

import logging
from collections import defaultdict
from collections.abc import Iterable
from decimal import Decimal

from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q
from django.urls import NoReverseMatch, reverse
from django.utils import timezone

from apps.core.notifications.models import Notification
from apps.core.permissions.roles import UserRole
from apps.smehub._shared.audit import AuditLog, _audit
from apps.smehub._shared.notifications import bulk_notify_smehub, notify_smehub
from apps.smehub.onboarding.models import Business, EntrepreneurProfile
from apps.smehub.showcasing import signals as show_signals
from apps.smehub.showcasing.models import (
    ChallengeRubricSection,
    ChallengeScoringRubric,
    DealAgreement,
    DealRoom,
    DealRoomDocument,
    DealRoomMessage,
    EventAttendance,
    InnovationCatalogueEntry,
    InnovationCatalogueVersion,
    InnovationChallenge,
    PitchScore,
    ShowcaseApplication,
    ShowcaseEvent,
    ShowcaseEventAccess,
)

Verb = Notification.Verb
logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------

class ShowcasingStateError(Exception):
    """Raised on illegal SP4 transitions / preconditions."""


# ---------------------------------------------------------------------------
# URL helper (mirror SP3)
# ---------------------------------------------------------------------------

def _safe_reverse(name: str, **kwargs) -> str:
    try:
        return reverse(name, kwargs=kwargs) if kwargs else reverse(name)
    except NoReverseMatch:
        return ""


def _counterparty_owner(counterparty) -> object | None:
    """Return the user we should notify on the counterparty side."""
    return (
        getattr(counterparty, "managed_by", None)
        or getattr(counterparty, "user", None)
        or getattr(counterparty, "contact_user", None)
    )


def _stakeholder_users():
    """Investors / partners / buyers / service providers — used for catalogue
    and challenge broadcast notifications.
    """
    from django.contrib.auth import get_user_model

    User = get_user_model()
    return User.objects.filter(
        role__in=[
            UserRole.INVESTOR,
            UserRole.FUNDER,
            UserRole.PARTNER,
            UserRole.BUYER,
            UserRole.SERVICE_PROVIDER,
        ],
        is_active=True,
    )


def _eligible_entrepreneur_users(target_sectors: list[str] | None):
    """FRSME-ISD003 — entrepreneurs with a verified business in the target
    sectors. If no sectors are configured, the call notifies all verified
    entrepreneurs.
    """
    qs = (
        Business.objects.filter(verification_status=Business.Status.VERIFIED)
        .select_related("entrepreneur__user")
    )
    if target_sectors:
        qs = qs.filter(sector__in=list(target_sectors))
    user_ids = qs.values_list("entrepreneur__user_id", flat=True).distinct()
    from django.contrib.auth import get_user_model

    User = get_user_model()
    return list(User.objects.filter(pk__in=user_ids, is_active=True))


# ---------------------------------------------------------------------------
# Showcase events  (FRSME-ISD001, 003, 011)
# ---------------------------------------------------------------------------

@transaction.atomic
def create_showcase_event(
    *,
    organiser,
    name: str,
    starts_at,
    ends_at,
    application_deadline=None,
    format: str = ShowcaseEvent.Format.VIRTUAL,
    summary: str = "",
    description: str = "",
    target_sectors: Iterable[str] | None = None,
    eligibility: dict | None = None,
    venue: str = "",
    video_link: str = "",
    cover_image=None,
    panel_users: Iterable | None = None,
) -> ShowcaseEvent:
    if ends_at < starts_at:
        raise ValidationError("Event end time must be on or after the start time.")
    event = ShowcaseEvent.objects.create(
        name=name,
        summary=summary,
        description=description,
        format=format,
        starts_at=starts_at,
        ends_at=ends_at,
        application_deadline=application_deadline,
        target_sectors=list(target_sectors or []),
        eligibility=eligibility or {},
        venue=venue,
        video_link=video_link,
        cover_image=cover_image,
        organiser=organiser,
    )
    if panel_users:
        event.evaluation_panel.set(list(panel_users))
    _audit(organiser, AuditLog.Action.CREATE, event)
    show_signals.smehub_showcase_created.send(sender=ShowcaseEvent, event=event, by_user=organiser)
    return event


@transaction.atomic
def update_showcase_event(event: ShowcaseEvent, *, by_user, panel_users=None, **fields) -> ShowcaseEvent:
    for key, value in fields.items():
        setattr(event, key, value)
    event.save()
    if panel_users is not None:
        event.evaluation_panel.set(list(panel_users))
    _audit(by_user, AuditLog.Action.UPDATE, event, metadata={"fields": list(fields.keys())})
    return event


@transaction.atomic
def publish_showcase(event: ShowcaseEvent, *, by_user) -> ShowcaseEvent:
    """FRSME-ISD001 — admin publishes the event so applicants can apply."""
    if event.status != ShowcaseEvent.Status.DRAFT:
        raise ShowcasingStateError("Only draft events can be published.")
    event.publish()
    event.save()
    _audit(by_user, AuditLog.Action.UPDATE, event, metadata={"status": "published"})
    show_signals.smehub_showcase_published.send(
        sender=ShowcaseEvent, event=event, by_user=by_user,
    )
    # Stakeholders (investors / partners / buyers / providers) AND eligible
    # entrepreneurs whose business sector matches the showcase target — FRSME-ISD003.
    audience = list(_stakeholder_users()) + _eligible_entrepreneur_users(event.target_sectors)
    bulk_notify_smehub(
        audience,
        f"New showcase event published: “{event.name}”.",
        verb=Verb.SMEHUB_SHOWCASE_PUBLISHED,
        action_url=_safe_reverse("smehub_showcasing:event_detail", pk=event.pk),
    )
    return event


@transaction.atomic
def revert_showcase_to_draft(event: ShowcaseEvent, *, by_user) -> ShowcaseEvent:
    if event.status != ShowcaseEvent.Status.PUBLISHED:
        raise ShowcasingStateError("Only published events can be reverted to draft.")
    event.revert_to_draft()
    event.save()
    _audit(by_user, AuditLog.Action.UPDATE, event, metadata={"status": "draft"})
    return event


@transaction.atomic
def mark_showcase_live(event: ShowcaseEvent, *, by_user) -> ShowcaseEvent:
    if event.status != ShowcaseEvent.Status.PUBLISHED:
        raise ShowcasingStateError("Only published events can go live.")
    event.go_live()
    event.save()
    _audit(by_user, AuditLog.Action.UPDATE, event, metadata={"status": "live"})
    # FRSME-ISD011 — generate + distribute the conferencing link for virtual /
    # hybrid events as they go live.
    if event.is_virtual_or_hybrid:
        generate_event_conference(event, by_user=by_user)
    show_signals.smehub_showcase_live.send(
        sender=ShowcaseEvent, event=event, by_user=by_user,
    )
    return event


def _bbb_configured() -> bool:
    from django.conf import settings

    return bool(getattr(settings, "BBB_URL", "") and getattr(settings, "BBB_SHARED_SECRET", ""))


def _conference_duration_minutes(event: ShowcaseEvent) -> int:
    duration = 240
    if event.ends_at and event.starts_at:
        duration = max(30, int((event.ends_at - event.starts_at).total_seconds() // 60))
    return duration


def _ensure_bbb_meeting(event: ShowcaseEvent) -> None:
    """Idempotently (re)create the event's BBB meeting so ``join`` will succeed.

    BBB garbage-collects an *empty* meeting a few minutes after it is created,
    so a join that happens any meaningful time after go-live hits
    ``invalidMeetingIdentifier`` ("did not match any existing meetings"). BBB's
    ``create`` is idempotent — calling it again with the same ``meetingID``
    returns the existing meeting (``duplicateWarning``) or recreates an expired
    one — so we call it right before handing out a join URL rather than relying
    on the single go-live create. Never raises: a BBB hiccup must not break the
    join view (the meeting may still exist from go-live, and the caller falls
    back to ``video_link`` if not).
    """
    if not (event.conference_meeting_id and _bbb_configured()):
        return
    try:
        from apps.core.integrations import bbb_client

        result = bbb_client.create_meeting(
            meeting_id=event.conference_meeting_id,
            name=event.name,
            attendee_pw=event.conference_attendee_pw,
            moderator_pw=event.conference_moderator_pw,
            record=True,
            duration_minutes=_conference_duration_minutes(event),
        )
        if result.get("returncode") != "SUCCESS":
            logger.warning(
                "smehub-showcasing: BBB create returned %s for event %s",
                result.get("returncode"), event.pk,
            )
    except Exception:  # pragma: no cover - network/config failure
        logger.exception("smehub-showcasing: BBB ensure-meeting failed for event %s", event.pk)


def event_conference_join_url(event: ShowcaseEvent, *, user, role: str) -> str:
    """FRSME-ISD011 — resolve the actual conferencing URL for a permitted user.

    Prefers a per-user BigBlueButton join URL when BBB is configured and a
    meeting has been minted; otherwise falls back to the event's manual
    ``video_link``. Returns "" when nothing is available yet.
    """
    if event.conference_meeting_id and _bbb_configured():
        try:
            from apps.core.integrations import bbb_client

            # Ensure the meeting still exists on the BBB server — it may have
            # been garbage-collected since go-live — before building the join
            # URL, otherwise the redirect lands on BBB's invalidMeetingIdentifier
            # error page.
            _ensure_bbb_meeting(event)

            password = (
                event.conference_moderator_pw
                if role in (ShowcaseEventAccess.Role.ORGANISER, ShowcaseEventAccess.Role.EVALUATOR)
                else event.conference_attendee_pw
            )
            return bbb_client.join_url(
                meeting_id=event.conference_meeting_id,
                full_name=(user.get_full_name() or user.email)[:120],
                password=password,
                user_id=str(user.pk),
            )
        except Exception:  # pragma: no cover - network/config failure
            logger.exception("smehub-showcasing: BBB join_url failed for event %s", event.pk)
    return event.video_link or ""


@transaction.atomic
def generate_event_conference(event: ShowcaseEvent, *, by_user, distribute: bool = True) -> ShowcaseEvent:
    """FRSME-ISD011 — mint a conferencing session for a virtual/hybrid event and
    distribute the (gated, in-app) join link to confirmed participants and
    evaluators. Idempotent — an event that already has a meeting is only
    (re-)distributed, never re-minted.
    """
    import uuid

    if not event.is_virtual_or_hybrid:
        return event
    if not event.conference_meeting_id:
        event.conference_meeting_id = f"smehub-showcase-{event.pk}-{uuid.uuid4().hex[:12]}"
        event.conference_attendee_pw = uuid.uuid4().hex[:16]
        event.conference_moderator_pw = uuid.uuid4().hex[:16]
        # Best-effort create on go-live; the join path also calls
        # _ensure_bbb_meeting so an expired/garbage-collected meeting is
        # recreated on demand (BBB create is idempotent).
        _ensure_bbb_meeting(event)
        event.save(update_fields=[
            "conference_meeting_id",
            "conference_attendee_pw",
            "conference_moderator_pw",
            "updated_at",
        ])
    if distribute:
        _distribute_event_conference(event)
    return event


def _distribute_event_conference(event: ShowcaseEvent) -> int:
    """Notify every confirmed participant + evaluator with the join link."""
    join_path = _safe_reverse("smehub_showcasing:event_join", pk=event.pk)
    ct = ContentType.objects.get_for_model(ShowcaseEvent)
    recipients = {}
    for app in ShowcaseApplication.objects.filter(
        target_content_type=ct,
        target_object_id=str(event.pk),
        status=ShowcaseApplication.Status.CONFIRMED,
    ).select_related("entrepreneur__user"):
        recipients[app.entrepreneur.user_id] = app.entrepreneur.user
    for judge in event.evaluation_panel.all():
        recipients[judge.pk] = judge
    for user in recipients.values():
        notify_smehub(
            user,
            f"The live session for “{event.name}” is ready. Join from your dashboard when it starts.",
            verb=Verb.SMEHUB_SHOWCASE_PARTICIPANT_CONFIRMED,
            action_url=join_path,
            target=event,
        )
    return len(recipients)


def event_access_role(event: ShowcaseEvent, user) -> str | None:
    """FRSME-ISD011 — the conferencing role for ``user`` on ``event``, or None
    if they may not join (used to gate the join view + pick the BBB password).
    """
    if user is None or not getattr(user, "is_authenticated", False):
        return None
    if event.organiser_id == user.pk or getattr(user, "role", None) in (
        UserRole.SME_ADMIN,
        UserRole.SYSTEM_ADMIN,
    ):
        return ShowcaseEventAccess.Role.ORGANISER
    if event.evaluation_panel.filter(pk=user.pk).exists():
        return ShowcaseEventAccess.Role.EVALUATOR
    ct = ContentType.objects.get_for_model(ShowcaseEvent)
    is_participant = ShowcaseApplication.objects.filter(
        target_content_type=ct,
        target_object_id=str(event.pk),
        entrepreneur__user=user,
        status=ShowcaseApplication.Status.CONFIRMED,
    ).exists()
    return ShowcaseEventAccess.Role.PARTICIPANT if is_participant else None


@transaction.atomic
def record_event_access(event: ShowcaseEvent, *, user, role: str) -> ShowcaseEventAccess:
    """FRSME-ISD011 — log a join and return the access row."""
    return ShowcaseEventAccess.objects.create(event=event, user=user, role=role)


@transaction.atomic
def conclude_showcase(event: ShowcaseEvent, *, by_user) -> ShowcaseEvent:
    if event.status not in (ShowcaseEvent.Status.PUBLISHED, ShowcaseEvent.Status.LIVE):
        raise ShowcasingStateError("Only published or live events can be concluded.")
    event.conclude()
    event.save()
    _audit(by_user, AuditLog.Action.UPDATE, event, metadata={"status": "concluded"})
    show_signals.smehub_showcase_concluded.send(
        sender=ShowcaseEvent, event=event, by_user=by_user,
    )
    return event


# ---------------------------------------------------------------------------
# Innovation challenges  (FRSME-ISD002, 008)
# ---------------------------------------------------------------------------

@transaction.atomic
def create_challenge(
    *,
    organiser,
    title: str,
    application_deadline,
    summary: str = "",
    description: str = "",
    theme: str = "",
    target_sectors: Iterable[str] | None = None,
    eligibility: dict | None = None,
    prize_summary: str = "",
    judging_deadline=None,
    cover_image=None,
    rubric_sections: list[dict] | None = None,
) -> InnovationChallenge:
    challenge = InnovationChallenge.objects.create(
        title=title,
        summary=summary,
        description=description,
        theme=theme,
        target_sectors=list(target_sectors or []),
        eligibility=eligibility or {},
        prize_summary=prize_summary,
        application_deadline=application_deadline,
        judging_deadline=judging_deadline,
        organiser=organiser,
        cover_image=cover_image,
    )
    rubric = ChallengeScoringRubric.objects.create(challenge=challenge)
    for idx, section in enumerate(rubric_sections or []):
        ChallengeRubricSection.objects.create(
            rubric=rubric,
            title=section["title"],
            instructions=section.get("instructions", ""),
            weight=Decimal(str(section.get("weight", "0.25"))),
            max_marks=int(section.get("max_marks", 10)),
            order=section.get("order", idx),
        )
    _audit(organiser, AuditLog.Action.CREATE, challenge)
    return challenge


@transaction.atomic
def publish_challenge(challenge: InnovationChallenge, *, by_user) -> InnovationChallenge:
    if challenge.status != InnovationChallenge.Status.DRAFT:
        raise ShowcasingStateError("Only draft challenges can be published.")
    rubric = getattr(challenge, "rubric", None)
    if rubric is None or not rubric.is_complete:
        raise ShowcasingStateError("Challenge rubric must be complete (sections sum to 1.0) before publishing.")
    challenge.status = InnovationChallenge.Status.OPEN
    challenge.published_at = timezone.now()
    challenge.save(update_fields=["status", "published_at", "updated_at"])
    _audit(by_user, AuditLog.Action.UPDATE, challenge, metadata={"status": "open"})
    show_signals.smehub_challenge_published.send(
        sender=InnovationChallenge, challenge=challenge, by_user=by_user,
    )
    audience = list(_stakeholder_users()) + _eligible_entrepreneur_users(challenge.target_sectors)
    bulk_notify_smehub(
        audience,
        f"New innovation challenge open: “{challenge.title}”.",
        verb=Verb.SMEHUB_CHALLENGE_PUBLISHED,
        action_url=_safe_reverse("smehub_showcasing:challenge_detail", pk=challenge.pk),
    )
    return challenge


@transaction.atomic
def transition_to_judging(
    challenge: InnovationChallenge, *, by_user,
) -> InnovationChallenge:
    """Slice B.3c — move an OPEN challenge into JUDGING.

    The JUDGING state existed as an enum value but was never set anywhere in
    the codebase, so the SRS-mandated lifecycle OPEN → JUDGING → ANNOUNCED
    was unreachable. Admins / programme officers call this once the
    application deadline has passed to lock new submissions.
    """
    if challenge.status != InnovationChallenge.Status.OPEN:
        raise ShowcasingStateError("Only open challenges can move into judging.")
    challenge.status = InnovationChallenge.Status.JUDGING
    challenge.save(update_fields=["status", "updated_at"])
    _audit(by_user, AuditLog.Action.UPDATE, challenge, metadata={"status": "judging"})
    return challenge


@transaction.atomic
def announce_winners(
    challenge: InnovationChallenge,
    *,
    winners: Iterable[ShowcaseApplication],
    by_user,
) -> InnovationChallenge:
    if challenge.status not in (InnovationChallenge.Status.OPEN, InnovationChallenge.Status.JUDGING):
        raise ShowcasingStateError("Only open or judging challenges can announce winners.")
    challenge.status = InnovationChallenge.Status.ANNOUNCED
    challenge.announced_at = timezone.now()
    challenge.save(update_fields=["status", "announced_at", "updated_at"])
    winners_list = list(winners)
    # FRSME-ISD A5 — flag winners on their ShowcaseApplication so the
    # catalogue / profile can surface a "Challenge Winner" badge. Audit
    # found this was missing entirely; ``announce_winners`` only emitted a
    # notification.
    now = timezone.now()
    for app in winners_list:
        app.is_challenge_winner = True
        app.challenge_winner_at = now
        app.challenge_winner_notes = f"Winner of \"{challenge.title}\""
        app.save(update_fields=[
            "is_challenge_winner",
            "challenge_winner_at",
            "challenge_winner_notes",
            "updated_at",
        ])
        _audit(by_user, AuditLog.Action.UPDATE, app, metadata={"event": "challenge_winner_announced"})
    show_signals.smehub_challenge_announced.send(
        sender=InnovationChallenge, challenge=challenge, winners=winners_list,
    )
    for app in winners_list:
        notify_smehub(
            app.entrepreneur.user,
            f"Congratulations — your innovation “{app.innovation_title}” was selected in “{challenge.title}”.",
            verb=Verb.SMEHUB_SHOWCASE_PARTICIPANT_CONFIRMED,
            target=app,
        )
    # FRSME-ISD007 — close the loop for everyone still awaiting a decision:
    # auto-reject + notify each application left in Submitted / Under review so
    # non-selected applicants learn their outcome when winners are announced.
    # Winners are excluded explicitly so a selected entry is never rejected or
    # double-notified, regardless of its current status.
    reject_remaining_applications(
        target=challenge,
        by_user=by_user,
        note=f"Not selected in “{challenge.title}”.",
        exclude_ids=[app.pk for app in winners_list],
    )
    return challenge


# ---------------------------------------------------------------------------
# Applications  (FRSME-ISD004–006)
# ---------------------------------------------------------------------------

def _check_business(business: Business, entrepreneur: EntrepreneurProfile) -> None:
    if business.entrepreneur_id != entrepreneur.pk:
        raise ShowcasingStateError("Business does not belong to this entrepreneur.")
    if business.verification_status != Business.Status.VERIFIED:
        raise ShowcasingStateError("Business must be verified before applying.")


def _existing_active_application(entrepreneur: EntrepreneurProfile, target) -> ShowcaseApplication | None:
    ct = ContentType.objects.get_for_model(target.__class__)
    return ShowcaseApplication.objects.filter(
        entrepreneur=entrepreneur,
        target_content_type=ct,
        target_object_id=str(target.pk),
        status__in=[
            ShowcaseApplication.Status.SUBMITTED,
            ShowcaseApplication.Status.UNDER_REVIEW,
            ShowcaseApplication.Status.CONFIRMED,
        ],
    ).first()


def get_showcase_draft(*, entrepreneur: EntrepreneurProfile, target) -> ShowcaseApplication | None:
    """FRSME-ISD004 — the entrepreneur's saved draft for this target, if any."""
    ct = ContentType.objects.get_for_model(target.__class__)
    return _existing_draft(entrepreneur, ct, target)


def _existing_draft(entrepreneur: EntrepreneurProfile, ct, target) -> ShowcaseApplication | None:
    return ShowcaseApplication.objects.filter(
        entrepreneur=entrepreneur,
        target_content_type=ct,
        target_object_id=str(target.pk),
        status=ShowcaseApplication.Status.DRAFT,
    ).first()


_APPLICATION_CONTENT_FIELDS = (
    "innovation_title",
    "innovation_description",
    "problem_statement",
    "solution_summary",
    "impact_potential",
    "pitch_video_link",
    "supporting_link",
)


def _write_showcase_application(
    *,
    target,
    ct,
    entrepreneur: EntrepreneurProfile,
    business: Business,
    fields: dict,
    pitch_deck,
    as_draft: bool,
) -> tuple[ShowcaseApplication, bool]:
    """FRSME-ISD004 — create/update a draft, or submit (reusing any draft).

    Returns ``(application, submitted)`` where ``submitted`` is True only when
    this call transitioned the application into SUBMITTED (so callers know
    whether to notify the organiser).
    """
    draft = _existing_draft(entrepreneur, ct, target)
    if as_draft:
        if draft is None:
            draft = ShowcaseApplication(
                target_content_type=ct,
                target_object_id=str(target.pk),
                entrepreneur=entrepreneur,
                business=business,
                status=ShowcaseApplication.Status.DRAFT,
            )
        for key in _APPLICATION_CONTENT_FIELDS:
            if key in fields:
                setattr(draft, key, fields[key])
        if pitch_deck is not None:
            draft.pitch_deck = pitch_deck
        created = draft.pk is None
        draft.save()
        _audit(
            entrepreneur.user,
            AuditLog.Action.CREATE if created else AuditLog.Action.UPDATE,
            draft,
            metadata={"draft": True},
        )
        return draft, False

    # Submitting — never duplicate an already-active application.
    existing = _existing_active_application(entrepreneur, target)
    if existing:
        return existing, False
    if draft is not None:
        for key in _APPLICATION_CONTENT_FIELDS:
            if key in fields:
                setattr(draft, key, fields[key])
        if pitch_deck is not None:
            draft.pitch_deck = pitch_deck
        draft.submit()  # DRAFT → SUBMITTED
        draft.save()
        application = draft
    else:
        application = ShowcaseApplication.objects.create(
            target_content_type=ct,
            target_object_id=str(target.pk),
            entrepreneur=entrepreneur,
            business=business,
            pitch_deck=pitch_deck,
            **{k: fields.get(k, "") for k in _APPLICATION_CONTENT_FIELDS},
        )
    return application, True


@transaction.atomic
def apply_to_event(
    *,
    event: ShowcaseEvent,
    entrepreneur: EntrepreneurProfile,
    business: Business,
    innovation_title: str,
    innovation_description: str = "",
    problem_statement: str = "",
    solution_summary: str = "",
    impact_potential: str = "",
    pitch_deck=None,
    pitch_video_link: str = "",
    supporting_link: str = "",
    as_draft: bool = False,
) -> ShowcaseApplication:
    if event.status not in (ShowcaseEvent.Status.PUBLISHED, ShowcaseEvent.Status.LIVE):
        raise ShowcasingStateError("Applications are only accepted for published or live events.")
    if event.application_deadline and timezone.now() > event.application_deadline:
        raise ShowcasingStateError("Application deadline has passed.")
    _check_business(business, entrepreneur)
    ct = ContentType.objects.get_for_model(ShowcaseEvent)
    application, submitted = _write_showcase_application(
        target=event,
        ct=ct,
        entrepreneur=entrepreneur,
        business=business,
        fields={
            "innovation_title": innovation_title,
            "innovation_description": innovation_description,
            "problem_statement": problem_statement,
            "solution_summary": solution_summary,
            "impact_potential": impact_potential,
            "pitch_video_link": pitch_video_link,
            "supporting_link": supporting_link,
        },
        pitch_deck=pitch_deck,
        as_draft=as_draft,
    )
    if not submitted:
        return application
    _audit(entrepreneur.user, AuditLog.Action.CREATE, application, metadata={"event": event.name})
    show_signals.smehub_showcase_application_submitted.send(
        sender=ShowcaseApplication, application=application,
    )
    if event.organiser is not None:
        notify_smehub(
            event.organiser,
            f"New application from {business.name} for “{event.name}”.",
            verb=Verb.SMEHUB_APPLICATION_RECEIVED,
            action_url=_safe_reverse("smehub_showcasing:event_applications", pk=event.pk),
            target=application,
        )
    return application


@transaction.atomic
def apply_to_challenge(
    *,
    challenge: InnovationChallenge,
    entrepreneur: EntrepreneurProfile,
    business: Business,
    innovation_title: str,
    innovation_description: str = "",
    problem_statement: str = "",
    solution_summary: str = "",
    impact_potential: str = "",
    pitch_deck=None,
    pitch_video_link: str = "",
    supporting_link: str = "",
    as_draft: bool = False,
) -> ShowcaseApplication:
    if challenge.status not in (InnovationChallenge.Status.OPEN, InnovationChallenge.Status.JUDGING):
        raise ShowcasingStateError("Applications are only accepted for open or judging challenges.")
    if challenge.application_deadline and timezone.now() > challenge.application_deadline:
        raise ShowcasingStateError("Application deadline has passed.")
    _check_business(business, entrepreneur)
    ct = ContentType.objects.get_for_model(InnovationChallenge)
    application, submitted = _write_showcase_application(
        target=challenge,
        ct=ct,
        entrepreneur=entrepreneur,
        business=business,
        fields={
            "innovation_title": innovation_title,
            "innovation_description": innovation_description,
            "problem_statement": problem_statement,
            "solution_summary": solution_summary,
            "impact_potential": impact_potential,
            "pitch_video_link": pitch_video_link,
            "supporting_link": supporting_link,
        },
        pitch_deck=pitch_deck,
        as_draft=as_draft,
    )
    if not submitted:
        return application
    _audit(entrepreneur.user, AuditLog.Action.CREATE, application, metadata={"challenge": challenge.title})
    show_signals.smehub_showcase_application_submitted.send(
        sender=ShowcaseApplication, application=application,
    )
    if challenge.organiser is not None:
        notify_smehub(
            challenge.organiser,
            f"New entry from {business.name} for “{challenge.title}”.",
            verb=Verb.SMEHUB_APPLICATION_RECEIVED,
            action_url=_safe_reverse("smehub_showcasing:challenge_detail", pk=challenge.pk),
            target=application,
        )
    return application


@transaction.atomic
def withdraw_application(application: ShowcaseApplication, *, by_user) -> ShowcaseApplication:
    if application.entrepreneur.user_id != getattr(by_user, "pk", None) and not getattr(by_user, "is_staff", False):
        raise ShowcasingStateError("Only the applicant or staff may withdraw an application.")
    application.withdraw()
    application.save()
    _audit(by_user, AuditLog.Action.UPDATE, application, metadata={"status": "withdrawn"})
    return application


@transaction.atomic
def confirm_participants(
    *,
    target,
    application_ids: Iterable[int],
    by_user,
    note: str = "",
) -> list[ShowcaseApplication]:
    """FRSME-ISD006 — admin confirms a list of applications for the event/challenge."""
    ct = ContentType.objects.get_for_model(target.__class__)
    apps = ShowcaseApplication.objects.filter(
        pk__in=list(application_ids),
        target_content_type=ct,
        target_object_id=str(target.pk),
    ).select_related("entrepreneur__user", "business")
    confirmed: list[ShowcaseApplication] = []
    for app in apps:
        if app.status not in (
            ShowcaseApplication.Status.SUBMITTED,
            ShowcaseApplication.Status.UNDER_REVIEW,
        ):
            continue
        score = compute_pitch_ranking(app)
        app.confirm(by_user=by_user, note=note, weighted_score=score or None)
        app.save()
        _audit(by_user, AuditLog.Action.UPDATE, app, metadata={"status": "confirmed"})
        show_signals.smehub_showcase_participant_confirmed.send(
            sender=ShowcaseApplication, application=app, by_user=by_user,
        )
        notify_smehub(
            app.entrepreneur.user,
            f"You're confirmed as a participant for “{target}”.",
            verb=Verb.SMEHUB_SHOWCASE_PARTICIPANT_CONFIRMED,
            target=app,
        )
        confirmed.append(app)
    return confirmed


@transaction.atomic
def reject_remaining_applications(
    *,
    target,
    by_user,
    note: str = "",
    exclude_ids: Iterable[int] | None = None,
) -> int:
    """FRSME-ISD007 — flip every still-open application for ``target`` to
    REJECTED. Used after participant confirmation / winner announcement so
    non-selected applicants are notified of their outcome in one pass.

    ``exclude_ids`` — application pks to leave untouched (e.g. announced
    winners), guarding against rejecting / double-notifying a selected entry.
    """
    ct = ContentType.objects.get_for_model(target.__class__)
    pending = ShowcaseApplication.objects.filter(
        target_content_type=ct,
        target_object_id=str(target.pk),
        status__in=[
            ShowcaseApplication.Status.SUBMITTED,
            ShowcaseApplication.Status.UNDER_REVIEW,
        ],
    )
    if exclude_ids:
        pending = pending.exclude(pk__in=list(exclude_ids))
    rejected = 0
    for app in pending:
        reject_application(app, by_user=by_user, note=note)
        rejected += 1
    return rejected


@transaction.atomic
def reject_application(
    application: ShowcaseApplication,
    *,
    by_user,
    note: str = "",
) -> ShowcaseApplication:
    application.reject(by_user=by_user, note=note)
    application.save()
    _audit(by_user, AuditLog.Action.UPDATE, application, metadata={"status": "rejected"})
    show_signals.smehub_showcase_application_rejected.send(
        sender=ShowcaseApplication, application=application, by_user=by_user,
    )
    notify_smehub(
        application.entrepreneur.user,
        f"Your application “{application.innovation_title}” was not selected.",
        verb=Verb.SMEHUB_COHORT_REJECTED,
        target=application,
    )
    return application


# ---------------------------------------------------------------------------
# Catalogue  (FRSME-ISD009–010)
# ---------------------------------------------------------------------------

def _snapshot_catalogue_entry(
    entry: InnovationCatalogueEntry,
    *,
    by_user,
    notes: str = "",
) -> InnovationCatalogueVersion:
    return InnovationCatalogueVersion.objects.create(
        entry=entry,
        version=entry.version,
        title=entry.title,
        summary=entry.summary,
        description=entry.description,
        sectors=entry.sectors,
        impact_metrics=entry.impact_metrics,
        cover_image=entry.cover_image or None,
        pitch_deck=entry.pitch_deck or None,
        video_link=entry.video_link,
        website=entry.website,
        notes=notes,
        captured_by=by_user,
    )


@transaction.atomic
def publish_to_catalogue(
    application: ShowcaseApplication,
    *,
    by_user,
    title: str | None = None,
    summary: str = "",
    description: str = "",
    sectors: Iterable[str] | None = None,
    impact_metrics: dict | None = None,
    cover_image=None,
    pitch_deck=None,
    video_link: str = "",
    website: str = "",
    visibility: str = InnovationCatalogueEntry.Visibility.STAKEHOLDER,
) -> InnovationCatalogueEntry:
    if application.status != ShowcaseApplication.Status.CONFIRMED:
        raise ShowcasingStateError("Only confirmed applications can be published to the catalogue.")
    entry, created = InnovationCatalogueEntry.objects.get_or_create(
        application=application,
        defaults={
            "entrepreneur": application.entrepreneur,
            "business": application.business,
            "title": title or application.innovation_title,
            "summary": summary or application.solution_summary,
            "description": description or application.innovation_description,
            "sectors": list(sectors or [application.business.sector] if application.business.sector else []),
            "impact_metrics": impact_metrics or {},
            "cover_image": cover_image,
            "pitch_deck": pitch_deck or application.pitch_deck or None,
            "video_link": video_link or application.pitch_video_link,
            "website": website,
            "visibility": visibility,
            "last_updated_by": by_user,
        },
    )
    if not created:
        return update_catalogue_entry(
            entry,
            by_user=by_user,
            title=title,
            summary=summary,
            description=description,
            sectors=sectors,
            impact_metrics=impact_metrics,
            cover_image=cover_image,
            pitch_deck=pitch_deck,
            video_link=video_link,
            website=website,
            visibility=visibility,
        )
    _snapshot_catalogue_entry(entry, by_user=by_user, notes="Initial publication.")
    _audit(by_user, AuditLog.Action.CREATE, entry)
    show_signals.smehub_catalogue_published.send(
        sender=InnovationCatalogueEntry, entry=entry, by_user=by_user,
    )
    return entry


@transaction.atomic
def update_catalogue_entry(
    entry: InnovationCatalogueEntry,
    *,
    by_user,
    notes: str = "",
    **fields,
) -> InnovationCatalogueEntry:
    """Versioned update — applies changes, then snapshots the new state.

    The prior version is preserved by the InnovationCatalogueVersion row that
    publish_to_catalogue / a previous update_catalogue_entry already created.
    """
    for key, value in fields.items():
        if value is None and key in {"title", "summary", "description"}:
            continue  # ignore None overrides
        if key == "sectors" and value is not None:
            entry.sectors = list(value)
            continue
        if value is not None:
            setattr(entry, key, value)
    entry.version += 1
    entry.last_updated_by = by_user
    entry.save()
    _snapshot_catalogue_entry(entry, by_user=by_user, notes=notes or "Updated revision.")
    _audit(by_user, AuditLog.Action.UPDATE, entry, metadata={"version": entry.version})
    show_signals.smehub_catalogue_updated.send(
        sender=InnovationCatalogueEntry, entry=entry, version=entry.version, by_user=by_user,
    )
    return entry


# ---------------------------------------------------------------------------
# Attendance  (FRSME-ISD012)
# ---------------------------------------------------------------------------

@transaction.atomic
def record_attendance(
    *,
    event: ShowcaseEvent,
    participant,
    application: ShowcaseApplication | None = None,
    attended: bool = True,
    by_user=None,
    notes: str = "",
) -> EventAttendance:
    record, _ = EventAttendance.objects.update_or_create(
        event=event,
        participant=participant,
        defaults={
            "application": application,
            "attended": attended,
            "checked_in_at": timezone.now() if attended else None,
            "checked_in_by": by_user,
            "notes": notes,
        },
    )
    show_signals.smehub_attendance_recorded.send(
        sender=EventAttendance, attendance=record,
    )
    return record


# ---------------------------------------------------------------------------
# Pitch scoring  (FRSME-ISD013)
# ---------------------------------------------------------------------------

@transaction.atomic
def submit_pitch_score(
    *,
    application: ShowcaseApplication,
    judge,
    score,
    section: ChallengeRubricSection | None = None,
    comments: str = "",
) -> PitchScore:
    record, _ = PitchScore.objects.update_or_create(
        application=application,
        judge=judge,
        section=section,
        defaults={
            "score": Decimal(str(score)),
            "comments": comments,
            "submitted_at": timezone.now(),
        },
    )
    show_signals.smehub_pitch_scored.send(sender=PitchScore, score=record)
    return record


def compute_pitch_ranking(application: ShowcaseApplication) -> Decimal:
    """FRSME-ISD013 — weighted average mirroring SP2 incubation math.

    For challenge applications: aggregate per-section average across judges,
    normalise by section.max_marks, weight by section.weight, sum across
    sections, return as 0–100 percentage.

    For event applications (section=None): straight average of judge scores
    expressed as a 0–100 percentage (assumes raw scores 0–10 by convention).
    """
    if application.is_challenge_application:
        challenge = application.target
        rubric = getattr(challenge, "rubric", None) if challenge is not None else None
        if rubric is None:
            return Decimal("0")
        sections = list(rubric.sections.all())
        if not sections:
            return Decimal("0")
        scores_by_section: dict[int, list[Decimal]] = defaultdict(list)
        for ps in PitchScore.objects.filter(application=application).select_related("section"):
            if ps.section_id is not None:
                scores_by_section[ps.section_id].append(ps.score)
        total = Decimal("0")
        for section in sections:
            section_scores = scores_by_section.get(section.pk, [])
            if not section_scores or section.max_marks == 0:
                continue
            avg = sum(section_scores) / Decimal(len(section_scores))
            normalised = avg / Decimal(section.max_marks)
            total += normalised * section.weight
        return (total * Decimal("100")).quantize(Decimal("0.01"))
    # Event composite
    overall_scores = [ps.score for ps in PitchScore.objects.filter(application=application, section__isnull=True)]
    if not overall_scores:
        return Decimal("0")
    avg = sum(overall_scores) / Decimal(len(overall_scores))
    return (avg * Decimal("10")).quantize(Decimal("0.01"))


def compute_event_pitch_ranking(event: ShowcaseEvent) -> list[ShowcaseApplication]:
    """Returns event applications ordered by computed pitch score desc."""
    ct = ContentType.objects.get_for_model(ShowcaseEvent)
    apps = ShowcaseApplication.objects.filter(
        target_content_type=ct,
        target_object_id=str(event.pk),
        status__in=[
            ShowcaseApplication.Status.SUBMITTED,
            ShowcaseApplication.Status.UNDER_REVIEW,
            ShowcaseApplication.Status.CONFIRMED,
        ],
    ).select_related("entrepreneur__user", "business")
    ranked = []
    for app in apps:
        app.computed_score = compute_pitch_ranking(app)
        ranked.append(app)
    ranked.sort(key=lambda a: (a.computed_score, a.created_at), reverse=True)
    return ranked


# ---------------------------------------------------------------------------
# Deal rooms  (FRSME-ISD014–016, ISD019)
# ---------------------------------------------------------------------------

DEFAULT_DEAL_ROOM_DAYS = 60


@transaction.atomic
def open_deal_room(
    *,
    entrepreneur: EntrepreneurProfile,
    business: Business,
    counterparty,
    origin=None,
    title: str = "",
    summary: str = "",
    expiry_date=None,
    created_by=None,
) -> DealRoom:
    """FRSME-ISD014 — open a deal room.

    Idempotent on (business, counterparty): returns the existing OPEN room if
    one exists. PRD A4 — different counterparties get separate rooms.
    """
    if business.entrepreneur_id != entrepreneur.pk:
        raise ShowcasingStateError("Business does not belong to this entrepreneur.")
    counterparty_ct = ContentType.objects.get_for_model(counterparty.__class__)
    existing = DealRoom.objects.filter(
        business=business,
        counterparty_content_type=counterparty_ct,
        counterparty_object_id=str(counterparty.pk),
        status=DealRoom.Status.OPEN,
    ).first()
    if existing:
        return existing
    if expiry_date is None:
        expiry_date = timezone.now() + timezone.timedelta(days=DEFAULT_DEAL_ROOM_DAYS)
    origin_ct = None
    origin_id = ""
    if origin is not None:
        origin_ct = ContentType.objects.get_for_model(origin.__class__)
        origin_id = str(origin.pk)
    room = DealRoom.objects.create(
        entrepreneur=entrepreneur,
        business=business,
        counterparty_content_type=counterparty_ct,
        counterparty_object_id=str(counterparty.pk),
        origin_content_type=origin_ct,
        origin_object_id=origin_id,
        title=title or f"{business.name} ↔ {counterparty}",
        summary=summary,
        expiry_date=expiry_date,
        created_by=created_by,
    )
    _audit(created_by or entrepreneur.user, AuditLog.Action.CREATE, room)
    show_signals.smehub_deal_room_opened.send(
        sender=DealRoom, deal_room=room, by_user=created_by,
    )
    body = "A deal room has been opened — exchange messages and documents securely."
    notify_smehub(
        entrepreneur.user,
        body,
        verb=Verb.SMEHUB_DEAL_ROOM_OPENED,
        action_url=_safe_reverse("smehub_showcasing:deal_room_detail", pk=room.pk),
        target=room,
    )
    cp_owner = _counterparty_owner(counterparty)
    if cp_owner is not None:
        notify_smehub(
            cp_owner,
            body,
            verb=Verb.SMEHUB_DEAL_ROOM_OPENED,
            action_url=_safe_reverse("smehub_showcasing:deal_room_detail", pk=room.pk),
            target=room,
        )
    return room


@transaction.atomic
def close_deal_room(room: DealRoom, *, by_user, reason: str = "") -> DealRoom:
    if room.status == DealRoom.Status.CLOSED:
        return room
    room.close(reason=reason)
    room.save()
    _audit(by_user, AuditLog.Action.UPDATE, room, metadata={"status": "closed", "reason": reason})
    show_signals.smehub_deal_room_closed.send(
        sender=DealRoom, deal_room=room, by_user=by_user, reason=reason,
    )
    return room


def warn_expiring_deal_rooms(*, days_before: int = 3, now=None) -> int:
    """FRSME-ISD016 alt-A3 — email both parties when a Deal Room is within
    ``days_before`` days of its expiry. Idempotent: writes
    ``DealRoom.last_expiry_warning_at`` so a daily schedule does not spam
    re-warnings. Returns the number of rooms warned.
    """
    from datetime import timedelta

    now = now or timezone.now()
    window_end = now + timedelta(days=days_before)
    qs = DealRoom.objects.filter(
        status=DealRoom.Status.OPEN,
        expiry_date__gte=now,
        expiry_date__lte=window_end,
    )
    count = 0
    for room in qs:
        # Skip rooms we've already warned within the current cycle.
        if (
            getattr(room, "last_expiry_warning_at", None)
            and room.last_expiry_warning_at >= now - timedelta(days=days_before)
        ):
            continue
        recipients = [room.entrepreneur.user]
        cp_owner = _counterparty_owner(room.counterparty)
        if cp_owner and cp_owner not in recipients:
            recipients.append(cp_owner)
        days = (room.expiry_date - now).days
        bulk_notify_smehub(
            recipients,
            f"Deal room {room.room_id} expires in ~{max(days, 0)} day(s). "
            f"Take action before {room.expiry_date:%d %b %Y}.",
            verb=Verb.SMEHUB_DEAL_ROOM_EXPIRED,
            action_url=_safe_reverse("smehub_showcasing:deal_room_detail", pk=room.pk),
        )
        # Best-effort idempotency: a missing column should not crash the task.
        if hasattr(room, "last_expiry_warning_at"):
            DealRoom.objects.filter(pk=room.pk).update(last_expiry_warning_at=now)
        count += 1
    return count


@transaction.atomic
def expire_deal_rooms(now=None) -> int:
    """FRSME-ISD016 — Celery beat: flip OPEN rooms past expiry to EXPIRED."""
    now = now or timezone.now()
    qs = DealRoom.objects.filter(status=DealRoom.Status.OPEN, expiry_date__lt=now)
    count = 0
    for room in qs:
        # Re-fetch to avoid FSM cached-value issues (project memory).
        room = DealRoom.objects.get(pk=room.pk)
        room.expire()
        room.save()
        show_signals.smehub_deal_room_expired.send(sender=DealRoom, deal_room=room)
        recipients = [room.entrepreneur.user]
        cp_owner = _counterparty_owner(room.counterparty)
        if cp_owner and cp_owner not in recipients:
            recipients.append(cp_owner)
        bulk_notify_smehub(
            recipients,
            f"Deal room {room.room_id} has expired.",
            verb=Verb.SMEHUB_DEAL_ROOM_EXPIRED,
            action_url=_safe_reverse("smehub_showcasing:deal_room_detail", pk=room.pk),
        )
        count += 1
    return count


@transaction.atomic
def post_deal_room_message(
    *,
    room: DealRoom,
    author,
    body: str,
    parent: DealRoomMessage | None = None,
    attachment=None,
) -> DealRoomMessage:
    if room.status not in (DealRoom.Status.OPEN, DealRoom.Status.AGREEMENT_REACHED):
        raise ShowcasingStateError("Deal room is not open for new messages.")
    body = (body or "").strip()
    if not body and attachment is None:
        raise ValidationError("Message body or attachment is required.")
    message = DealRoomMessage.objects.create(
        deal_room=room,
        parent=parent,
        author=author,
        body=body,
        attachment=attachment,
    )
    show_signals.smehub_deal_room_message_posted.send(
        sender=DealRoomMessage, message=message,
    )
    recipients = []
    if room.entrepreneur.user_id != getattr(author, "pk", None):
        recipients.append(room.entrepreneur.user)
    cp_owner = _counterparty_owner(room.counterparty)
    if cp_owner is not None and getattr(cp_owner, "pk", None) != getattr(author, "pk", None):
        recipients.append(cp_owner)
    if recipients:
        bulk_notify_smehub(
            recipients,
            f"New message in deal room {room.room_id}.",
            verb=Verb.SMEHUB_DEAL_ROOM_MESSAGE,
            action_url=_safe_reverse("smehub_showcasing:deal_room_detail", pk=room.pk),
        )
    return message


def _next_doc_version(room: DealRoom, label: str) -> int:
    last = (
        DealRoomDocument.objects
        .filter(deal_room=room, label=label)
        .order_by("-version")
        .first()
    )
    return (last.version + 1) if last else 1


@transaction.atomic
def upload_deal_room_document(
    *,
    room: DealRoom,
    label: str,
    file,
    kind: str = DealRoomDocument.Kind.OTHER,
    notes: str = "",
    uploaded_by,
) -> DealRoomDocument:
    if room.status != DealRoom.Status.OPEN:
        raise ShowcasingStateError("Documents can only be uploaded to an open deal room.")
    document = DealRoomDocument.objects.create(
        deal_room=room,
        label=label,
        kind=kind,
        file=file,
        version=_next_doc_version(room, label),
        notes=notes,
        uploaded_by=uploaded_by,
    )
    _audit(uploaded_by, AuditLog.Action.CREATE, document)
    show_signals.smehub_deal_room_document_uploaded.send(
        sender=DealRoomDocument, document=document,
    )
    recipients = [room.entrepreneur.user]
    cp_owner = _counterparty_owner(room.counterparty)
    if cp_owner is not None and cp_owner not in recipients:
        recipients.append(cp_owner)
    recipients = [u for u in recipients if getattr(u, "pk", None) != getattr(uploaded_by, "pk", None)]
    if recipients:
        bulk_notify_smehub(
            recipients,
            f"New {document.get_kind_display().lower()} document in deal room {room.room_id}.",
            verb=Verb.SMEHUB_DEAL_ROOM_DOCUMENT,
            action_url=_safe_reverse("smehub_showcasing:deal_room_detail", pk=room.pk),
        )
    return document


@transaction.atomic
def formalise_agreement(
    *,
    room: DealRoom,
    title: str,
    signed_file,
    by_admin,
    summary: str = "",
    notes: str = "",
) -> DealAgreement:
    """FRSME-ISD019 — admin uploads the signed agreement and flips the room
    to AGREEMENT_REACHED.
    """
    if room.status not in (DealRoom.Status.OPEN, DealRoom.Status.AGREEMENT_REACHED):
        raise ShowcasingStateError("Only open or agreement-reached deal rooms can be formalised.")
    if signed_file is None:
        raise ValidationError("A signed file is required to formalise an agreement.")
    if hasattr(room, "agreement"):
        raise ShowcasingStateError("This deal room already has a formalised agreement.")
    if room.status == DealRoom.Status.OPEN:
        room.reach_agreement()
        room.save()
    agreement = DealAgreement.objects.create(
        deal_room=room,
        title=title,
        summary=summary,
        signed_file=signed_file,
        formalised_by_admin=by_admin,
        notes=notes,
    )
    _audit(by_admin, AuditLog.Action.CREATE, agreement)
    show_signals.smehub_agreement_formalised.send(
        sender=DealRoom, deal_room=room, agreement=agreement, by_user=by_admin,
    )
    recipients = [room.entrepreneur.user]
    cp_owner = _counterparty_owner(room.counterparty)
    if cp_owner is not None and cp_owner not in recipients:
        recipients.append(cp_owner)
    bulk_notify_smehub(
        recipients,
        f"Agreement formalised: “{title}”.",
        verb=Verb.SMEHUB_AGREEMENT_FORMALISED,
        action_url=_safe_reverse("smehub_showcasing:deal_room_detail", pk=room.pk),
    )
    return agreement
