"""SP4 showcasing views — events, challenges, applications, catalogue, deal rooms.

Side-effects flow through ``services``; the view layer does form binding,
permission checks, and template / HTMX response selection only.
"""
from __future__ import annotations

from decimal import Decimal

from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Count, Q
from django.http import Http404, HttpResponse
from django.contrib.auth.views import redirect_to_login
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.decorators.http import require_POST
from django.views.generic import (
    CreateView,
    DetailView,
    ListView,
    TemplateView,
    UpdateView,
)

from apps.core.lists import paginate_qs
from apps.core.permissions.roles import UserRole
from apps.smehub._shared.role_gates import (
    EntrepreneurRequired,
    JudgeRequired,
    SMEAdminRequired,
)
from apps.smehub.onboarding.models import Business, EntrepreneurProfile
from apps.smehub.showcasing import services
from apps.smehub.showcasing.forms import (
    ApplyToChallengeForm,
    ApplyToEventForm,
    AttendanceForm,
    CatalogueUpdateForm,
    ChallengeRubricCategoryFormSet,
    ChallengeRubricSectionFormSet,
    CloseDealRoomForm,
    ConfirmParticipantsForm,
    DealRoomDocumentForm,
    DealRoomMessageForm,
    FormaliseAgreementForm,
    InnovationChallengeForm,
    OpenDealRoomForm,
    PitchScoreForm,
    PublishToCatalogueForm,
    RejectApplicationForm,
    ShowcaseEventForm,
)
from apps.smehub.showcasing.models import (
    ChallengeRubricSection,
    ChallengeScoringRubric,
    DealAgreement,
    DealRoom,
    DealRoomDocument,
    DealRoomMessage,
    EventAttendance,
    InnovationCatalogueEntry,
    InnovationChallenge,
    PitchScore,
    ShowcaseApplication,
    ShowcaseEvent,
)
from apps.smehub.showcasing.rubric_defaults import apply_esg_rubric


SME_ADMIN_ROLES = {
    UserRole.SYSTEM_ADMIN,
    UserRole.ADMIN,
    UserRole.SME_ADMIN,
}

STAKEHOLDER_ROLES = {
    UserRole.INVESTOR,
    UserRole.FUNDER,
    UserRole.PARTNER,
    UserRole.BUYER,
    UserRole.SERVICE_PROVIDER,
}


# ---------------------------------------------------------------------------
# Hub landing — Slice B.2.
# ---------------------------------------------------------------------------

class ShowcasingHubView(LoginRequiredMixin, TemplateView):
    """Hub re-homing Events / Challenges / Catalogue / My entries."""

    template_name = "smehub/showcasing/hub.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        user = self.request.user
        ctx["is_admin"] = user.is_authenticated and user.role in SME_ADMIN_ROLES
        ctx["is_entrepreneur"] = user.is_authenticated and user.role == UserRole.ENTREPRENEUR
        ctx["is_judge"] = user.is_authenticated and user.role == UserRole.JUDGE
        ctx["upcoming_events"] = (
            ShowcaseEvent.objects.filter(
                status__in=[ShowcaseEvent.Status.PUBLISHED, ShowcaseEvent.Status.LIVE],
            )
            .order_by("starts_at")[:6]
        )
        ctx["open_challenges"] = (
            InnovationChallenge.objects.filter(
                status=InnovationChallenge.Status.OPEN,
            )
            .order_by("application_deadline")[:6]
        )
        ctx["catalogue_count"] = InnovationCatalogueEntry.objects.filter(
            is_active=True,
        ).count()
        if ctx["is_entrepreneur"]:
            ctx["my_applications_count"] = ShowcaseApplication.objects.filter(
                entrepreneur__user=user,
            ).count()
        if ctx["is_judge"]:
            # FRSME-ISD008 — a panel judge's route to the scoring pages lives
            # here (and on the dashboard widget); without it the judge has no
            # in-app path to their assigned events/challenges.
            ctx["judge_panel_events"] = list(
                user.smehub_showcase_panels.exclude(status=ShowcaseEvent.Status.DRAFT)
                .order_by("-starts_at")[:6]
            )
            ctx["judge_panel_challenges"] = list(
                user.smehub_challenge_panels.exclude(status=InnovationChallenge.Status.DRAFT)
                .order_by("-application_deadline")[:6]
            )
            ctx["judge_panel_count"] = len(ctx["judge_panel_events"]) + len(
                ctx["judge_panel_challenges"]
            )
        return ctx


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _is_sme_admin(user) -> bool:
    if not user.is_authenticated:
        return False
    if user.is_superuser:
        return True
    return user.role in SME_ADMIN_ROLES


def _entrepreneur_profile(user) -> EntrepreneurProfile | None:
    if not user.is_authenticated:
        return None
    return EntrepreneurProfile.objects.filter(user=user).first()


def _resolve_counterparty(kind: str, pk: int):
    from apps.smehub.linkage.models import (
        PartnerDirectoryEntry,
        ServiceProviderEntry,
    )
    from apps.smehub.marketplace.models import BuyerRegistryEntry

    Model = {
        "partner": PartnerDirectoryEntry,
        "service_provider": ServiceProviderEntry,
        "buyer": BuyerRegistryEntry,
    }.get(kind)
    if Model is None:
        raise Http404("Unknown counterparty kind.")
    return get_object_or_404(Model, pk=pk)


def _user_can_access_deal_room(user, room: DealRoom) -> bool:
    if not user.is_authenticated:
        return False
    if _is_sme_admin(user):
        return True
    if room.entrepreneur.user_id == user.pk:
        return True
    counterparty = room.counterparty
    if counterparty is None:
        return False
    cp_owner = (
        getattr(counterparty, "managed_by", None)
        or getattr(counterparty, "user", None)
    )
    return cp_owner is not None and getattr(cp_owner, "pk", None) == user.pk


# ---------------------------------------------------------------------------
# Showcase events — public
# ---------------------------------------------------------------------------

class ShowcaseEventListView(ListView):
    template_name = "smehub/showcasing/event_list.html"
    context_object_name = "events"
    paginate_by = 12

    def get_queryset(self):
        qs = ShowcaseEvent.objects.all()
        if not self.request.user.is_authenticated or not _is_sme_admin(self.request.user):
            qs = qs.exclude(status=ShowcaseEvent.Status.DRAFT)
        sector = self.request.GET.get("sector")
        if sector:
            qs = qs.filter(target_sectors__icontains=sector)
        fmt = self.request.GET.get("format")
        if fmt:
            qs = qs.filter(format=fmt)
        status = self.request.GET.get("status")
        if status:
            qs = qs.filter(status=status)
        return qs.order_by("-starts_at")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["sector_filter"] = self.request.GET.get("sector", "")
        ctx["format_filter"] = self.request.GET.get("format", "")
        ctx["status_filter"] = self.request.GET.get("status", "")
        ctx["is_admin"] = self.request.user.is_authenticated and _is_sme_admin(self.request.user)
        ctx["format_choices"] = ShowcaseEvent.Format.choices
        ctx["status_choices"] = ShowcaseEvent.Status.choices
        ctx["stat_counts"] = {
            "total": ShowcaseEvent.objects.exclude(status=ShowcaseEvent.Status.DRAFT).count(),
            "published": ShowcaseEvent.objects.filter(status=ShowcaseEvent.Status.PUBLISHED).count(),
            "live": ShowcaseEvent.objects.filter(status=ShowcaseEvent.Status.LIVE).count(),
            "concluded": ShowcaseEvent.objects.filter(status=ShowcaseEvent.Status.CONCLUDED).count(),
        }
        return ctx


