"""SP3 linkage views — directories, connections, MoUs, advisory bookings."""
from __future__ import annotations

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 import IntegrityError
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import Http404, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
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,
    PartnerRequired,
    ServiceProviderRequired,
    SMEAdminRequired,
)
from apps.smehub.linkage import services
from apps.smehub.linkage.ai_matchmaking import (
    recommend_partners,
    refresh_recommendations,
)
from apps.smehub.linkage.forms import (
    AdvisorySessionCancelForm,
    AdvisorySessionCompleteForm,
    AdvisorySessionForm,
    AdvisorySlotForm,
    ConnectionDeclineForm,
    ConnectionRequestForm,
    DirectoryRejectForm,
    MoUAmendForm,
    MoUCommentForm,
    MoUDraftForm,
    MoUFormaliseForm,
    OrganisationRecommendationForm,
    PartnerDirectoryEntryForm,
    RegulatoryGuideForm,
    ServiceProviderEntryForm,
)
from apps.smehub.linkage.models import (
    AdvisorySession,
    AdvisorySlot,
    Connection,
    ConnectionRequest,
    MoU,
    MoUPartyConfirmation,
    OrganisationRecommendation,
    PartnerDirectoryEntry,
    RecommendationSnapshot,
    RegulatoryGuide,
    ServiceProviderEntry,
)
from apps.smehub.onboarding.models import EntrepreneurProfile


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


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 _get_entrepreneur_profile(user) -> EntrepreneurProfile | None:
    if not user.is_authenticated:
        return None
    return EntrepreneurProfile.objects.filter(user=user).first()


def _resolve_target(kind: str, pk: int):
    # Buyer support (MPL007) — BuyerRegistryEntry is in the marketplace app,
    # imported lazily to avoid a cross-module cycle at module load.
    if kind == "buyer":
        from apps.smehub.marketplace.models import BuyerRegistryEntry

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


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

class LinkageNetworkHubView(LoginRequiredMixin, TemplateView):
    """Hub re-homing Partners / Providers / Regulatory / Connections / Recommendations."""

    template_name = "smehub/linkage/hub.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        user = self.request.user
        ctx["is_admin"] = _is_sme_admin(user)
        ctx["entrepreneur"] = _get_entrepreneur_profile(user) if user.is_authenticated else None
        ctx["partner_count"] = PartnerDirectoryEntry.objects.filter(
            verification_status=PartnerDirectoryEntry.Status.VERIFIED,
        ).count()
        ctx["provider_count"] = ServiceProviderEntry.objects.filter(
            verification_status=ServiceProviderEntry.Status.VERIFIED,
        ).count()
        ctx["regulatory_count"] = RegulatoryGuide.objects.filter(is_active=True).count()
        ctx["is_partner_managed"] = PartnerDirectoryEntry.objects.filter(managed_by=user).exists()
        ctx["is_provider_managed"] = ServiceProviderEntry.objects.filter(managed_by=user).exists()
        return ctx


# ---------------------------------------------------------------------------
# Directories — public
# ---------------------------------------------------------------------------

def _relevance_ordering(qs, q: str):
    """FRSME-MPL004 — order a directory queryset by keyword relevance.

    A name hit outranks a description-only hit, which outranks the rest;
    ties break alphabetically. DB-agnostic (no pg_trgm dependency) so it
    works identically across the three directories.
    """
    if not q:
        return qs.order_by("organisation_name")
    return qs.annotate(
        _relevance=Case(
            When(organisation_name__icontains=q, then=Value(2)),
            When(description__icontains=q, then=Value(1)),
            default=Value(0),
            output_field=IntegerField(),
        ),
    ).order_by("-_relevance", "organisation_name")


class _DirectoryListMixin:
    paginate_by = 20

    def get_queryset(self):
        qs = self.model.objects.filter(verification_status=self.model.Status.VERIFIED)
        q = (self.request.GET.get("q") or "").strip()
        if q:
            qs = qs.filter(Q(organisation_name__icontains=q) | Q(description__icontains=q))
        sector = self.request.GET.get("sector")
        if sector:
            qs = qs.filter(sector_focus__contains=[sector])
        country = self.request.GET.get("country")
        if country:
            qs = qs.filter(geographic_coverage__contains=[country])
        org_type = self.request.GET.get("org_type")
        if org_type:
            qs = qs.filter(org_type=org_type)
        # FRSME-MPL004 — relevance-rank keyword results instead of alpha-only.
        return _relevance_ordering(qs, q)

    def get_context_data(self, **kwargs):
        # FRSME-MPL004 — surface the sector / country / org-type facets the
        # queryset already supports as filter controls.
        from apps.smehub._shared.choices import COUNTRIES, SECTORS

        ctx = super().get_context_data(**kwargs)
        ctx["sector_choices"] = SECTORS
        ctx["country_choices"] = COUNTRIES
        ctx["org_type_choices"] = self.model.OrgType.choices
        ctx["sector_selected"] = self.request.GET.get("sector", "")
        ctx["country_selected"] = self.request.GET.get("country", "")
        ctx["org_type_selected"] = self.request.GET.get("org_type", "")
        return ctx


class PartnerDirectoryView(LoginRequiredMixin, _DirectoryListMixin, ListView):
    model = PartnerDirectoryEntry
    template_name = "smehub/linkage/partner_directory.html"
    context_object_name = "entries"

    def get_template_names(self):
        if self.request.htmx:
            return ["smehub/linkage/partials/partner_directory_results.html"]
        return [self.template_name]


class PartnerDetailView(LoginRequiredMixin, DetailView):
    model = PartnerDirectoryEntry
    template_name = "smehub/linkage/partner_detail.html"
    context_object_name = "entry"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["entry_kind"] = "partner"
        return ctx


class ServiceProviderListView(LoginRequiredMixin, _DirectoryListMixin, ListView):
    model = ServiceProviderEntry
    template_name = "smehub/linkage/service_provider_list.html"
    context_object_name = "entries"

    def get_queryset(self):
        qs = super().get_queryset()
        # FRSME-MPL004 — filter by the service offering the SP provides.
        service_offering = self.request.GET.get("service_offering")
        if service_offering:
            qs = qs.filter(service_offerings__contains=[service_offering])
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["service_offering_choices"] = ServiceProviderEntry.ServiceOffering.choices
        ctx["service_offering_selected"] = self.request.GET.get("service_offering", "")
        return ctx


class ServiceProviderDetailView(LoginRequiredMixin, DetailView):
    model = ServiceProviderEntry
    template_name = "smehub/linkage/service_provider_detail.html"
    context_object_name = "entry"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["entry_kind"] = "service_provider"
        return ctx