class ShowcaseEventDetailView(DetailView):
    model = ShowcaseEvent
    template_name = "smehub/showcasing/event_detail.html"
    context_object_name = "event"

    def get_queryset(self):
        qs = super().get_queryset()
        if not self.request.user.is_authenticated or not _is_sme_admin(self.request.user):
            qs = qs.exclude(status=ShowcaseEvent.Status.DRAFT)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        event: ShowcaseEvent = ctx["event"]
        ctx["is_admin"] = self.request.user.is_authenticated and _is_sme_admin(self.request.user)
        ctx["panel"] = list(event.evaluation_panel.all())
        ctx["is_panel_judge"] = (
            self.request.user.is_authenticated
            and getattr(self.request.user, "role", None) == UserRole.JUDGE
            and any(j.pk == self.request.user.pk for j in ctx["panel"])
        )
        profile = _entrepreneur_profile(self.request.user) if self.request.user.is_authenticated else None
        ctx["entrepreneur"] = profile
        ct = ContentType.objects.get_for_model(ShowcaseEvent)
        ctx["application_count"] = ShowcaseApplication.objects.filter(
            target_content_type=ct, target_object_id=str(event.pk),
        ).count()
        ctx["confirmed_count"] = ShowcaseApplication.objects.filter(
            target_content_type=ct,
            target_object_id=str(event.pk),
            status=ShowcaseApplication.Status.CONFIRMED,
        ).count()
        ctx["my_application"] = None
        if profile is not None:
            ctx["my_application"] = ShowcaseApplication.objects.filter(
                target_content_type=ct,
                target_object_id=str(event.pk),
                entrepreneur=profile,
            ).first()
        # FRSME-ISD006 — surface the closed-applications state before the user
        # invests effort in the wizard, rather than only rejecting on submit.
        ctx["deadline_passed"] = bool(
            event.application_deadline and event.application_deadline < timezone.now()
        )
        # FRSME-ISD011 — surface the gated "Join live session" button to
        # permitted users, and the access log to the organiser / admin.
        ctx["can_join_conference"] = (
            event.status == ShowcaseEvent.Status.LIVE
            and event.is_virtual_or_hybrid
            and services.event_access_role(event, self.request.user) is not None
        )
        if ctx["is_admin"] or (
            self.request.user.is_authenticated and event.organiser_id == self.request.user.pk
        ):
            ctx["access_log"] = list(
                event.access_log.select_related("user").order_by("-accessed_at")[:20]
            )
        return ctx


_SHOWCASE_EVENT_WIZARD_STEPS = [
    {"n": 1, "title": "Basics"},
    {"n": 2, "title": "Schedule & venue"},
    {"n": 3, "title": "Eligibility & panel"},
]


class ShowcaseEventCreateView(SMEAdminRequired, CreateView):
    model = ShowcaseEvent
    form_class = ShowcaseEventForm
    template_name = "smehub/showcasing/event_form.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _SHOWCASE_EVENT_WIZARD_STEPS
        ctx["cancel_url"] = reverse("smehub_showcasing:event_list")
        ctx["submit_label"] = "Save as draft"
        return ctx

    def form_valid(self, form):
        try:
            event = services.create_showcase_event(
                organiser=self.request.user,
                name=form.cleaned_data["name"],
                summary=form.cleaned_data.get("summary", ""),
                description=form.cleaned_data.get("description", ""),
                format=form.cleaned_data["format"],
                starts_at=form.cleaned_data["starts_at"],
                ends_at=form.cleaned_data["ends_at"],
                application_deadline=form.cleaned_data.get("application_deadline"),
                target_sectors=form.cleaned_data.get("target_sectors") or [],
                venue=form.cleaned_data.get("venue", ""),
                video_link=form.cleaned_data.get("video_link", ""),
                cover_image=form.cleaned_data.get("cover_image"),
                panel_users=form.cleaned_data.get("evaluation_panel"),
            )
        except (ValidationError, services.ShowcasingStateError) as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        messages.success(self.request, f"Showcase event “{event.name}” drafted.")
        return redirect("smehub_showcasing:event_detail", pk=event.pk)


class ShowcaseEventUpdateView(SMEAdminRequired, UpdateView):
    model = ShowcaseEvent
    form_class = ShowcaseEventForm
    template_name = "smehub/showcasing/event_form.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _SHOWCASE_EVENT_WIZARD_STEPS
        ctx["cancel_url"] = reverse("smehub_showcasing:event_list")
        ctx["submit_label"] = "Save changes"
        return ctx

    def form_valid(self, form):
        event = self.get_object()
        if event.status == ShowcaseEvent.Status.CONCLUDED:
            form.add_error(None, "Concluded events cannot be edited.")
            return self.form_invalid(form)
        try:
            services.update_showcase_event(
                event,
                by_user=self.request.user,
                panel_users=form.cleaned_data.get("evaluation_panel"),
                name=form.cleaned_data["name"],
                summary=form.cleaned_data.get("summary", ""),
                description=form.cleaned_data.get("description", ""),
                format=form.cleaned_data["format"],
                starts_at=form.cleaned_data["starts_at"],
                ends_at=form.cleaned_data["ends_at"],
                application_deadline=form.cleaned_data.get("application_deadline"),
                target_sectors=list(form.cleaned_data.get("target_sectors") or []),
                venue=form.cleaned_data.get("venue", ""),
                video_link=form.cleaned_data.get("video_link", ""),
                cover_image=form.cleaned_data.get("cover_image") or event.cover_image,
            )
        except (ValidationError, services.ShowcasingStateError) as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        messages.success(self.request, "Event updated.")
        return redirect("smehub_showcasing:event_detail", pk=event.pk)


@require_POST
def showcase_publish_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    event = get_object_or_404(ShowcaseEvent, pk=pk)
    try:
        services.publish_showcase(event, by_user=request.user)
        messages.success(request, "Event published.")
    except services.ShowcasingStateError as exc:
        messages.error(request, str(exc))
    return redirect("smehub_showcasing:event_detail", pk=event.pk)


@require_POST
def showcase_revert_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    event = get_object_or_404(ShowcaseEvent, pk=pk)
    try:
        services.revert_showcase_to_draft(event, by_user=request.user)
        messages.success(request, "Event reverted to draft.")
    except services.ShowcasingStateError as exc:
        messages.error(request, str(exc))
    return redirect("smehub_showcasing:event_detail", pk=event.pk)


@require_POST
def showcase_go_live_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    event = get_object_or_404(ShowcaseEvent, pk=pk)
    try:
        services.mark_showcase_live(event, by_user=request.user)
        messages.success(request, "Event is now live.")
    except services.ShowcasingStateError as exc:
        messages.error(request, str(exc))
    return redirect("smehub_showcasing:event_detail", pk=event.pk)


def showcase_event_join_view(request, pk: int):
    """FRSME-ISD011 — gated entry to a virtual/hybrid event's conferencing
    session. Confirmed participants, evaluators, organiser and SME admins may
    join; every access is logged before redirecting to the actual link."""
    if not request.user.is_authenticated:
        return redirect_to_login(request.get_full_path())
    event = get_object_or_404(ShowcaseEvent, pk=pk)
    role = services.event_access_role(event, request.user)
    if role is None:
        # Guidance beats a 403 — the viewer can usually fix this themselves
        # (apply / await confirmation) or knows who to contact.
        messages.warning(
            request,
            "The live session is open to confirmed participants, the evaluation panel "
            "and organisers only. If you'd like to take part, apply to the event below; "
            "if you've already applied, you'll be able to join once the organiser "
            "confirms your participation.",
        )
        return redirect("smehub_showcasing:event_detail", pk=event.pk)
    services.record_event_access(event, user=request.user, role=role)
    join_url = services.event_conference_join_url(event, user=request.user, role=role)
    if not join_url:
        messages.info(request, "The live session link isn't available yet — please check back shortly.")
        return redirect("smehub_showcasing:event_detail", pk=event.pk)
    return redirect(join_url)


@require_POST
def showcase_conclude_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    event = get_object_or_404(ShowcaseEvent, pk=pk)
    try:
        services.conclude_showcase(event, by_user=request.user)
        messages.success(request, "Event concluded.")
    except services.ShowcasingStateError as exc:
        messages.error(request, str(exc))
    return redirect("smehub_showcasing:event_detail", pk=event.pk)


# ---------------------------------------------------------------------------
# Applications  (event)
# ---------------------------------------------------------------------------

_APPLICATION_WIZARD_STEPS = [
    {"n": 1, "title": "Business"},
    {"n": 2, "title": "Innovation"},
    {"n": 3, "title": "Materials"},
]


class ApplyToEventView(EntrepreneurRequired, View):
    template_name = "smehub/showcasing/apply_to_event.html"

    def _profile_or_403(self, user):
        profile = _entrepreneur_profile(user)
        if profile is None:
            raise PermissionDenied("Entrepreneur profile required.")
        return profile

    def _ctx(self, event, form):
        return {
            "event": event,
            "form": form,
            "wizard_steps": _APPLICATION_WIZARD_STEPS,
            "cancel_url": reverse("smehub_showcasing:event_detail", kwargs={"pk": event.pk}),
            "autosave_key": f"smehub-showcase-event-{event.pk}",
        }

    def get(self, request, pk: int):
        event = get_object_or_404(ShowcaseEvent, pk=pk)
        profile = self._profile_or_403(request.user)
        # FRSME-ISD006 — don't let the applicant fill the whole wizard when the
        # deadline has already passed; surface the closed state up front.
        if event.application_deadline and event.application_deadline < timezone.now():
            messages.info(
                request,
                f"Applications for “{event.name}” closed on "
                f"{event.application_deadline.strftime('%-d %b %Y')}.",
            )
            return redirect("smehub_showcasing:event_detail", pk=event.pk)
        draft = services.get_showcase_draft(entrepreneur=profile, target=event)
        form = ApplyToEventForm(entrepreneur=profile, instance=draft)
        return render(request, self.template_name, self._ctx(event, form))

    def post(self, request, pk: int):
        event = get_object_or_404(ShowcaseEvent, pk=pk)
        profile = self._profile_or_403(request.user)
        as_draft = request.POST.get("action") == "draft"
        form = ApplyToEventForm(request.POST, request.FILES, entrepreneur=profile, draft=as_draft)
        if not form.is_valid():
            return render(request, self.template_name, self._ctx(event, form))
        try:
            application = services.apply_to_event(
                event=event,
                entrepreneur=profile,
                business=form.cleaned_data["business"],
                innovation_title=form.cleaned_data.get("innovation_title", ""),
                innovation_description=form.cleaned_data.get("innovation_description", ""),
                problem_statement=form.cleaned_data.get("problem_statement", ""),
                solution_summary=form.cleaned_data.get("solution_summary", ""),
                impact_potential=form.cleaned_data.get("impact_potential", ""),
                pitch_deck=form.cleaned_data.get("pitch_deck"),
                pitch_video_link=form.cleaned_data.get("pitch_video_link", ""),
                supporting_link=form.cleaned_data.get("supporting_link", ""),
                as_draft=as_draft,
            )
        except services.ShowcasingStateError as exc:
            form.add_error(None, str(exc))
            return render(request, self.template_name, self._ctx(event, form))
        if as_draft:
            messages.success(request, "Draft saved — finish and submit any time.")
            return redirect("smehub_showcasing:my_applications")
        messages.success(request, f"Application {application.application_id} submitted.")
        return redirect("smehub_showcasing:event_detail", pk=event.pk)


# ---------------------------------------------------------------------------
# Innovation challenges
# ---------------------------------------------------------------------------

class ChallengeListView(ListView):
    template_name = "smehub/showcasing/challenge_list.html"
    context_object_name = "challenges"
    paginate_by = 12

    def get_queryset(self):
        qs = InnovationChallenge.objects.all()
        if not self.request.user.is_authenticated or not _is_sme_admin(self.request.user):
            qs = qs.exclude(status=InnovationChallenge.Status.DRAFT)
        status = self.request.GET.get("status")
        if status:
            qs = qs.filter(status=status)
        return qs.order_by("-application_deadline")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["status_filter"] = self.request.GET.get("status", "")
        ctx["status_choices"] = InnovationChallenge.Status.choices
        ctx["is_admin"] = self.request.user.is_authenticated and _is_sme_admin(self.request.user)
        ctx["stat_counts"] = {
            "open": InnovationChallenge.objects.filter(status=InnovationChallenge.Status.OPEN).count(),
            "judging": InnovationChallenge.objects.filter(status=InnovationChallenge.Status.JUDGING).count(),
            "announced": InnovationChallenge.objects.filter(status=InnovationChallenge.Status.ANNOUNCED).count(),
            "closed": InnovationChallenge.objects.filter(status=InnovationChallenge.Status.CLOSED).count(),
        }
        return ctx


class ChallengeDetailView(DetailView):
    model = InnovationChallenge
    template_name = "smehub/showcasing/challenge_detail.html"
    context_object_name = "challenge"

    def get_queryset(self):
        qs = super().get_queryset()
        if not self.request.user.is_authenticated or not _is_sme_admin(self.request.user):
            qs = qs.exclude(status=InnovationChallenge.Status.DRAFT)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        challenge: InnovationChallenge = ctx["challenge"]
        ctx["is_admin"] = self.request.user.is_authenticated and _is_sme_admin(self.request.user)
        ctx["is_panel_judge"] = (
            self.request.user.is_authenticated
            and getattr(self.request.user, "role", None) == UserRole.JUDGE
            and challenge.evaluation_panel.filter(pk=self.request.user.pk).exists()
        )
        ctx["rubric"] = getattr(challenge, "rubric", None)
        ct = ContentType.objects.get_for_model(InnovationChallenge)
        ctx["application_count"] = ShowcaseApplication.objects.filter(
            target_content_type=ct, target_object_id=str(challenge.pk),
        ).count()
        profile = _entrepreneur_profile(self.request.user)
        ctx["entrepreneur"] = profile
        ctx["my_application"] = None
        if profile is not None:
            ctx["my_application"] = ShowcaseApplication.objects.filter(
                target_content_type=ct,
                target_object_id=str(challenge.pk),
                entrepreneur=profile,
            ).first()
        # FRSME-ISD006 — surface the closed-applications state pre-submit.
        ctx["deadline_passed"] = bool(
            challenge.application_deadline and challenge.application_deadline < timezone.now()
        )
        return ctx


class ChallengeCreateView(SMEAdminRequired, View):
    template_name = "smehub/showcasing/challenge_form.html"

    def get(self, request):
        form = InnovationChallengeForm()
        return render(request, self.template_name, {"form": form, "is_create": True})

    def post(self, request):
        form = InnovationChallengeForm(request.POST, request.FILES)
        if not form.is_valid():
            return render(request, self.template_name, {"form": form, "is_create": True})
        challenge = services.create_challenge(
            organiser=request.user,
            title=form.cleaned_data["title"],
            summary=form.cleaned_data.get("summary", ""),
            description=form.cleaned_data.get("description", ""),
            theme=form.cleaned_data.get("theme", ""),
            target_sectors=form.cleaned_data.get("target_sectors") or [],
            prize_summary=form.cleaned_data.get("prize_summary", ""),
            application_deadline=form.cleaned_data["application_deadline"],
            judging_deadline=form.cleaned_data.get("judging_deadline"),
            cover_image=form.cleaned_data.get("cover_image"),
        )
        messages.success(request, f"Challenge “{challenge.title}” drafted. Configure the rubric next.")
        return redirect("smehub_showcasing:challenge_rubric", pk=challenge.pk)


class ChallengeUpdateView(SMEAdminRequired, UpdateView):
    model = InnovationChallenge
    form_class = InnovationChallengeForm
    template_name = "smehub/showcasing/challenge_form.html"

    def get_success_url(self):
        return reverse("smehub_showcasing:challenge_detail", kwargs={"pk": self.object.pk})