class DirectorySearchView(LoginRequiredMixin, TemplateView):
    """FRSME-MPL004 — one keyword search spanning all three directories
    (Partner, Service Provider, Buyer), each relevance-ranked."""

    template_name = "smehub/linkage/directory_search.html"

    def _search(self, model, status, q):
        qs = model.objects.filter(verification_status=status).filter(
            Q(organisation_name__icontains=q) | Q(description__icontains=q),
        )
        return list(_relevance_ordering(qs, q)[:25])

    def get_context_data(self, **kwargs):
        from apps.smehub.marketplace.models import BuyerRegistryEntry

        ctx = super().get_context_data(**kwargs)
        q = (self.request.GET.get("q") or "").strip()
        ctx["q"] = q
        if q:
            ctx["partners"] = self._search(
                PartnerDirectoryEntry, PartnerDirectoryEntry.Status.VERIFIED, q,
            )
            ctx["providers"] = self._search(
                ServiceProviderEntry, ServiceProviderEntry.Status.VERIFIED, q,
            )
            ctx["buyers"] = self._search(
                BuyerRegistryEntry, BuyerRegistryEntry.Status.VERIFIED, q,
            )
            ctx["total"] = len(ctx["partners"]) + len(ctx["providers"]) + len(ctx["buyers"])
        return ctx


# ---------------------------------------------------------------------------
# Self-managed directory entries
# ---------------------------------------------------------------------------

class _SelfManagedDirectoryMixin:
    """Restrict edits to the row's `managed_by` user (or SME admins)."""

    def dispatch(self, request, *args, **kwargs):
        response = super().dispatch(request, *args, **kwargs)
        return response

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        if obj.managed_by_id and obj.managed_by_id != self.request.user.pk and not _is_sme_admin(self.request.user):
            raise PermissionDenied("You do not manage this directory entry.")
        return obj


class PartnerEntryCreateView(PartnerRequired, CreateView):
    model = PartnerDirectoryEntry
    form_class = PartnerDirectoryEntryForm
    template_name = "smehub/linkage/partner_entry_form.html"

    def form_valid(self, form):
        form.instance.managed_by = self.request.user
        self.object = services.create_directory_entry(
            kind="partner",
            managed_by=self.request.user,
            created_by=self.request.user,
            **form.cleaned_data,
        )
        messages.success(self.request, "Your partner profile has been submitted for verification.")
        return redirect(self.get_success_url())

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


class PartnerEntryUpdateView(PartnerRequired, _SelfManagedDirectoryMixin, UpdateView):
    model = PartnerDirectoryEntry
    form_class = PartnerDirectoryEntryForm
    template_name = "smehub/linkage/partner_entry_form.html"

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


class ServiceProviderCreateView(ServiceProviderRequired, CreateView):
    model = ServiceProviderEntry
    form_class = ServiceProviderEntryForm
    template_name = "smehub/linkage/service_provider_form.html"

    def form_valid(self, form):
        self.object = services.create_directory_entry(
            kind="service_provider",
            managed_by=self.request.user,
            created_by=self.request.user,
            **form.cleaned_data,
        )
        messages.success(self.request, "Your service provider profile has been submitted for verification.")
        return redirect(self.get_success_url())

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


class ServiceProviderUpdateView(ServiceProviderRequired, _SelfManagedDirectoryMixin, UpdateView):
    model = ServiceProviderEntry
    form_class = ServiceProviderEntryForm
    template_name = "smehub/linkage/service_provider_form.html"

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


# ---------------------------------------------------------------------------
# Admin directory management
# ---------------------------------------------------------------------------

class LinkageAdminDashboardView(SMEAdminRequired, TemplateView):
    """FRSME use case Table 39 — admin linkage dashboard with aggregate metrics."""
    template_name = "smehub/linkage/admin_dashboard.html"

    def get_context_data(self, **kwargs):
        from collections import Counter
        from datetime import timedelta

        from django.db.models import Count
        from django.utils import timezone

        from apps.smehub.marketplace.models import (
            BuyerRegistryEntry,
            DemandResponse,
            MarketDemandListing,
        )

        ctx = super().get_context_data(**kwargs)
        now = timezone.now()
        thirty_days_ago = now - timedelta(days=30)

        # Connection counts
        ctx["connection_counts"] = {
            "pending": ConnectionRequest.objects.filter(status=ConnectionRequest.Status.PENDING).count(),
            "accepted": ConnectionRequest.objects.filter(status=ConnectionRequest.Status.ACCEPTED).count(),
            "declined": ConnectionRequest.objects.filter(status=ConnectionRequest.Status.DECLINED).count(),
            "active": Connection.objects.filter(status=Connection.Status.ACTIVE).count(),
            "last_30d": ConnectionRequest.objects.filter(created_at__gte=thirty_days_ago).count(),
        }

        # MoU counts by status
        ctx["mou_counts"] = {
            "draft": MoU.objects.filter(status=MoU.Status.DRAFT).count(),
            "under_review": MoU.objects.filter(status=MoU.Status.UNDER_REVIEW).count(),
            "formalised": MoU.objects.filter(status=MoU.Status.FORMALISED).count(),
            "amended": MoU.objects.filter(status=MoU.Status.AMENDED).count(),
            "total": MoU.objects.count(),
        }

        # Advisory counts
        ctx["advisory_counts"] = {
            "scheduled": AdvisorySession.objects.filter(status=AdvisorySession.Status.SCHEDULED).count(),
            "completed": AdvisorySession.objects.filter(status=AdvisorySession.Status.COMPLETED).count(),
            "cancelled": AdvisorySession.objects.filter(status=AdvisorySession.Status.CANCELLED).count(),
        }

        # Marketplace counts
        ctx["marketplace_counts"] = {
            "open_demand": MarketDemandListing.objects.filter(status=MarketDemandListing.Status.OPEN).count(),
            "responses": DemandResponse.objects.count(),
            "verified_buyers": BuyerRegistryEntry.objects.filter(verification_status=BuyerRegistryEntry.Status.VERIFIED).count(),
        }

        # Top sectors across verified directory entries
        sector_counter: Counter = Counter()
        for e in PartnerDirectoryEntry.objects.filter(verification_status=PartnerDirectoryEntry.Status.VERIFIED).values_list("sector_focus", flat=True):
            sector_counter.update(e or [])
        for e in ServiceProviderEntry.objects.filter(verification_status=ServiceProviderEntry.Status.VERIFIED).values_list("sector_focus", flat=True):
            sector_counter.update(e or [])
        ctx["top_sectors"] = sector_counter.most_common(8)

        # Top countries across verified directory entries
        geo_counter: Counter = Counter()
        for e in PartnerDirectoryEntry.objects.filter(verification_status=PartnerDirectoryEntry.Status.VERIFIED).values_list("geographic_coverage", flat=True):
            geo_counter.update(e or [])
        for e in ServiceProviderEntry.objects.filter(verification_status=ServiceProviderEntry.Status.VERIFIED).values_list("geographic_coverage", flat=True):
            geo_counter.update(e or [])
        ctx["top_countries"] = geo_counter.most_common(8)

        # Recent activity — last 10 accepted connections
        ctx["recent_accepted"] = (
            ConnectionRequest.objects.filter(status=ConnectionRequest.Status.ACCEPTED)
            .select_related("entrepreneur__user", "target_content_type")
            .order_by("-decided_at")[:10]
        )

        # Recent formalised MoUs
        ctx["recent_mous"] = (
            MoU.objects.filter(status=MoU.Status.FORMALISED)
            .select_related("connection__entrepreneur__user")
            .order_by("-formalised_at")[:5]
        )

        # Pending suggestions count
        ctx["pending_suggestions"] = OrganisationRecommendation.objects.filter(
            status=OrganisationRecommendation.Status.SUBMITTED,
        ).count()

        return ctx