class ChallengeRubricView(SMEAdminRequired, View):
    template_name = "smehub/showcasing/challenge_rubric_form.html"

    def _get_rubric(self, challenge):
        rubric, _ = ChallengeScoringRubric.objects.get_or_create(challenge=challenge)
        return rubric

    def _section_formset(self, rubric, data=None):
        # form_kwargs reaches EVERY form the formset builds — including the
        # freshly-constructed ``empty_form`` the template clones for new rows —
        # so each criterion's ``category`` dropdown is scoped to this rubric.
        # (Binding ``formset.empty_form.fields`` in the view does not work:
        # ``empty_form`` is a plain property that returns a new form each access.)
        return ChallengeRubricSectionFormSet(
            data, instance=rubric, form_kwargs={"rubric": rubric},
        )

    def _render(self, request, challenge, rubric, section_formset, category_formset):
        return render(
            request,
            self.template_name,
            {
                "challenge": challenge,
                "rubric": rubric,
                "formset": section_formset,
                "category_formset": category_formset,
            },
        )

    def get(self, request, pk: int):
        challenge = get_object_or_404(InnovationChallenge, pk=pk)
        rubric = self._get_rubric(challenge)
        return self._render(
            request,
            challenge,
            rubric,
            self._section_formset(rubric),
            ChallengeRubricCategoryFormSet(instance=rubric),
        )

    def post(self, request, pk: int):
        challenge = get_object_or_404(InnovationChallenge, pk=pk)
        rubric = self._get_rubric(challenge)

        # One-click provisioning of the canonical ESG rubric (replaces any
        # existing categories + criteria on this rubric).
        if request.POST.get("apply_esg"):
            # PitchScore.section is PROTECT — once judges have scored against the
            # current criteria we cannot swap the rubric out from under them.
            if PitchScore.objects.filter(section__rubric=rubric).exists():
                messages.error(
                    request,
                    "Judges have already scored against this rubric, so it can't be "
                    "replaced. Clear the existing scores first if you need to reset it.",
                )
                return redirect("smehub_showcasing:challenge_rubric", pk=challenge.pk)
            apply_esg_rubric(rubric)
            messages.success(
                request,
                "Applied the ESG standard rubric — 6 categories, 20 criteria, weights sum to 1.0.",
            )
            return redirect("smehub_showcasing:challenge_rubric", pk=challenge.pk)

        category_formset = ChallengeRubricCategoryFormSet(request.POST, instance=rubric)
        section_formset = self._section_formset(rubric, request.POST)

        if not (category_formset.is_valid() and section_formset.is_valid()):
            return self._render(request, challenge, rubric, section_formset, category_formset)

        # Save categories first so newly-added ones are selectable for criteria
        # on the next page load.
        category_formset.save()
        section_formset.save()

        if rubric.is_complete:
            messages.success(request, "Rubric saved — challenge is ready to publish.")
        else:
            messages.warning(
                request,
                f"Rubric saved but criterion weights total {rubric.total_weight}; they must sum to 1.0 before publishing.",
            )
        return redirect("smehub_showcasing:challenge_rubric", pk=challenge.pk)


@require_POST
def challenge_publish_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    challenge = get_object_or_404(InnovationChallenge, pk=pk)
    try:
        services.publish_challenge(challenge, by_user=request.user)
        messages.success(request, "Challenge published.")
    except services.ShowcasingStateError as exc:
        messages.error(request, str(exc))
    return redirect("smehub_showcasing:challenge_detail", pk=challenge.pk)


class ApplyToChallengeView(EntrepreneurRequired, View):
    template_name = "smehub/showcasing/apply_to_challenge.html"

    def _profile_or_403(self, user):
        profile = _entrepreneur_profile(user)
        if profile is None:
            raise PermissionDenied("Entrepreneur profile required.")
        return profile

    def _ctx(self, challenge, form):
        return {
            "challenge": challenge,
            "form": form,
            "wizard_steps": _APPLICATION_WIZARD_STEPS,
            "cancel_url": reverse("smehub_showcasing:challenge_detail", kwargs={"pk": challenge.pk}),
            "autosave_key": f"smehub-showcase-challenge-{challenge.pk}",
        }

    def get(self, request, pk: int):
        challenge = get_object_or_404(InnovationChallenge, pk=pk)
        profile = self._profile_or_403(request.user)
        # FRSME-ISD006 — surface the closed-entries state up front.
        if challenge.application_deadline and challenge.application_deadline < timezone.now():
            messages.info(
                request,
                f"Entries for “{challenge.title}” closed on "
                f"{challenge.application_deadline.strftime('%-d %b %Y')}.",
            )
            return redirect("smehub_showcasing:challenge_detail", pk=challenge.pk)
        draft = services.get_showcase_draft(entrepreneur=profile, target=challenge)
        form = ApplyToChallengeForm(entrepreneur=profile, instance=draft)
        return render(request, self.template_name, self._ctx(challenge, form))

    def post(self, request, pk: int):
        challenge = get_object_or_404(InnovationChallenge, pk=pk)
        profile = self._profile_or_403(request.user)
        as_draft = request.POST.get("action") == "draft"
        form = ApplyToChallengeForm(request.POST, request.FILES, entrepreneur=profile, draft=as_draft)
        if not form.is_valid():
            return render(request, self.template_name, self._ctx(challenge, form))
        try:
            application = services.apply_to_challenge(
                challenge=challenge,
                entrepreneur=profile,
                business=form.cleaned_data["business"],
                innovation_title=form.cleaned_data.get("innovation_title", ""),
                innovation_description=form.cleaned_data.get("innovation_description", ""),
                problem_statement=form.cleaned_data.get("problem_statement", ""),
                solution_summary=form.cleaned_data.get("solution_summary", ""),
                impact_potential=form.cleaned_data.get("impact_potential", ""),
                pitch_deck=form.cleaned_data.get("pitch_deck"),
                pitch_video_link=form.cleaned_data.get("pitch_video_link", ""),
                supporting_link=form.cleaned_data.get("supporting_link", ""),
                as_draft=as_draft,
            )
        except services.ShowcasingStateError as exc:
            form.add_error(None, str(exc))
            return render(request, self.template_name, self._ctx(challenge, form))
        if as_draft:
            messages.success(request, "Draft saved — finish and submit any time.")
            return redirect("smehub_showcasing:my_applications")
        messages.success(request, f"Entry {application.application_id} submitted.")
        return redirect("smehub_showcasing:challenge_detail", pk=challenge.pk)


# ---------------------------------------------------------------------------
# Application admin (per event/challenge)
# ---------------------------------------------------------------------------

class PanelOrAdminRequired(SMEAdminRequired):
    """FRSME-ISD008 — SME admins manage applications; judges on the target's
    ``evaluation_panel`` get read-only access so they can reach the pitch
    scoring pages (templates hide admin controls via ``can_manage``).
    """

    allowed_roles = (*SMEAdminRequired.allowed_roles, UserRole.JUDGE)
    panel_target_model = None  # set on the view
    panel_detail_url = None  # detail route to land on when a judge isn't on the panel

    def dispatch(self, request, *args, **kwargs):
        user = request.user
        if (
            user.is_authenticated
            and not user.is_superuser
            and getattr(user, "role", None) == UserRole.JUDGE
        ):
            target = get_object_or_404(self.panel_target_model, pk=kwargs["pk"])
            if not target.evaluation_panel.filter(pk=user.pk).exists():
                # A judge who simply isn't on this panel gets guidance, not a
                # bare 403 — the lockout is fixable by the organiser.
                messages.warning(
                    request,
                    "You're not on the evaluation panel for "
                    f"“{target}” yet, so its pitches aren't open to you. "
                    "Ask the organiser or an SME-Hub administrator to add you to the panel — "
                    "your assigned events and challenges then appear in the judging queue on your dashboard.",
                )
                return redirect(self.panel_detail_url, pk=target.pk)
        return super().dispatch(request, *args, **kwargs)


class EventApplicationsView(PanelOrAdminRequired, TemplateView):
    template_name = "smehub/showcasing/event_applications.html"
    panel_target_model = ShowcaseEvent
    panel_detail_url = "smehub_showcasing:event_detail"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        event = get_object_or_404(ShowcaseEvent, pk=kwargs["pk"])
        ct = ContentType.objects.get_for_model(ShowcaseEvent)
        applications = ShowcaseApplication.objects.filter(
            target_content_type=ct, target_object_id=str(event.pk),
        ).select_related("entrepreneur__user", "business").order_by("-created_at")
        for app in applications:
            app.computed_score = services.compute_pitch_ranking(app)
        # NOTE: intentionally NOT paginated — the page is one confirm-participants
        # <form>; checkboxes across all submissions POST together, so off-page
        # rows must stay in the DOM.
        ctx.update({
            "event": event,
            "applications": applications,
            "confirm_form": ConfirmParticipantsForm(),
            "can_manage": _is_sme_admin(self.request.user),
            "stat_counts": {
                "submitted": applications.filter(status=ShowcaseApplication.Status.SUBMITTED).count(),
                "under_review": applications.filter(status=ShowcaseApplication.Status.UNDER_REVIEW).count(),
                "confirmed": applications.filter(status=ShowcaseApplication.Status.CONFIRMED).count(),
                "rejected": applications.filter(status=ShowcaseApplication.Status.REJECTED).count(),
            },
        })
        return ctx


class ChallengeApplicationsView(PanelOrAdminRequired, TemplateView):
    template_name = "smehub/showcasing/challenge_applications.html"
    panel_target_model = InnovationChallenge
    panel_detail_url = "smehub_showcasing:challenge_detail"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        challenge = get_object_or_404(InnovationChallenge, pk=kwargs["pk"])
        ct = ContentType.objects.get_for_model(InnovationChallenge)
        applications = ShowcaseApplication.objects.filter(
            target_content_type=ct, target_object_id=str(challenge.pk),
        ).select_related("entrepreneur__user", "business").order_by("-created_at")
        for app in applications:
            app.computed_score = services.compute_pitch_ranking(app)
        applications = sorted(applications, key=lambda a: a.computed_score, reverse=True)
        # NOTE: intentionally NOT paginated — the page is one winner-pick <form>;
        # checkboxes across the full ranked list POST together to
        # challenge_confirm_participants, so off-page rows must stay in the DOM.
        ctx.update({
            "challenge": challenge,
            "applications": applications,
            "confirm_form": ConfirmParticipantsForm(),
            "can_manage": _is_sme_admin(self.request.user),
        })
        return ctx


@require_POST
def challenge_transition_to_judging_view(request, pk: int):
    """Slice B.3c — move OPEN challenge to JUDGING."""
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    challenge = get_object_or_404(InnovationChallenge, pk=pk)
    try:
        services.transition_to_judging(challenge, by_user=request.user)
    except services.ShowcasingStateError as exc:
        messages.error(request, str(exc))
    else:
        messages.success(request, f"Challenge moved to judging. Scores can now be finalised.")
    return redirect("smehub_showcasing:challenge_applications", pk=challenge.pk)


class ChallengeAnnounceWinnersView(SMEAdminRequired, LoginRequiredMixin, View):
    """Slice B.3b — admin UI for the existing ``announce_winners()`` service.

    Lists confirmed/scored applications ranked by computed score; admin ticks
    winners; service triggers MEI signal + entrepreneur notifications.
    """

    template_name = "smehub/showcasing/challenge_announce_winners.html"

    def _ctx(self, challenge):
        applications = list(
            ShowcaseApplication.objects.filter(
                target_content_type=ContentType.objects.get_for_model(InnovationChallenge),
                target_object_id=str(challenge.pk),
                status=ShowcaseApplication.Status.CONFIRMED,
            ).select_related("entrepreneur__user", "business")
        )
        for app in applications:
            app.computed_score = services.compute_pitch_ranking(app)
        applications.sort(key=lambda a: a.computed_score, reverse=True)
        # NOTE: intentionally NOT paginated — winner checkboxes across the full
        # ranked list POST together as winner_ids, so off-page rows must stay
        # in the DOM.
        return {"challenge": challenge, "applications": applications}

    def get(self, request, pk: int):
        challenge = get_object_or_404(InnovationChallenge, pk=pk)
        return render(request, self.template_name, self._ctx(challenge))

    def post(self, request, pk: int):
        challenge = get_object_or_404(InnovationChallenge, pk=pk)
        winner_ids = [int(v) for v in request.POST.getlist("winner_ids") if v.isdigit()]
        if not winner_ids:
            messages.error(request, "Select at least one winner.")
            return redirect("smehub_showcasing:challenge_announce_winners", pk=challenge.pk)
        winners = ShowcaseApplication.objects.filter(pk__in=winner_ids)
        n = winners.count()
        try:
            services.announce_winners(challenge, winners=winners, by_user=request.user)
        except services.ShowcasingStateError as exc:
            messages.error(request, str(exc))
            return redirect("smehub_showcasing:challenge_announce_winners", pk=challenge.pk)
        plural = "" if n == 1 else "s"
        messages.success(
            request,
            f"✓ {n} winner{plural} announced for “{challenge.title}” · "
            f"{n} notification email{plural} sent · {n} deal room{plural} opened.",
        )
        return redirect("smehub_showcasing:challenge_detail", pk=challenge.pk)


@require_POST
def confirm_event_participants_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    event = get_object_or_404(ShowcaseEvent, pk=pk)
    form = ConfirmParticipantsForm(request.POST)
    if not form.is_valid():
        messages.error(request, "; ".join(err for errs in form.errors.values() for err in errs))
        return redirect("smehub_showcasing:event_applications", pk=event.pk)
    confirmed = services.confirm_participants(
        target=event,
        application_ids=form.cleaned_data["application_ids"],
        by_user=request.user,
        note=form.cleaned_data.get("note", ""),
    )
    messages.success(request, f"Confirmed {len(confirmed)} participant(s).")
    return redirect("smehub_showcasing:event_applications", pk=event.pk)


@require_POST
def finalise_event_selection_view(request, pk: int):
    """FRSME-ISD007 — close the loop for a Showcase Event: reject + notify every
    application still awaiting a decision so non-selected applicants learn their
    outcome. Confirmed participants have already left the Submitted/Under-review
    states, so they are untouched.
    """
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    event = get_object_or_404(ShowcaseEvent, pk=pk)
    n = services.reject_remaining_applications(
        target=event,
        by_user=request.user,
        note=f"Not selected for “{event.name}”.",
    )
    if n:
        messages.success(
            request,
            f"Selection finalised — {n} non-selected applicant"
            f"{'' if n == 1 else 's'} notified of their outcome.",
        )
    else:
        messages.info(request, "No applications were awaiting a decision.")
    return redirect("smehub_showcasing:event_applications", pk=event.pk)


@require_POST
def confirm_challenge_participants_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    challenge = get_object_or_404(InnovationChallenge, pk=pk)
    form = ConfirmParticipantsForm(request.POST)
    if not form.is_valid():
        messages.error(request, "; ".join(err for errs in form.errors.values() for err in errs))
        return redirect("smehub_showcasing:challenge_applications", pk=challenge.pk)
    confirmed = services.confirm_participants(
        target=challenge,
        application_ids=form.cleaned_data["application_ids"],
        by_user=request.user,
        note=form.cleaned_data.get("note", ""),
    )
    messages.success(request, f"Confirmed {len(confirmed)} entrant(s).")
    return redirect("smehub_showcasing:challenge_applications", pk=challenge.pk)


@require_POST
def reject_application_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    application = get_object_or_404(ShowcaseApplication, pk=pk)
    form = RejectApplicationForm(request.POST)
    note = form.cleaned_data.get("note", "") if form.is_valid() else ""
    try:
        services.reject_application(application, by_user=request.user, note=note)
        messages.success(request, "Application rejected.")
    except Exception as exc:  # noqa: BLE001
        messages.error(request, str(exc))
    return redirect(request.POST.get("next") or "smehub_showcasing:event_list")


@require_POST
def withdraw_application_view(request, pk: int):
    application = get_object_or_404(ShowcaseApplication, pk=pk)
    profile = _entrepreneur_profile(request.user)
    if profile is None or application.entrepreneur_id != profile.pk:
        raise PermissionDenied
    services.withdraw_application(application, by_user=request.user)
    messages.success(request, "Application withdrawn.")
    return redirect("smehub_showcasing:my_applications")


# ---------------------------------------------------------------------------
# Catalogue
# ---------------------------------------------------------------------------