class AdminDirectoryListView(SMEAdminRequired, TemplateView):
    template_name = "smehub/linkage/admin_directory_management.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["partners_pending"] = paginate_qs(
            self.request,
            PartnerDirectoryEntry.objects.filter(
                verification_status=PartnerDirectoryEntry.Status.PENDING,
            ),
            param="partners_pending_page",
        )
        ctx["partners_verified"] = paginate_qs(
            self.request,
            PartnerDirectoryEntry.objects.filter(
                verification_status=PartnerDirectoryEntry.Status.VERIFIED,
            ),
            param="partners_verified_page",
        )
        ctx["partners_deactivated"] = paginate_qs(
            self.request,
            PartnerDirectoryEntry.objects.filter(
                verification_status=PartnerDirectoryEntry.Status.DEACTIVATED,
            ),
            param="partners_deactivated_page",
        )
        ctx["providers_pending"] = paginate_qs(
            self.request,
            ServiceProviderEntry.objects.filter(
                verification_status=ServiceProviderEntry.Status.PENDING,
            ),
            param="providers_pending_page",
        )
        ctx["providers_verified"] = paginate_qs(
            self.request,
            ServiceProviderEntry.objects.filter(
                verification_status=ServiceProviderEntry.Status.VERIFIED,
            ),
            param="providers_verified_page",
        )
        ctx["providers_deactivated"] = paginate_qs(
            self.request,
            ServiceProviderEntry.objects.filter(
                verification_status=ServiceProviderEntry.Status.DEACTIVATED,
            ),
            param="providers_deactivated_page",
        )
        return ctx