class InnovationCatalogueView(ListView):
    template_name = "smehub/showcasing/innovation_catalogue.html"
    context_object_name = "entries"
    paginate_by = 12

    def get_queryset(self):
        qs = InnovationCatalogueEntry.objects.filter(is_active=True).select_related(
            "entrepreneur__user", "business",
        )
        if self.request.user.is_authenticated and _is_sme_admin(self.request.user):
            return qs.order_by("-published_at")
        if self.request.user.is_authenticated and self.request.user.role in STAKEHOLDER_ROLES:
            qs = qs.filter(
                visibility__in=[
                    InnovationCatalogueEntry.Visibility.PUBLIC,
                    InnovationCatalogueEntry.Visibility.STAKEHOLDER,
                ],
            )
        else:
            qs = qs.filter(visibility=InnovationCatalogueEntry.Visibility.PUBLIC)
        sector = self.request.GET.get("sector")
        if sector:
            qs = qs.filter(sectors__icontains=sector)
        return qs.order_by("-published_at")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["sector_filter"] = self.request.GET.get("sector", "")
        ctx["is_admin"] = self.request.user.is_authenticated and _is_sme_admin(self.request.user)
        return ctx


class InnovationDetailView(DetailView):
    model = InnovationCatalogueEntry
    template_name = "smehub/showcasing/innovation_detail.html"
    context_object_name = "entry"

    def get_queryset(self):
        qs = super().get_queryset().filter(is_active=True)
        if not self.request.user.is_authenticated or not _is_sme_admin(self.request.user):
            qs = qs.filter(visibility=InnovationCatalogueEntry.Visibility.PUBLIC)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        entry: InnovationCatalogueEntry = ctx["entry"]
        ctx["versions"] = list(entry.versions.all()[:10])
        ctx["is_admin"] = self.request.user.is_authenticated and _is_sme_admin(self.request.user)
        ctx["can_edit"] = (
            self.request.user.is_authenticated
            and (ctx["is_admin"] or entry.entrepreneur.user_id == self.request.user.pk)
        )
        return ctx


class PublishToCatalogueView(SMEAdminRequired, View):
    template_name = "smehub/showcasing/catalogue_publish_form.html"

    def get(self, request, pk: int):
        application = get_object_or_404(ShowcaseApplication, pk=pk)
        if application.status != ShowcaseApplication.Status.CONFIRMED:
            messages.error(request, "Only confirmed applications can be published.")
            return redirect("smehub_showcasing:event_applications", pk=application.target_object_id)
        form = PublishToCatalogueForm(initial={
            "title": application.innovation_title,
            "summary": application.solution_summary,
            "description": application.innovation_description,
        })
        return render(request, self.template_name, {"application": application, "form": form})

    def post(self, request, pk: int):
        application = get_object_or_404(ShowcaseApplication, pk=pk)
        form = PublishToCatalogueForm(request.POST, request.FILES)
        if not form.is_valid():
            return render(request, self.template_name, {"application": application, "form": form})
        try:
            entry = services.publish_to_catalogue(
                application,
                by_user=request.user,
                title=form.cleaned_data["title"],
                summary=form.cleaned_data.get("summary", ""),
                description=form.cleaned_data.get("description", ""),
                sectors=form.cleaned_data.get("sectors") or [],
                cover_image=form.cleaned_data.get("cover_image"),
                pitch_deck=form.cleaned_data.get("pitch_deck"),
                video_link=form.cleaned_data.get("video_link", ""),
                website=form.cleaned_data.get("website", ""),
                visibility=form.cleaned_data.get("visibility") or InnovationCatalogueEntry.Visibility.STAKEHOLDER,
            )
        except services.ShowcasingStateError as exc:
            form.add_error(None, str(exc))
            return render(request, self.template_name, {"application": application, "form": form})
        messages.success(request, "Innovation published to the catalogue.")
        return redirect("smehub_showcasing:innovation_detail", pk=entry.pk)


class UpdateCatalogueView(LoginRequiredMixin, View):
    template_name = "smehub/showcasing/catalogue_update_form.html"

    def _entry_for(self, request, pk):
        entry = get_object_or_404(InnovationCatalogueEntry, pk=pk)
        if not (_is_sme_admin(request.user) or entry.entrepreneur.user_id == request.user.pk):
            raise PermissionDenied
        return entry

    def get(self, request, pk: int):
        entry = self._entry_for(request, pk)
        form = CatalogueUpdateForm(instance=entry)
        return render(request, self.template_name, {"entry": entry, "form": form})

    def post(self, request, pk: int):
        entry = self._entry_for(request, pk)
        form = CatalogueUpdateForm(request.POST, request.FILES, instance=entry)
        if not form.is_valid():
            return render(request, self.template_name, {"entry": entry, "form": form})
        services.update_catalogue_entry(
            entry,
            by_user=request.user,
            notes=form.cleaned_data.get("notes", ""),
            title=form.cleaned_data.get("title"),
            summary=form.cleaned_data.get("summary"),
            description=form.cleaned_data.get("description"),
            sectors=form.cleaned_data.get("sectors") or None,
            cover_image=form.cleaned_data.get("cover_image"),
            pitch_deck=form.cleaned_data.get("pitch_deck"),
            video_link=form.cleaned_data.get("video_link"),
            website=form.cleaned_data.get("website"),
            visibility=form.cleaned_data.get("visibility"),
        )
        messages.success(request, "Innovation updated.")
        return redirect("smehub_showcasing:innovation_detail", pk=entry.pk)


# ---------------------------------------------------------------------------
# Attendance + scoring
# ---------------------------------------------------------------------------

class EventAttendanceView(SMEAdminRequired, View):
    template_name = "smehub/showcasing/attendance_form.html"

    def get(self, request, pk: int):
        event = get_object_or_404(ShowcaseEvent, pk=pk)
        ct = ContentType.objects.get_for_model(ShowcaseEvent)
        confirmed = ShowcaseApplication.objects.filter(
            target_content_type=ct,
            target_object_id=str(event.pk),
            status=ShowcaseApplication.Status.CONFIRMED,
        ).select_related("entrepreneur__user", "business")
        attendance_lookup = {
            a.participant_id: a for a in EventAttendance.objects.filter(event=event)
        }
        rows = []
        for app in confirmed:
            user_id = app.entrepreneur.user_id
            rows.append({
                "application": app,
                "user_id": user_id,
                "attendance": attendance_lookup.get(user_id),
            })
        # NOTE: intentionally NOT paginated — the check-in form POSTs the full
        # roster at once (Present rows via "attended", absent rows by omission),
        # so every confirmed participant must stay in the DOM.
        return render(request, self.template_name, {"event": event, "rows": rows})

    def post(self, request, pk: int):
        event = get_object_or_404(ShowcaseEvent, pk=pk)
        ct = ContentType.objects.get_for_model(ShowcaseEvent)
        confirmed_apps = list(
            ShowcaseApplication.objects.filter(
                target_content_type=ct,
                target_object_id=str(event.pk),
                status=ShowcaseApplication.Status.CONFIRMED,
            ).select_related("entrepreneur__user")
        )
        attended_ids = set(request.POST.getlist("attended"))
        for app in confirmed_apps:
            user = app.entrepreneur.user
            services.record_attendance(
                event=event,
                participant=user,
                application=app,
                attended=str(user.pk) in attended_ids,
                by_user=request.user,
            )
        messages.success(request, "Attendance saved.")
        return redirect("smehub_showcasing:event_attendance", pk=event.pk)


class PitchScoringView(JudgeRequired, View):
    template_name = "smehub/showcasing/pitch_scoring.html"

    def _application(self, pk):
        return get_object_or_404(ShowcaseApplication, pk=pk)

    def _is_assigned(self, application, user) -> bool:
        """FRSME-ISD008 — judge must be on the target's evaluation panel.

        Audit fix: previously challenges accepted any user with the JUDGE
        role. Both ShowcaseEvent and InnovationChallenge now expose
        ``evaluation_panel``, so the gate is consistent across both target
        types.
        """
        target = application.target
        if target is None:
            return False
        return target.evaluation_panel.filter(pk=user.pk).exists()

    def _not_assigned_redirect(self, request, application):
        """Guidance instead of a bare 403 when the judge isn't on the panel."""
        target = application.target
        messages.warning(
            request,
            "This pitch belongs to "
            f"“{target or 'an event that no longer exists'}”, and you're not on its "
            "evaluation panel. Ask the organiser or an SME-Hub administrator to add "
            "you — your assigned pitches appear in the judging queue on your dashboard.",
        )
        if target is None:
            return redirect("smehub_showcasing:hub")
        if application.is_challenge_application:
            return redirect("smehub_showcasing:challenge_detail", pk=target.pk)
        return redirect("smehub_showcasing:event_detail", pk=target.pk)

    def get(self, request, pk: int):
        application = self._application(pk)
        if not self._is_assigned(application, request.user):
            return self._not_assigned_redirect(request, application)
        sections = []
        if application.is_challenge_application:
            challenge = application.target
            rubric = getattr(challenge, "rubric", None)
            if rubric is not None:
                sections = list(rubric.sections.select_related("category").all())
        existing = {
            (ps.section_id): ps for ps in
            PitchScore.objects.filter(application=application, judge=request.user)
        }
        # Group scored criteria by their rubric category so judges see the ESG
        # structure (category banner → its criteria). Ungrouped criteria and
        # the event "overall" row fall into a category=None bucket rendered flat.
        score_groups: list[dict] = []
        groups_by_cat: dict[int | None, dict] = {}

        def _bucket(category):
            key = category.pk if category is not None else None
            group = groups_by_cat.get(key)
            if group is None:
                group = {"category": category, "rows": []}
                groups_by_cat[key] = group
                score_groups.append(group)
            return group

        if sections:
            for section in sections:
                instance = existing.get(section.pk)
                form = PitchScoreForm(instance=instance, max_marks=section.max_marks)
                _bucket(section.category)["rows"].append((section, form))
        else:
            instance = existing.get(None)
            _bucket(None)["rows"].append((None, PitchScoreForm(instance=instance)))
        return render(
            request,
            self.template_name,
            {
                "application": application,
                "score_groups": score_groups,
                "computed_score": services.compute_pitch_ranking(application),
            },
        )

    def post(self, request, pk: int):
        application = self._application(pk)
        if not self._is_assigned(application, request.user):
            return self._not_assigned_redirect(request, application)
        if application.is_challenge_application:
            challenge = application.target
            rubric = getattr(challenge, "rubric", None)
            sections = list(rubric.sections.all()) if rubric else []
            for section in sections:
                key = f"section_{section.pk}"
                score_raw = request.POST.get(f"{key}_score", "").strip()
                comments = request.POST.get(f"{key}_comments", "")
                if not score_raw:
                    continue
                services.submit_pitch_score(
                    application=application,
                    judge=request.user,
                    section=section,
                    score=Decimal(score_raw),
                    comments=comments,
                )
        else:
            score_raw = request.POST.get("section_overall_score", "").strip()
            if score_raw:
                services.submit_pitch_score(
                    application=application,
                    judge=request.user,
                    section=None,
                    score=Decimal(score_raw),
                    comments=request.POST.get("section_overall_comments", ""),
                )
        messages.success(request, "Scores saved.")
        return redirect("smehub_showcasing:pitch_scoring", pk=application.pk)


# ---------------------------------------------------------------------------
# Deal rooms
# ---------------------------------------------------------------------------

class MyDealRoomsView(LoginRequiredMixin, TemplateView):
    template_name = "smehub/showcasing/my_deal_rooms.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        user = self.request.user
        rooms = DealRoom.objects.select_related("entrepreneur__user", "business").order_by("-last_activity_at")
        if _is_sme_admin(user):
            pass
        else:
            profile = _entrepreneur_profile(user)
            user_filter = Q(entrepreneur=profile) if profile else Q()
            cp_match = Q()
            for kind, ct in _counterparty_content_types().items():
                cp_match |= Q(
                    counterparty_content_type=ct,
                    counterparty_object_id__in=_user_owned_counterparty_ids(user, kind),
                )
            rooms = rooms.filter(user_filter | cp_match) if (profile or cp_match) else rooms.none()
        ctx["stat_counts"] = {
            "open": rooms.filter(status=DealRoom.Status.OPEN).count(),
            "agreement_reached": rooms.filter(status=DealRoom.Status.AGREEMENT_REACHED).count(),
            "expired": rooms.filter(status=DealRoom.Status.EXPIRED).count(),
            "messages_this_week": DealRoomMessage.objects.filter(
                deal_room__in=rooms,
                created_at__gte=timezone.now() - timezone.timedelta(days=7),
            ).count(),
        }
        ctx["rooms"] = paginate_qs(self.request, rooms, param="room_page")
        ctx["is_admin"] = _is_sme_admin(user)
        return ctx


def _counterparty_content_types() -> dict[str, ContentType]:
    from apps.smehub.linkage.models import (
        PartnerDirectoryEntry,
        ServiceProviderEntry,
    )
    from apps.smehub.marketplace.models import BuyerRegistryEntry

    return {
        "partner": ContentType.objects.get_for_model(PartnerDirectoryEntry),
        "service_provider": ContentType.objects.get_for_model(ServiceProviderEntry),
        "buyer": ContentType.objects.get_for_model(BuyerRegistryEntry),
    }


def _user_owned_counterparty_ids(user, kind: str) -> list[str]:
    from apps.smehub.linkage.models import (
        PartnerDirectoryEntry,
        ServiceProviderEntry,
    )
    from apps.smehub.marketplace.models import BuyerRegistryEntry

    if kind == "partner":
        return [str(pk) for pk in PartnerDirectoryEntry.objects.filter(managed_by=user).values_list("pk", flat=True)]
    if kind == "service_provider":
        return [str(pk) for pk in ServiceProviderEntry.objects.filter(managed_by=user).values_list("pk", flat=True)]
    if kind == "buyer":
        return [str(pk) for pk in BuyerRegistryEntry.objects.filter(user=user).values_list("pk", flat=True)]
    return []


class DealRoomDetailView(LoginRequiredMixin, View):
    template_name = "smehub/showcasing/deal_room_detail.html"

    def get(self, request, pk: int):
        from django.utils import timezone as _tz

        room = get_object_or_404(DealRoom, pk=pk)
        if not _user_can_access_deal_room(request.user, room):
            raise PermissionDenied
        messages_qs = DealRoomMessage.objects.filter(deal_room=room).select_related("author").order_by("created_at")
        documents = DealRoomDocument.objects.filter(deal_room=room).order_by("-uploaded_at")
        message_form = DealRoomMessageForm()
        document_form = DealRoomDocumentForm()
        agreement = getattr(room, "agreement", None)
        days_until_expiry = None
        if room.expiry_date and room.status == DealRoom.Status.OPEN:
            delta = room.expiry_date - _tz.now()
            days_until_expiry = max(0, delta.days)
        return render(
            request,
            self.template_name,
            {
                "room": room,
                "messages_thread": list(messages_qs),
                "documents": documents,
                "message_form": message_form,
                "document_form": document_form,
                "is_admin": _is_sme_admin(request.user),
                "agreement": agreement,
                "can_close": _is_sme_admin(request.user),
                "close_form": CloseDealRoomForm(),
                "formalise_form": FormaliseAgreementForm(),
                "days_until_expiry": days_until_expiry,
            },
        )


@require_POST
def deal_room_message_view(request, pk: int):
    room = get_object_or_404(DealRoom, pk=pk)
    if not _user_can_access_deal_room(request.user, room):
        raise PermissionDenied
    form = DealRoomMessageForm(request.POST, request.FILES)
    if not form.is_valid():
        if request.headers.get("HX-Request"):
            return render(
                request,
                "smehub/showcasing/partials/deal_room_message_form.html",
                {"room": room, "message_form": form},
                status=422,
            )
        messages.error(request, "Message could not be saved.")
        return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)
    parent_id = form.cleaned_data.get("parent_id")
    parent = DealRoomMessage.objects.filter(pk=parent_id, deal_room=room).first() if parent_id else None
    try:
        message = services.post_deal_room_message(
            room=room,
            author=request.user,
            body=form.cleaned_data["body"],
            parent=parent,
            attachment=form.cleaned_data.get("attachment"),
        )
    except (services.ShowcasingStateError, ValidationError) as exc:
        if request.headers.get("HX-Request"):
            form.add_error(None, str(exc))
            return render(
                request,
                "smehub/showcasing/partials/deal_room_message_form.html",
                {"room": room, "message_form": form},
                status=422,
            )
        messages.error(request, str(exc))
        return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)
    if request.headers.get("HX-Request"):
        return render(
            request,
            "smehub/showcasing/partials/deal_room_message.html",
            {"room": room, "message": message, "is_admin": _is_sme_admin(request.user)},
        )
    return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)