@require_POST
def directory_verify_view(request, kind: str, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied()
    entry = _resolve_target(kind, pk)
    services.verify_directory_entry(entry, by_user=request.user)
    messages.success(request, f"{entry.organisation_name} verified.")
    return redirect("smehub_linkage:admin_directory_management")


@require_POST
def directory_reject_view(request, kind: str, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied()
    entry = _resolve_target(kind, pk)
    form = DirectoryRejectForm(request.POST)
    if not form.is_valid():
        messages.error(request, "A rejection reason is required.")
    else:
        services.reject_directory_entry(entry, by_user=request.user, reason=form.cleaned_data["reason"])
        messages.success(request, f"{entry.organisation_name} rejected.")
    return redirect("smehub_linkage:admin_directory_management")


@require_POST
def directory_deactivate_view(request, kind: str, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied()
    entry = _resolve_target(kind, pk)
    reason = request.POST.get("reason", "")
    try:
        services.deactivate_directory_entry(entry, by_user=request.user, reason=reason)
    except Exception as exc:  # FSM transition failure
        messages.error(request, f"Cannot deactivate: {exc}")
    else:
        messages.success(request, f"{entry.organisation_name} deactivated.")
    return redirect("smehub_linkage:admin_directory_management")


@require_POST
def directory_reactivate_view(request, kind: str, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied()
    entry = _resolve_target(kind, pk)
    try:
        services.reactivate_directory_entry(entry, by_user=request.user)
    except Exception as exc:
        messages.error(request, f"Cannot reactivate: {exc}")
    else:
        messages.success(request, f"{entry.organisation_name} reactivated.")
    return redirect("smehub_linkage:admin_directory_management")


# ---------------------------------------------------------------------------
# Connections
# ---------------------------------------------------------------------------

class RequestConnectionView(EntrepreneurRequired, View):
    template_name = "smehub/linkage/connection_request_form.html"

    def get(self, request, kind: str, pk: int):
        target = _resolve_target(kind, pk)
        profile = _get_entrepreneur_profile(request.user)
        form = ConnectionRequestForm(entrepreneur=profile)
        return render(request, self.template_name, {"target": target, "form": form, "entry_kind": kind})

    def post(self, request, kind: str, pk: int):
        target = _resolve_target(kind, pk)
        profile = _get_entrepreneur_profile(request.user)
        if profile is None:
            raise PermissionDenied("Entrepreneur profile required.")
        # FRSME-MPL007 / MEI004 — a directory target must be VERIFIED before
        # entrepreneurs can request a connection. This applies uniformly to
        # PartnerDirectoryEntry, ServiceProviderEntry, and BuyerRegistryEntry.
        target_status = getattr(target, "verification_status", None)
        verified_value = getattr(getattr(target, "Status", None), "VERIFIED", "verified")
        if target_status is not None and target_status != verified_value:
            messages.error(
                request,
                f"{getattr(target, 'organisation_name', 'This organisation')} is not "
                "yet verified. You can connect once an administrator approves them.",
            )
            return redirect("smehub_linkage:my_connections")
        form = ConnectionRequestForm(request.POST, entrepreneur=profile)
        if not form.is_valid():
            return render(request, self.template_name, {"target": target, "form": form, "entry_kind": kind})
        cr = services.request_connection(
            entrepreneur=profile,
            target=target,
            message=form.cleaned_data["message"],
            business=form.cleaned_data.get("business"),
        )
        if getattr(cr, "was_already_active", False):
            messages.info(
                request,
                f"You already have an active request to {target}. We've taken you to your connections.",
            )
        else:
            messages.success(request, "Connection request sent.")
        return redirect("smehub_linkage:my_connections")


class MyConnectionsView(LoginRequiredMixin, TemplateView):
    template_name = "smehub/linkage/my_connections.html"

    def get_context_data(self, **kwargs):
        from apps.smehub.marketplace.models import BuyerRegistryEntry

        ctx = super().get_context_data(**kwargs)
        user = self.request.user
        profile = _get_entrepreneur_profile(user)
        ctx["profile"] = profile
        if profile is not None:
            sent_requests = ConnectionRequest.objects.filter(entrepreneur=profile).select_related(
                "target_content_type", "decided_by",
            )
            my_connections = Connection.objects.filter(entrepreneur=profile)
        else:
            sent_requests = ConnectionRequest.objects.none()
            my_connections = Connection.objects.none()

        # Requests targeted *at* this user as a directory manager / buyer:
        partner_ct = ContentType.objects.get_for_model(PartnerDirectoryEntry)
        provider_ct = ContentType.objects.get_for_model(ServiceProviderEntry)
        buyer_ct = ContentType.objects.get_for_model(BuyerRegistryEntry)
        partner_ids = list(PartnerDirectoryEntry.objects.filter(managed_by=user).values_list("pk", flat=True))
        provider_ids = list(ServiceProviderEntry.objects.filter(managed_by=user).values_list("pk", flat=True))
        buyer_ids = list(BuyerRegistryEntry.objects.filter(user=user).values_list("pk", flat=True))
        incoming = ConnectionRequest.objects.filter(
            Q(target_content_type=partner_ct, target_object_id__in=[str(i) for i in partner_ids])
            | Q(target_content_type=provider_ct, target_object_id__in=[str(i) for i in provider_ids])
            | Q(target_content_type=buyer_ct, target_object_id__in=[str(i) for i in buyer_ids]),
        ).select_related("entrepreneur__user", "target_content_type")

        # FRSME-MPL010 — tabbed layout. Default to whichever tab actually has
        # rows so the page isn't blank for a fresh user.
        gp = self.request.GET
        valid_tabs = ("active", "incoming", "sent")
        requested_tab = gp.get("tab", "")
        if requested_tab in valid_tabs:
            active_tab = requested_tab
        elif my_connections.exists():
            active_tab = "active"
        elif incoming.exists():
            active_tab = "incoming"
        else:
            active_tab = "sent"
        ctx["active_tab"] = active_tab
        ctx["tab_counts"] = {
            "active": my_connections.count() if profile else 0,
            "incoming": incoming.count(),
            "sent": sent_requests.count() if profile else 0,
        }
        ctx["sent_requests"] = paginate_qs(self.request, sent_requests, param="sent_page")
        ctx["my_connections"] = paginate_qs(self.request, my_connections, param="connections_page")
        ctx["incoming_requests"] = paginate_qs(self.request, incoming, param="incoming_page")
        return ctx


class ConnectionRequestDecisionView(LoginRequiredMixin, View):
    template_name = "smehub/linkage/connection_request_decision.html"

    def _can_decide(self, user, cr: ConnectionRequest) -> bool:
        if _is_sme_admin(user):
            return True
        target = cr.target
        # Partner / ServiceProvider use ``managed_by_id``;
        # BuyerRegistryEntry exposes ownership via ``user_id``.
        owner_id = getattr(target, "managed_by_id", None) or getattr(target, "user_id", None)
        return owner_id == user.pk

    def get(self, request, pk: int):
        cr = get_object_or_404(ConnectionRequest, pk=pk)
        if not self._can_decide(request.user, cr):
            raise PermissionDenied()
        return render(request, self.template_name, {"cr": cr, "form": ConnectionDeclineForm()})

    def post(self, request, pk: int):
        cr = get_object_or_404(ConnectionRequest, pk=pk)
        if not self._can_decide(request.user, cr):
            raise PermissionDenied()
        action = request.POST.get("action")
        if action == "accept":
            services.accept_connection(cr, by_user=request.user)
            messages.success(request, "Connection accepted.")
        elif action == "decline":
            form = ConnectionDeclineForm(request.POST)
            reason = form.cleaned_data["reason"] if form.is_valid() else ""
            services.decline_connection(cr, by_user=request.user, reason=reason)
            messages.success(request, "Connection declined.")
        else:
            messages.error(request, "Pick accept or decline.")
        return redirect("smehub_linkage:my_connections")


@require_POST
def connection_request_withdraw_view(request, pk: int):
    cr = get_object_or_404(ConnectionRequest, pk=pk)
    try:
        services.withdraw_connection_request(cr, by_user=request.user)
    except services.LinkageStateError as exc:
        messages.error(request, str(exc))
    else:
        messages.success(request, "Connection request withdrawn.")
    return redirect("smehub_linkage:my_connections")


# ---------------------------------------------------------------------------
# AI matchmaking
# ---------------------------------------------------------------------------

class AIRecommendationsView(EntrepreneurRequired, TemplateView):
    template_name = "smehub/linkage/ai_recommendations.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        profile = _get_entrepreneur_profile(self.request.user)
        ctx["profile"] = profile
        if profile is not None:
            snapshot = RecommendationSnapshot.objects.filter(entrepreneur=profile).first()
            if snapshot is None:
                snapshot = refresh_recommendations(profile, trigger="page_view")
            ctx["snapshot"] = snapshot
            # Hydrate recommendation entries for the template
            from apps.smehub.marketplace.models import BuyerRegistryEntry
            partner_ids = [r["id"] for r in snapshot.recommendations if r.get("kind") == "partner"]
            provider_ids = [r["id"] for r in snapshot.recommendations if r.get("kind") == "service_provider"]
            buyer_ids = [r["id"] for r in snapshot.recommendations if r.get("kind") == "buyer"]
            partners = {e.pk: e for e in PartnerDirectoryEntry.objects.filter(pk__in=partner_ids)}
            providers = {e.pk: e for e in ServiceProviderEntry.objects.filter(pk__in=provider_ids)}
            buyers = {e.pk: e for e in BuyerRegistryEntry.objects.filter(pk__in=buyer_ids)}
            by_kind = {"partner": partners, "service_provider": providers, "buyer": buyers}
            hydrated: list[dict] = []
            for r in snapshot.recommendations:
                entry = by_kind.get(r.get("kind"), {}).get(r["id"])
                if entry is None:
                    continue
                # Buyers store sectors on `sectors_sourced`; partners/providers
                # on `sector_focus`. Resolve here so the template stays simple.
                sectors = getattr(entry, "sector_focus", None) or getattr(entry, "sectors_sourced", None) or []
                hydrated.append({"recommendation": r, "entry": entry, "sectors": sectors})
            ctx["hydrated"] = hydrated
        else:
            ctx["snapshot"] = None
            ctx["hydrated"] = []
        ctx["live_preview"] = recommend_partners(profile) if profile else []
        return ctx


@require_POST
def refresh_recommendations_view(request):
    profile = _get_entrepreneur_profile(request.user)
    if profile is None:
        raise PermissionDenied()
    refresh_recommendations(profile, trigger="manual_refresh")
    messages.success(request, "Recommendations refreshed.")
    return redirect("smehub_linkage:ai_recommendations")


# ---------------------------------------------------------------------------
# MoU workspace
# ---------------------------------------------------------------------------

class MoUDraftView(LoginRequiredMixin, View):
    """FRSME-MPL011 — administrator OR the connection's entrepreneur OR the
    target organisation manager may initiate an MoU.
    """
    template_name = "smehub/linkage/mou_draft_form.html"

    def _get_connection(self, request, connection_pk):
        connection = get_object_or_404(Connection, pk=connection_pk)
        if _is_sme_admin(request.user):
            return connection
        if connection.entrepreneur.user_id == request.user.pk:
            return connection
        target = connection.target
        if getattr(target, "managed_by_id", None) == request.user.pk:
            return connection
        raise PermissionDenied("You don't have permission to draft an MoU for this connection.")

    def _existing_mou(self, connection):
        # A connection should have one live MoU thread at a time. Amendments
        # extend the thread (services.amend_mou creates a new row and flips
        # the parent to AMENDED), so the newest non-terminated row is always
        # the current head of that thread.
        return connection.mous.exclude(status=MoU.Status.TERMINATED).order_by("-created_at").first()

    def get(self, request, connection_pk: int):
        connection = self._get_connection(request, connection_pk)
        existing = self._existing_mou(connection)
        if existing is not None:
            messages.info(request, "This connection already has an MoU in progress — opening it.")
            return redirect("smehub_linkage:mou_workspace", pk=existing.pk)
        return render(request, self.template_name, {"connection": connection, "form": MoUDraftForm()})

    def post(self, request, connection_pk: int):
        connection = self._get_connection(request, connection_pk)
        existing = self._existing_mou(connection)
        if existing is not None:
            messages.info(request, "This connection already has an MoU in progress — opening it.")
            return redirect("smehub_linkage:mou_workspace", pk=existing.pk)
        form = MoUDraftForm(request.POST, request.FILES)
        if not form.is_valid():
            return render(request, self.template_name, {"connection": connection, "form": form})
        mou = services.init_mou(
            connection=connection,
            title=form.cleaned_data["title"],
            summary=form.cleaned_data.get("summary", ""),
            draft_file=form.cleaned_data.get("draft_file"),
            by_user=request.user,
        )
        messages.success(request, "MoU draft created.")
        return redirect("smehub_linkage:mou_workspace", pk=mou.pk)


class MoUWorkspaceView(LoginRequiredMixin, DetailView):
    model = MoU
    template_name = "smehub/linkage/mou_workspace.html"
    context_object_name = "mou"

    def _can_view(self, user, mou: MoU) -> bool:
        if _is_sme_admin(user):
            return True
        if mou.connection.entrepreneur.user_id == user.pk:
            return True
        target = mou.connection.target
        return getattr(target, "managed_by_id", None) == user.pk

    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        if not self._can_view(request.user, self.object):
            raise PermissionDenied()
        ctx = self.get_context_data(object=self.object)
        return self.render_to_response(ctx)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        mou = ctx["mou"]
        ctx["versions"] = mou.versions.all()
        ctx["comments"] = mou.comments.filter(parent__isnull=True).select_related("author")
        ctx["comment_form"] = MoUCommentForm()
        ctx["formalise_form"] = MoUFormaliseForm()
        ctx["amend_form"] = MoUAmendForm()

        confirmations = {c.party: c for c in mou.party_confirmations.select_related("confirmed_by")}
        entrepreneur_row = confirmations.get(MoUPartyConfirmation.Party.ENTREPRENEUR.value)
        counterparty_row = confirmations.get(MoUPartyConfirmation.Party.COUNTERPARTY.value)
        ctx["entrepreneur_confirmation"] = entrepreneur_row
        ctx["counterparty_confirmation"] = counterparty_row
        ctx["both_parties_confirmed"] = bool(entrepreneur_row and counterparty_row)

        user = self.request.user
        target = mou.connection.target
        is_admin = _is_sme_admin(user)
        is_entrepreneur = mou.connection.entrepreneur.user_id == user.pk
        is_target_owner = getattr(target, "managed_by_id", None) == user.pk
        ctx["can_confirm_as_entrepreneur"] = (is_entrepreneur or is_admin) and entrepreneur_row is None
        ctx["can_confirm_as_counterparty"] = (is_target_owner or is_admin) and counterparty_row is None
        # Mirrors the gate in mou_submit_review_view — only the connection's
        # entrepreneur or an SME admin may send a draft for review, so hide
        # the button from the counterparty instead of 403-ing on click.
        ctx["can_submit_review"] = is_entrepreneur or is_admin
        return ctx


class MoUConfirmPartyView(LoginRequiredMixin, View):
    """FRSME-MPL013 — UI hook for `confirm_mou_party` so MoUs can actually
    progress to formalisation in production.
    """

    def post(self, request, pk: int, party: str):
        mou = get_object_or_404(MoU, pk=pk)
        target = mou.connection.target
        is_admin = _is_sme_admin(request.user)
        is_entrepreneur = mou.connection.entrepreneur.user_id == request.user.pk
        is_target_owner = getattr(target, "managed_by_id", None) == request.user.pk

        if party == MoUPartyConfirmation.Party.ENTREPRENEUR.value:
            authorised = is_admin or is_entrepreneur
        elif party == MoUPartyConfirmation.Party.COUNTERPARTY.value:
            authorised = is_admin or is_target_owner
        else:
            raise Http404("Unknown MoU party.")

        if not authorised:
            raise PermissionDenied()

        try:
            services.confirm_mou_party(mou, party=party, by_user=request.user)
        except services.LinkageStateError as exc:
            messages.error(request, str(exc))
        else:
            messages.success(request, f"{party.replace('_', ' ').title()} confirmation recorded.")
        return redirect("smehub_linkage:mou_workspace", pk=mou.pk)


@require_POST
def mou_comment_view(request, pk: int):
    mou = get_object_or_404(MoU, pk=pk)
    form = MoUCommentForm(request.POST)
    if not form.is_valid():
        return HttpResponseBadRequest("Invalid comment.")
    services.comment_on_mou(
        mou=mou,
        author=request.user,
        body=form.cleaned_data["body"],
        parent_id=form.cleaned_data.get("parent_id"),
    )
    return redirect("smehub_linkage:mou_workspace", pk=mou.pk)


@require_POST
def mou_submit_review_view(request, pk: int):
    mou = get_object_or_404(MoU, pk=pk)
    if mou.connection.entrepreneur.user_id != request.user.pk and not _is_sme_admin(request.user):
        raise PermissionDenied()
    try:
        services.submit_mou_for_review(mou, by_user=request.user)
    except services.LinkageStateError as exc:
        messages.error(request, str(exc))
    else:
        messages.success(request, "MoU sent for review.")
    return redirect("smehub_linkage:mou_workspace", pk=mou.pk)


class MoUFormaliseView(LoginRequiredMixin, View):
    def post(self, request, pk: int):
        mou = get_object_or_404(MoU, pk=pk)
        target = mou.connection.target
        is_owner = getattr(target, "managed_by_id", None) == request.user.pk
        if not (_is_sme_admin(request.user) or is_owner):
            raise PermissionDenied()
        form = MoUFormaliseForm(request.POST, request.FILES)
        if not form.is_valid():
            messages.error(request, "Upload the signed MoU file.")
            return redirect("smehub_linkage:mou_workspace", pk=mou.pk)
        try:
            services.formalise_mou(
                mou,
                signed_file=form.cleaned_data["signed_file"],
                by_user=request.user,
            )
        except (services.LinkageStateError, ValidationError) as exc:
            messages.error(request, str(exc))
        else:
            messages.success(request, "MoU formalised.")
        return redirect("smehub_linkage:mou_workspace", pk=mou.pk)


class MoUAmendView(LoginRequiredMixin, View):
    def post(self, request, pk: int):
        parent_mou = get_object_or_404(MoU, pk=pk)
        # Slice B.3e — PRD A5 says either party can amend. Previously only the
        # entrepreneur or an admin was permitted; counterparty (partner /
        # service provider managing the directory entry) is now also allowed.
        is_entrepreneur = parent_mou.connection.entrepreneur.user_id == request.user.pk
        target = parent_mou.connection.target
        is_counterparty = getattr(target, "managed_by_id", None) == request.user.pk
        if not (is_entrepreneur or is_counterparty or _is_sme_admin(request.user)):
            raise PermissionDenied()
        form = MoUAmendForm(request.POST, request.FILES)
        if not form.is_valid():
            messages.error(request, "Provide a title and optional draft for the amendment.")
            return redirect("smehub_linkage:mou_workspace", pk=parent_mou.pk)
        try:
            new_mou = services.amend_mou(
                parent_mou,
                title=form.cleaned_data["title"],
                summary=form.cleaned_data.get("summary", ""),
                draft_file=form.cleaned_data.get("draft_file"),
                by_user=request.user,
            )
        except services.LinkageStateError as exc:
            messages.error(request, str(exc))
            return redirect("smehub_linkage:mou_workspace", pk=parent_mou.pk)
        return redirect("smehub_linkage:mou_workspace", pk=new_mou.pk)


# ---------------------------------------------------------------------------
# Advisory bookings
# ---------------------------------------------------------------------------

class AdvisoryBookingView(EntrepreneurRequired, View):
    template_name = "smehub/linkage/advisory_booking.html"

    def _has_connection(self, profile, provider) -> bool:
        ct = ContentType.objects.get_for_model(ServiceProviderEntry)
        return Connection.objects.filter(
            entrepreneur=profile,
            target_content_type=ct,
            target_object_id=str(provider.pk),
            status=Connection.Status.ACTIVE,
        ).exists()

    def _slot_count(self, provider) -> int:
        return provider.advisory_slots.filter(
            session__isnull=True, starts_at__gte=timezone.now(),
        ).count()

    def get(self, request, pk: int):
        provider = get_object_or_404(ServiceProviderEntry, pk=pk)
        profile = _get_entrepreneur_profile(request.user)
        connected = profile and self._has_connection(profile, provider)
        return render(request, self.template_name, {
            "provider": provider,
            "form": AdvisorySessionForm(service_provider=provider),
            "connected": connected,
            "open_slot_count": self._slot_count(provider),
        })

    def post(self, request, pk: int):
        provider = get_object_or_404(ServiceProviderEntry, pk=pk)
        form = AdvisorySessionForm(request.POST, service_provider=provider)
        profile = _get_entrepreneur_profile(request.user)
        if profile is None:
            raise PermissionDenied()
        connected = self._has_connection(profile, provider)
        if not form.is_valid():
            return render(request, self.template_name, {
                "provider": provider, "form": form, "connected": connected,
                "open_slot_count": self._slot_count(provider),
            })
        try:
            session = services.book_advisory_session(
                entrepreneur=profile,
                service_provider=provider,
                booked_by=request.user,
                **form.cleaned_data,
            )
        except services.LinkageStateError as exc:
            messages.error(request, str(exc))
            return render(request, self.template_name, {
                "provider": provider, "form": form, "connected": connected,
                "open_slot_count": self._slot_count(provider),
            })
        messages.success(request, "Advisory session booked.")
        return redirect("smehub_linkage:advisory_sessions")


class AdvisorySlotManageView(LoginRequiredMixin, View):
    """FRSME-MPL017 — a service provider publishes / retires bookable advisory
    slots for a directory entry they manage. Ownership (or SME-admin) is
    enforced per-object in ``_get_provider`` rather than by a blanket role
    gate, so admins can also help manage availability."""

    template_name = "smehub/linkage/advisory_slots.html"

    def _get_provider(self, request, pk):
        provider = get_object_or_404(ServiceProviderEntry, pk=pk)
        if provider.managed_by_id != request.user.pk and not _is_sme_admin(request.user):
            raise PermissionDenied("You can only manage availability for your own entries.")
        return provider

    def _ctx(self, provider, form):
        now = timezone.now()
        slots = provider.advisory_slots.select_related("session").order_by("starts_at")
        return {
            "provider": provider,
            "form": form,
            "open_slots": [s for s in slots if s.session_id is None and s.starts_at >= now],
            "booked_slots": [s for s in slots if s.session_id is not None],
            "past_slots": [s for s in slots if s.session_id is None and s.starts_at < now],
        }

    def get(self, request, pk):
        provider = self._get_provider(request, pk)
        return render(request, self.template_name, self._ctx(provider, AdvisorySlotForm()))

    def post(self, request, pk):
        provider = self._get_provider(request, pk)
        form = AdvisorySlotForm(request.POST)
        if not form.is_valid():
            return render(request, self.template_name, self._ctx(provider, form), status=400)
        slot = form.save(commit=False)
        slot.service_provider = provider
        try:
            slot.save()
        except IntegrityError:
            messages.error(request, "A slot already exists at that time.")
            return redirect("smehub_linkage:advisory_slots", pk=provider.pk)
        messages.success(request, "Availability slot published.")
        return redirect("smehub_linkage:advisory_slots", pk=provider.pk)


class AdvisorySlotDeleteView(LoginRequiredMixin, View):
    def post(self, request, pk, slot_pk):
        provider = get_object_or_404(ServiceProviderEntry, pk=pk)
        if provider.managed_by_id != request.user.pk and not _is_sme_admin(request.user):
            raise PermissionDenied()
        slot = get_object_or_404(AdvisorySlot, pk=slot_pk, service_provider=provider)
        if slot.session_id is not None:
            messages.error(request, "That slot is booked — cancel the session first to free it.")
        else:
            slot.delete()
            messages.success(request, "Availability slot removed.")
        return redirect("smehub_linkage:advisory_slots", pk=provider.pk)


class AdvisorySessionListView(LoginRequiredMixin, ListView):
    model = AdvisorySession
    template_name = "smehub/linkage/session_history.html"
    context_object_name = "sessions"
    paginate_by = 20

    def get_queryset(self):
        user = self.request.user
        qs = AdvisorySession.objects.select_related("service_provider", "entrepreneur__user")
        if _is_sme_admin(user):
            return qs
        profile = _get_entrepreneur_profile(user)
        ent_filter = Q(entrepreneur=profile) if profile else Q(pk__lt=0)
        return qs.filter(ent_filter | Q(service_provider__managed_by=user))

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        qs = self.get_queryset()
        ctx["stats"] = {
            "total": qs.count(),
            "scheduled": qs.filter(status=AdvisorySession.Status.SCHEDULED).count(),
            "completed": qs.filter(status=AdvisorySession.Status.COMPLETED).count(),
            "cancelled": qs.filter(status=AdvisorySession.Status.CANCELLED).count(),
        }
        return ctx


class AdvisorySessionCancelView(LoginRequiredMixin, View):
    def post(self, request, pk: int):
        session = get_object_or_404(AdvisorySession, pk=pk)
        if session.entrepreneur.user_id != request.user.pk and session.service_provider.managed_by_id != request.user.pk and not _is_sme_admin(request.user):
            raise PermissionDenied()
        form = AdvisorySessionCancelForm(request.POST)
        reason = form.cleaned_data["reason"] if form.is_valid() else ""
        services.cancel_advisory_session(session, reason=reason, by_user=request.user)
        messages.success(request, "Advisory session cancelled.")
        return redirect("smehub_linkage:advisory_sessions")


class AdvisorySessionCompleteView(LoginRequiredMixin, View):
    def post(self, request, pk: int):
        session = get_object_or_404(AdvisorySession, pk=pk)
        if session.service_provider.managed_by_id != request.user.pk and not _is_sme_admin(request.user):
            raise PermissionDenied("Only the service provider can mark a session complete.")
        form = AdvisorySessionCompleteForm(request.POST)
        notes = form.cleaned_data["notes"] if form.is_valid() else ""
        services.complete_advisory_session(session, notes=notes, by_user=request.user)
        messages.success(request, "Advisory session marked complete.")
        return redirect("smehub_linkage:advisory_sessions")


# ---------------------------------------------------------------------------
# Organisation recommendations
# ---------------------------------------------------------------------------

class SuggestOrganisationView(LoginRequiredMixin, CreateView):
    model = OrganisationRecommendation
    form_class = OrganisationRecommendationForm
    template_name = "smehub/linkage/suggest_organisation.html"
    success_url = reverse_lazy("smehub_linkage:partner_directory")

    def form_valid(self, form):
        self.object = services.suggest_organisation(
            suggested_by=self.request.user,
            **form.cleaned_data,
        )
        messages.success(self.request, "Thanks — your suggestion has been recorded.")
        return redirect(self.success_url)


class OrganisationRecommendationsAdminView(SMEAdminRequired, ListView):
    model = OrganisationRecommendation
    template_name = "smehub/linkage/admin_recommendations.html"
    context_object_name = "recommendations"
    paginate_by = 30


@require_POST
def handle_recommendation_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied()
    rec = get_object_or_404(OrganisationRecommendation, pk=pk)
    decision = request.POST.get("decision")
    note = request.POST.get("note", "")
    try:
        services.handle_organisation_recommendation(rec, by_user=request.user, decision=decision, note=note)
    except services.LinkageStateError as exc:
        messages.error(request, str(exc))
    else:
        messages.success(request, f"Recommendation {decision}.")
    return redirect("smehub_linkage:org_recommendations_admin")


# ---------------------------------------------------------------------------
# Regulatory & Policy Navigation
# ---------------------------------------------------------------------------

class RegulatoryGuideListView(SMEAdminRequired, ListView):
    """Admin list view — RegulatoryGuide CRUD landing."""

    model = RegulatoryGuide
    template_name = "smehub/linkage/regulatory_admin_list.html"
    context_object_name = "guides"
    paginate_by = 25

    def get_queryset(self):
        qs = RegulatoryGuide.objects.all()
        body_type = self.request.GET.get("body_type", "").strip()
        if body_type:
            qs = qs.filter(body_type=body_type)
        active = self.request.GET.get("active", "").strip()
        if active == "yes":
            qs = qs.filter(is_active=True)
        elif active == "no":
            qs = qs.filter(is_active=False)
        q = self.request.GET.get("q", "").strip()
        if q:
            qs = qs.filter(title__icontains=q)
        return qs.order_by("title")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["body_type_choices"] = RegulatoryGuide.BodyType.choices
        ctx["filter_body_type"] = self.request.GET.get("body_type", "")
        ctx["filter_active"] = self.request.GET.get("active", "")
        ctx["filter_q"] = self.request.GET.get("q", "")
        ctx["total_active"] = RegulatoryGuide.objects.filter(is_active=True).count()
        ctx["total_inactive"] = RegulatoryGuide.objects.filter(is_active=False).count()
        return ctx


class RegulatoryGuideCreateView(SMEAdminRequired, CreateView):
    model = RegulatoryGuide
    form_class = RegulatoryGuideForm
    template_name = "smehub/linkage/regulatory_form.html"
    success_url = reverse_lazy("smehub_linkage:regulatory_admin_list")

    def form_valid(self, form):
        form.instance.created_by = self.request.user
        messages.success(self.request, f"Regulatory guide '{form.cleaned_data['title']}' created.")
        return super().form_valid(form)


class RegulatoryGuideUpdateView(SMEAdminRequired, UpdateView):
    model = RegulatoryGuide
    form_class = RegulatoryGuideForm
    template_name = "smehub/linkage/regulatory_form.html"
    success_url = reverse_lazy("smehub_linkage:regulatory_admin_list")

    def form_valid(self, form):
        messages.success(self.request, f"Regulatory guide '{form.cleaned_data['title']}' updated.")
        return super().form_valid(form)


@require_POST
def regulatory_guide_toggle_active_view(request, pk: int):
    if not _is_sme_admin(request.user):
        raise PermissionDenied
    guide = get_object_or_404(RegulatoryGuide, pk=pk)
    guide.is_active = not guide.is_active
    guide.save(update_fields=["is_active", "updated_at"])
    state = "activated" if guide.is_active else "deactivated"
    messages.success(request, f"'{guide.title}' {state}.")
    return redirect("smehub_linkage:regulatory_admin_list")


class EntrepreneurRegulatoryBrowseView(EntrepreneurRequired, TemplateView):
    """Entrepreneur-facing browse — regulatory guides matching their primary
    verified business's sector + country, grouped by body type."""

    template_name = "smehub/linkage/regulatory_browse.html"

    def get_context_data(self, **kwargs):
        from apps.smehub.onboarding.models import Business

        ctx = super().get_context_data(**kwargs)
        profile = _get_entrepreneur_profile(self.request.user)
        primary = None
        if profile is not None:
            primary = (
                Business.objects.filter(
                    entrepreneur=profile,
                    verification_status=Business.Status.VERIFIED,
                )
                .order_by("-created_at")
                .first()
            )
        ctx["business"] = primary
        guides = list(services.relevant_regulatory_guides(primary)) if primary else []
        # Group by body_type for the template.
        grouped: dict = {}
        for g in guides:
            grouped.setdefault(g.body_type, []).append(g)
        ctx["grouped_guides"] = [
            {
                "label": dict(RegulatoryGuide.BodyType.choices).get(bt, bt),
                "items": items,
            }
            for bt, items in grouped.items()
        ]
        ctx["total"] = len(guides)
        return ctx


# ---------------------------------------------------------------------------
# Persona workspaces — service provider + partner
# ---------------------------------------------------------------------------

class ServiceProviderDashboardView(ServiceProviderRequired, LoginRequiredMixin, TemplateView):
    template_name = "smehub/linkage/service_provider_dashboard.html"

    def get_context_data(self, **kwargs):
        from django.utils import timezone

        ctx = super().get_context_data(**kwargs)
        user = self.request.user
        entries = list(
            ServiceProviderEntry.objects.filter(managed_by=user).order_by("organisation_name")
        )
        ctx["entries"] = entries

        if not entries:
            return ctx

        sessions = AdvisorySession.objects.filter(service_provider__managed_by=user)
        now = timezone.now()
        month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)

        ctx["stats"] = {
            "scheduled": sessions.filter(status=AdvisorySession.Status.SCHEDULED).count(),
            "this_month": sessions.filter(starts_at__gte=month_start).count(),
            "completed": sessions.filter(status=AdvisorySession.Status.COMPLETED).count(),
            "cancelled": sessions.filter(status=AdvisorySession.Status.CANCELLED).count(),
        }
        ctx["upcoming_sessions"] = (
            sessions.filter(status=AdvisorySession.Status.SCHEDULED, starts_at__gte=now)
            .select_related("entrepreneur__user", "service_provider")
            .order_by("starts_at")[:5]
        )
        ctx["recent_completed"] = (
            sessions.filter(status=AdvisorySession.Status.COMPLETED)
            .select_related("entrepreneur__user", "service_provider")
            .order_by("-completed_at")[:5]
        )

        # Recent connections — accepted ConnectionRequests targeting any of
        # this user's SP entries. Polymorphic GFK so resolve via ContentType.
        sp_ct = ContentType.objects.get_for_model(ServiceProviderEntry)
        entry_ids = [str(e.pk) for e in entries]
        ctx["recent_connections"] = (
            ConnectionRequest.objects.filter(
                target_content_type=sp_ct,
                target_object_id__in=entry_ids,
                status=ConnectionRequest.Status.ACCEPTED,
            )
            .select_related("entrepreneur__user")
            .order_by("-decided_at")[:5]
        )
        ctx["pending_connections_count"] = ConnectionRequest.objects.filter(
            target_content_type=sp_ct,
            target_object_id__in=entry_ids,
            status=ConnectionRequest.Status.PENDING,
        ).count()

        return ctx


class PartnerDashboardView(PartnerRequired, LoginRequiredMixin, TemplateView):
    template_name = "smehub/linkage/partner_dashboard.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        user = self.request.user
        entries = list(
            PartnerDirectoryEntry.objects.filter(managed_by=user).order_by("organisation_name")
        )
        ctx["entries"] = entries

        if not entries:
            return ctx

        partner_ct = ContentType.objects.get_for_model(PartnerDirectoryEntry)
        entry_ids = [str(e.pk) for e in entries]

        all_requests = ConnectionRequest.objects.filter(
            target_content_type=partner_ct,
            target_object_id__in=entry_ids,
        )
        pending = all_requests.filter(status=ConnectionRequest.Status.PENDING)
        accepted = all_requests.filter(status=ConnectionRequest.Status.ACCEPTED)

        # MoUs linked via Connection → ConnectionRequest targeting these entries.
        mous = MoU.objects.filter(
            connection__request__target_content_type=partner_ct,
            connection__request__target_object_id__in=entry_ids,
        )
        formalised = mous.filter(status=MoU.Status.FORMALISED)
        awaiting_partner = mous.filter(
            status__in=[MoU.Status.DRAFT, MoU.Status.UNDER_REVIEW, MoU.Status.AMENDED],
        )

        ctx["stats"] = {
            "pending_requests": pending.count(),
            "active_connections": accepted.count(),
            "mous_awaiting": awaiting_partner.count(),
            "mous_formalised": formalised.count(),
        }
        ctx["pending_requests"] = (
            pending.select_related("entrepreneur__user").order_by("-created_at")[:5]
        )
        ctx["mous_awaiting"] = (
            awaiting_partner.select_related("connection__entrepreneur__user")
            .order_by("-updated_at")[:5]
        )
        ctx["recent_connections"] = (
            accepted.select_related("entrepreneur__user").order_by("-decided_at")[:5]
        )

        return ctx


class PartnerConnectionInboxView(PartnerRequired, LoginRequiredMixin, ListView):
    template_name = "smehub/linkage/partner_connection_inbox.html"
    context_object_name = "requests"
    paginate_by = 30

    def get_queryset(self):
        user = self.request.user
        partner_ct = ContentType.objects.get_for_model(PartnerDirectoryEntry)
        entry_ids = list(
            PartnerDirectoryEntry.objects.filter(managed_by=user).values_list("pk", flat=True)
        )
        if not entry_ids:
            return ConnectionRequest.objects.none()
        qs = (
            ConnectionRequest.objects.filter(
                target_content_type=partner_ct,
                target_object_id__in=[str(pk) for pk in entry_ids],
            )
            .select_related("entrepreneur__user")
            .order_by("status", "-created_at")
        )
        status = self.request.GET.get("status")
        if status:
            qs = qs.filter(status=status)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["selected_status"] = self.request.GET.get("status", "")
        ctx["status_choices"] = ConnectionRequest.Status.choices
        return ctx


class PartnerMoUListView(PartnerRequired, LoginRequiredMixin, ListView):
    template_name = "smehub/linkage/partner_mou_list.html"
    context_object_name = "mous"
    paginate_by = 30

    def get_queryset(self):
        user = self.request.user
        partner_ct = ContentType.objects.get_for_model(PartnerDirectoryEntry)
        entry_ids = list(
            PartnerDirectoryEntry.objects.filter(managed_by=user).values_list("pk", flat=True)
        )
        if not entry_ids:
            return MoU.objects.none()
        return (
            MoU.objects.filter(
                connection__request__target_content_type=partner_ct,
                connection__request__target_object_id__in=[str(pk) for pk in entry_ids],
            )
            .select_related("connection__entrepreneur__user")
            .order_by("-updated_at")
        )