@require_POST
def deal_room_document_view(request, pk: int):
    room = get_object_or_404(DealRoom, pk=pk)
    if not _user_can_access_deal_room(request.user, room):
        raise PermissionDenied
    form = DealRoomDocumentForm(request.POST, request.FILES)
    if not form.is_valid():
        messages.error(request, "Document could not be saved.")
        return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)
    try:
        document = services.upload_deal_room_document(
            room=room,
            label=form.cleaned_data["label"],
            file=form.cleaned_data["file"],
            kind=form.cleaned_data.get("kind") or DealRoomDocument.Kind.OTHER,
            notes=form.cleaned_data.get("notes", ""),
            uploaded_by=request.user,
        )
    except services.ShowcasingStateError as exc:
        messages.error(request, str(exc))
        return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)
    messages.success(request, f"{document.label} uploaded (v{document.version}).")
    return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)


@require_POST
def deal_room_close_view(request, pk: int):
    room = get_object_or_404(DealRoom, pk=pk)
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    form = CloseDealRoomForm(request.POST)
    reason = form.cleaned_data.get("reason", "") if form.is_valid() else ""
    services.close_deal_room(room, by_user=request.user, reason=reason)
    messages.success(request, "Deal room closed.")
    return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)


class DealRoomOpenView(SMEAdminRequired, View):
    template_name = "smehub/showcasing/deal_room_open_form.html"

    def _resolve_entrepreneur(self, request):
        ent_id = request.POST.get("entrepreneur") or request.GET.get("entrepreneur")
        if not ent_id:
            return None
        return EntrepreneurProfile.objects.filter(pk=ent_id).first()

    def get(self, request):
        form = OpenDealRoomForm()
        return render(request, self.template_name, {"form": form, "entrepreneur": None})

    def post(self, request):
        profile = self._resolve_entrepreneur(request)
        form = OpenDealRoomForm(request.POST, entrepreneur=profile)
        if not form.is_valid():
            messages.error(request, "Fix the highlighted fields.")
            return render(request, self.template_name, {"form": form, "entrepreneur": profile})
        profile = form.cleaned_data["entrepreneur"]
        cp_payload = form.cleaned_data["counterparty"]
        try:
            counterparty = _resolve_counterparty(cp_payload["kind"], cp_payload["pk"])
        except Http404:
            messages.error(request, "Counterparty not found.")
            return render(request, self.template_name, {"form": form, "entrepreneur": profile})
        try:
            room = services.open_deal_room(
                entrepreneur=profile,
                business=form.cleaned_data["business"],
                counterparty=counterparty,
                title=form.cleaned_data.get("title", ""),
                summary=form.cleaned_data.get("summary", ""),
                expiry_date=form.cleaned_data.get("expiry_date"),
                created_by=request.user,
            )
        except services.ShowcasingStateError as exc:
            messages.error(request, str(exc))
            return render(request, self.template_name, {"form": form, "entrepreneur": profile})
        messages.success(request, f"Deal room {room.room_id} opened.")
        return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)


@require_POST
def formalise_agreement_view(request, pk: int):
    room = get_object_or_404(DealRoom, pk=pk)
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    form = FormaliseAgreementForm(request.POST, request.FILES)
    if not form.is_valid():
        errs = "; ".join(e for errors in form.errors.values() for e in errors)
        messages.error(request, errs or "Agreement form invalid.")
        return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)
    try:
        services.formalise_agreement(
            room=room,
            title=form.cleaned_data["title"],
            summary=form.cleaned_data.get("summary", ""),
            signed_file=form.cleaned_data["signed_file"],
            notes=form.cleaned_data.get("notes", ""),
            by_admin=request.user,
        )
    except (ValidationError, services.ShowcasingStateError) as exc:
        messages.error(request, str(exc))
        return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)
    messages.success(request, "Agreement formalised.")
    return redirect("smehub_showcasing:deal_room_detail", pk=room.pk)


# ---------------------------------------------------------------------------
# Personal dashboards
# ---------------------------------------------------------------------------

class MyApplicationsView(EntrepreneurRequired, TemplateView):
    template_name = "smehub/showcasing/my_applications.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        profile = _entrepreneur_profile(self.request.user)
        ctx["entrepreneur"] = profile
        applications = []
        if profile:
            qs = (
                ShowcaseApplication.objects.filter(entrepreneur=profile)
                .select_related("target_content_type", "business")
                .order_by("-created_at")
            )
            event_ct = ContentType.objects.get_for_model(ShowcaseEvent)
            challenge_ct = ContentType.objects.get_for_model(InnovationChallenge)
            event_ids = [a.target_object_id for a in qs if a.target_content_type_id == event_ct.pk]
            challenge_ids = [a.target_object_id for a in qs if a.target_content_type_id == challenge_ct.pk]
            events = {str(e.pk): e for e in ShowcaseEvent.objects.filter(pk__in=event_ids)}
            challenges = {str(c.pk): c for c in InnovationChallenge.objects.filter(pk__in=challenge_ids)}
            for app in qs:
                if app.target_content_type_id == event_ct.pk:
                    app.target_kind = "event"
                    app.target_obj = events.get(app.target_object_id)
                else:
                    app.target_kind = "challenge"
                    app.target_obj = challenges.get(app.target_object_id)
                applications.append(app)
        ctx["stat_counts"] = {
            "submitted": sum(1 for a in applications if a.status == ShowcaseApplication.Status.SUBMITTED),
            "under_review": sum(1 for a in applications if a.status == ShowcaseApplication.Status.UNDER_REVIEW),
            "confirmed": sum(1 for a in applications if a.status == ShowcaseApplication.Status.CONFIRMED),
            "rejected": sum(1 for a in applications if a.status == ShowcaseApplication.Status.REJECTED),
        }
        ctx["applications"] = paginate_qs(self.request, applications, param="app_page")
        return ctx


# ---------------------------------------------------------------------------
# Admin dashboard
# ---------------------------------------------------------------------------

class ShowcasingAdminDashboardView(SMEAdminRequired, TemplateView):
    template_name = "smehub/showcasing/admin_dashboard.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["event_counts"] = {
            "draft": ShowcaseEvent.objects.filter(status=ShowcaseEvent.Status.DRAFT).count(),
            "published": ShowcaseEvent.objects.filter(status=ShowcaseEvent.Status.PUBLISHED).count(),
            "live": ShowcaseEvent.objects.filter(status=ShowcaseEvent.Status.LIVE).count(),
            "concluded": ShowcaseEvent.objects.filter(status=ShowcaseEvent.Status.CONCLUDED).count(),
        }
        ctx["challenge_counts"] = {
            "open": InnovationChallenge.objects.filter(status=InnovationChallenge.Status.OPEN).count(),
            "judging": InnovationChallenge.objects.filter(status=InnovationChallenge.Status.JUDGING).count(),
            "announced": InnovationChallenge.objects.filter(status=InnovationChallenge.Status.ANNOUNCED).count(),
        }
        ctx["deal_room_counts"] = {
            "open": DealRoom.objects.filter(status=DealRoom.Status.OPEN).count(),
            "agreement": DealRoom.objects.filter(status=DealRoom.Status.AGREEMENT_REACHED).count(),
            "expired": DealRoom.objects.filter(status=DealRoom.Status.EXPIRED).count(),
            "closed": DealRoom.objects.filter(status=DealRoom.Status.CLOSED).count(),
        }
        ctx["catalogue_count"] = InnovationCatalogueEntry.objects.filter(is_active=True).count()
        ctx["latest_events"] = ShowcaseEvent.objects.order_by("-created_at")[:5]
        ctx["latest_challenges"] = InnovationChallenge.objects.order_by("-created_at")[:5]
        ctx["latest_rooms"] = DealRoom.objects.order_by("-last_activity_at")[:5]
        return ctx
