"""SME-Hub linkage services — Subprocess SP3a.

Implements PRD FRSME-MPL001 → MPL013, MPL017 → MPL019. Side-effects
(notifications, audit log, signal emission) live here, never on the view
layer (matches the SP1/SP2 convention).
"""
from __future__ import annotations

from typing import Iterable

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

from apps.core.countries import to_country_name
from apps.core.notifications.models import Notification
from apps.smehub._shared.audit import AuditLog, _audit
from apps.smehub._shared.notifications import bulk_notify_smehub, notify_smehub
from apps.smehub.linkage import signals as link_signals
from apps.smehub.linkage.models import (
    AdvisorySession,
    AdvisorySlot,
    Connection,
    ConnectionRequest,
    MoU,
    MoUComment,
    MoUPartyConfirmation,
    MoUVersion,
    OrganisationRecommendation,
    PartnerDirectoryEntry,
    ServiceProviderEntry,
)
from apps.smehub.onboarding.models import EntrepreneurProfile

Verb = Notification.Verb


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

class LinkageStateError(Exception):
    """Raised on illegal SP3 transitions / preconditions."""


# ---------------------------------------------------------------------------
# URL helper
# ---------------------------------------------------------------------------

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


# ---------------------------------------------------------------------------
# Directories  (FRSME-MPL001 → MPL004)
# ---------------------------------------------------------------------------

@transaction.atomic
def create_directory_entry(
    *,
    kind: str,
    managed_by=None,
    created_by=None,
    **fields,
):
    """Create a partner or service-provider entry. ``kind`` ∈ {'partner', 'service_provider'}."""
    Model = PartnerDirectoryEntry if kind == "partner" else ServiceProviderEntry
    entry = Model.objects.create(managed_by=managed_by, **fields)
    _audit(created_by or managed_by, AuditLog.Action.CREATE, entry, metadata={"kind": kind})
    return entry


@transaction.atomic
def verify_directory_entry(entry, *, by_user):
    """FRSME-MPL003 — admin verifies a partner / service provider entry."""
    entry.verify(by_user=by_user)
    entry.save()
    _audit(by_user, AuditLog.Action.UPDATE, entry, metadata={"status": "verified"})
    link_signals.smehub_directory_entry_verified.send(
        sender=type(entry), entry=entry, by_user=by_user,
    )
    if entry.managed_by is not None:
        notify_smehub(
            entry.managed_by,
            f"Your directory entry “{entry.organisation_name}” has been verified.",
            verb=Verb.SMEHUB_BUSINESS_VERIFIED,
            target=entry,
        )
    return entry


@transaction.atomic
def reject_directory_entry(entry, *, by_user, reason: str):
    entry.reject(reason=reason)
    entry.save()
    _audit(by_user, AuditLog.Action.UPDATE, entry, metadata={"status": "rejected", "reason": reason})
    if entry.managed_by is not None:
        notify_smehub(
            entry.managed_by,
            f"Your directory entry “{entry.organisation_name}” was rejected: {reason}",
            verb=Verb.SMEHUB_BUSINESS_REJECTED,
            target=entry,
        )
    return entry


@transaction.atomic
def deactivate_directory_entry(entry, *, by_user, reason: str = ""):
    """FRSME-MPL001 — admin deactivates an entry (hides from public directory)."""
    entry.deactivate(reason=reason)
    entry.save()
    _audit(by_user, AuditLog.Action.UPDATE, entry, metadata={"status": "deactivated", "reason": reason})
    if entry.managed_by is not None:
        notify_smehub(
            entry.managed_by,
            f"Your directory entry “{entry.organisation_name}” has been deactivated by an administrator."
            + (f" Reason: {reason}" if reason else ""),
            verb=Verb.SMEHUB_BUSINESS_REVOKED,
            target=entry,
        )
    return entry


@transaction.atomic
def reactivate_directory_entry(entry, *, by_user):
    entry.reactivate(by_user=by_user)
    entry.save()
    _audit(by_user, AuditLog.Action.UPDATE, entry, metadata={"status": "verified", "reactivated": True})
    if entry.managed_by is not None:
        notify_smehub(
            entry.managed_by,
            f"Your directory entry “{entry.organisation_name}” has been reactivated.",
            verb=Verb.SMEHUB_BUSINESS_VERIFIED,
            target=entry,
        )
    return entry


# ---------------------------------------------------------------------------
# Connections  (FRSME-MPL007 → MPL010)
# ---------------------------------------------------------------------------

def _target_owner(target) -> object | None:
    """Return the user we should notify about a connection event."""
    return getattr(target, "managed_by", None) or getattr(target, "user", None)


@transaction.atomic
def request_connection(
    *,
    entrepreneur: EntrepreneurProfile,
    target,
    message: str = "",
    business=None,
) -> ConnectionRequest:
    """FRSME-MPL007 + MPL008.

    MPL007: ``business`` is the verified business this request represents
    (recipients see it on the decision page).

    MPL008: surface any existing pending OR active (accepted) request
    instead of raising, so the UI can redirect the entrepreneur to it.
    """
    if target is None:
        raise LinkageStateError("Connection target is required.")
    ct = ContentType.objects.get_for_model(target.__class__)
    existing = ConnectionRequest.objects.filter(
        entrepreneur=entrepreneur,
        target_content_type=ct,
        target_object_id=str(target.pk),
        status__in=[ConnectionRequest.Status.PENDING, ConnectionRequest.Status.ACCEPTED],
    ).first()
    if existing:
        # Audit fix: ``was_already_active`` is a transient attribute the view
        # layer reads to render an honest "you already have a request to
        # this organisation" message instead of the misleading "Connection
        # request sent" toast that misled entrepreneurs in the legacy flow.
        existing.was_already_active = True
        return existing
    cr = ConnectionRequest.objects.create(
        entrepreneur=entrepreneur,
        business=business,
        target_content_type=ct,
        target_object_id=str(target.pk),
        message=message,
    )
    cr.was_already_active = False
    _audit(entrepreneur.user, AuditLog.Action.CREATE, cr, metadata={"target": str(target)})
    link_signals.smehub_connection_requested.send(sender=ConnectionRequest, connection_request=cr)
    owner = _target_owner(target)
    if owner is not None:
        notify_smehub(
            owner,
            f"New connection request from {entrepreneur.user.get_full_name() or entrepreneur.user.email}.",
            verb=Verb.SMEHUB_CONNECTION_REQUESTED,
            action_url=_safe_reverse("smehub_linkage:connection_request_decision", pk=cr.pk),
            target=cr,
        )
    return cr


@transaction.atomic
def accept_connection(cr: ConnectionRequest, *, by_user) -> Connection:
    if cr.status != ConnectionRequest.Status.PENDING:
        raise LinkageStateError("Only pending requests can be accepted.")
    cr.accept(by_user=by_user)
    cr.save()
    connection, _ = Connection.objects.get_or_create(
        request=cr,
        defaults={
            "entrepreneur": cr.entrepreneur,
            "target_content_type": cr.target_content_type,
            "target_object_id": cr.target_object_id,
        },
    )
    _audit(by_user, AuditLog.Action.UPDATE, cr, metadata={"status": "accepted"})
    link_signals.smehub_connection_accepted.send(
        sender=ConnectionRequest,
        connection_request=cr,
        connection=connection,
        by_user=by_user,
    )
    notify_smehub(
        cr.entrepreneur.user,
        f"Your connection request to {cr.target} was accepted.",
        verb=Verb.SMEHUB_CONNECTION_ACCEPTED,
        action_url=_safe_reverse("smehub_linkage:my_connections"),
        target=connection,
    )
    return connection


@transaction.atomic
def decline_connection(cr: ConnectionRequest, *, by_user, reason: str = "") -> ConnectionRequest:
    if cr.status != ConnectionRequest.Status.PENDING:
        raise LinkageStateError("Only pending requests can be declined.")
    cr.decline(by_user=by_user, reason=reason)
    cr.save()
    _audit(by_user, AuditLog.Action.UPDATE, cr, metadata={"status": "declined", "reason": reason})
    link_signals.smehub_connection_declined.send(
        sender=ConnectionRequest, connection_request=cr, by_user=by_user, reason=reason,
    )
    notify_smehub(
        cr.entrepreneur.user,
        f"Your connection request to {cr.target} was declined."
        + (f" Reason: {reason}" if reason else ""),
        verb=Verb.SMEHUB_CONNECTION_DECLINED,
        target=cr,
    )
    return cr


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


@transaction.atomic
def end_connection(connection: Connection, *, by_user, reason: str = "") -> Connection:
    """Audit fix: ``Connection`` had an ENDED status but no service could
    transition into it, so accepted relationships could only end via raw DB
    edits. ``end_connection`` flips the row, records who/why/when, and
    notifies both parties.
    """
    if connection.status != Connection.Status.ACTIVE:
        raise LinkageStateError("Only active connections can be ended.")
    connection.status = Connection.Status.ENDED
    connection.ended_at = timezone.now()
    connection.ended_reason = reason
    connection.save(update_fields=["status", "ended_at", "ended_reason", "updated_at"])
    _audit(by_user, AuditLog.Action.UPDATE, connection, metadata={"status": "ended", "reason": reason})
    link_signals.smehub_connection_ended.send(
        sender=Connection, connection=connection, by_user=by_user, reason=reason,
    )
    recipients = [connection.entrepreneur.user]
    owner = _target_owner(connection.target)
    if owner and owner not in recipients:
        recipients.append(owner)
    bulk_notify_smehub(
        recipients,
        f"Connection with {connection.target} has ended."
        + (f" Reason: {reason}" if reason else ""),
        verb=Verb.SMEHUB_CONNECTION_DECLINED,
        action_url=_safe_reverse("smehub_linkage:my_connections"),
    )
    return connection


# ---------------------------------------------------------------------------
# MoUs  (FRSME-MPL011 → MPL013, A5)
# ---------------------------------------------------------------------------

def _next_version_number(mou: MoU) -> int:
    last = mou.versions.order_by("-version_number").first()
    return (last.version_number + 1) if last else 1


def _snapshot_mou(mou: MoU, *, by_user, notes: str = "") -> MoUVersion:
    return MoUVersion.objects.create(
        mou=mou,
        version_number=_next_version_number(mou),
        title=mou.title,
        summary=mou.summary,
        file=mou.signed_file or mou.draft_file or None,
        status_at_version=mou.status,
        notes=notes,
        created_by=by_user,
    )


@transaction.atomic
def init_mou(
    *,
    connection: Connection,
    title: str,
    summary: str = "",
    draft_file=None,
    by_user,
) -> MoU:
    mou = MoU.objects.create(
        connection=connection,
        title=title,
        summary=summary,
        draft_file=draft_file,
        created_by=by_user,
    )
    _snapshot_mou(mou, by_user=by_user, notes="Initial draft.")
    _audit(by_user, AuditLog.Action.CREATE, mou)
    link_signals.smehub_mou_drafted.send(sender=MoU, mou=mou, by_user=by_user)
    owner = _target_owner(connection.target)
    if owner is not None:
        notify_smehub(
            owner,
            f"New MoU draft “{mou.title}” awaits your review.",
            verb=Verb.SMEHUB_MOU_DRAFTED,
            action_url=_safe_reverse("smehub_linkage:mou_workspace", pk=mou.pk),
            target=mou,
        )
    notify_smehub(
        connection.entrepreneur.user,
        f"You drafted MoU “{mou.title}” for {connection.target}.",
        verb=Verb.SMEHUB_MOU_DRAFTED,
        action_url=_safe_reverse("smehub_linkage:mou_workspace", pk=mou.pk),
        target=mou,
    )
    return mou


@transaction.atomic
def comment_on_mou(*, mou: MoU, author, body: str, parent_id: int | None = None) -> MoUComment:
    if not body.strip():
        raise ValidationError("Comment body required.")
    parent = None
    if parent_id:
        parent = MoUComment.objects.filter(pk=parent_id, mou=mou).first()
    comment = MoUComment.objects.create(
        mou=mou,
        parent=parent,
        author=author,
        body=body.strip(),
    )
    return comment


@transaction.atomic
def submit_mou_for_review(mou: MoU, *, by_user) -> MoU:
    if mou.status != MoU.Status.DRAFT:
        raise LinkageStateError("Only draft MoUs can be submitted for review.")
    mou.submit_for_review()
    mou.save()
    _snapshot_mou(mou, by_user=by_user, notes="Submitted for review.")
    return mou


@transaction.atomic
def confirm_mou_party(
    mou: MoU,
    *,
    party: str,
    by_user,
    note: str = "",
) -> MoUPartyConfirmation:
    """FRSME-MPL013 audit fix — record one party's acceptance of an MoU.
    ``formalise_mou`` will refuse to formalise until both parties have
    confirmed (entrepreneur + counterparty).

    Idempotent: re-confirming by the same party simply refreshes the
    timestamp; we do not stack rows.
    """
    valid_parties = {p.value for p in MoUPartyConfirmation.Party}
    if party not in valid_parties:
        raise LinkageStateError(f"party must be one of {sorted(valid_parties)}")
    if mou.status not in (MoU.Status.DRAFT, MoU.Status.UNDER_REVIEW):
        raise LinkageStateError("Only draft or under-review MoUs can be confirmed.")
    confirmation, _created = MoUPartyConfirmation.objects.update_or_create(
        mou=mou,
        party=party,
        defaults={
            "confirmed_by": by_user,
            "confirmed_at": timezone.now(),
            "note": note,
        },
    )
    _audit(by_user, AuditLog.Action.UPDATE, mou, metadata={"event": "mou_party_confirmed", "party": party})
    return confirmation


def _both_parties_confirmed(mou: MoU) -> bool:
    """Both required parties must have written a confirmation row."""
    parties = set(
        mou.party_confirmations.values_list("party", flat=True),
    )
    return parties >= {
        MoUPartyConfirmation.Party.ENTREPRENEUR.value,
        MoUPartyConfirmation.Party.COUNTERPARTY.value,
    }


@transaction.atomic
def formalise_mou(mou: MoU, *, signed_file, by_user) -> MoU:
    """FRSME-MPL013 — admin / counterparty uploads the signed file and
    flips the MoU to FORMALISED.

    Phase 6 audit fix: gates formalisation on explicit two-party
    confirmation. Audit found this could happen unilaterally, which conflicts
    with the PRD's "upon confirmation from both parties" wording.
    """
    if mou.status not in (MoU.Status.DRAFT, MoU.Status.UNDER_REVIEW):
        raise LinkageStateError("Only draft or under-review MoUs can be formalised.")
    if signed_file is None:
        raise ValidationError("A signed file is required to formalise an MoU.")
    if not _both_parties_confirmed(mou):
        raise LinkageStateError(
            "Both the entrepreneur and the counterparty must confirm the MoU "
            "before it can be formalised. Call ``confirm_mou_party`` for each."
        )
    mou.signed_file = signed_file
    mou.formalise(by_user=by_user)
    mou.save()
    _snapshot_mou(mou, by_user=by_user, notes="Formalised.")
    _audit(by_user, AuditLog.Action.UPDATE, mou, metadata={"status": "formalised"})
    link_signals.smehub_mou_formalised.send(sender=MoU, mou=mou, by_user=by_user)
    recipients = [mou.connection.entrepreneur.user]
    owner = _target_owner(mou.connection.target)
    if owner and owner not in recipients:
        recipients.append(owner)
    bulk_notify_smehub(
        recipients,
        f"MoU “{mou.title}” has been formalised.",
        verb=Verb.SMEHUB_MOU_FORMALISED,
        action_url=_safe_reverse("smehub_linkage:mou_workspace", pk=mou.pk),
    )
    return mou


@transaction.atomic
def amend_mou(parent_mou: MoU, *, title: str, summary: str = "", draft_file=None, by_user) -> MoU:
    """A5 — create a new MoU row referencing the parent and snapshot the
    parent before flipping it to AMENDED.
    """
    if parent_mou.status not in (MoU.Status.FORMALISED, MoU.Status.AMENDED):
        raise LinkageStateError("Only formalised MoUs can be amended.")
    _snapshot_mou(parent_mou, by_user=by_user, notes="Snapshot taken prior to amendment.")
    parent_mou.mark_amended()
    parent_mou.save()
    new_mou = MoU.objects.create(
        connection=parent_mou.connection,
        title=title,
        summary=summary,
        draft_file=draft_file,
        parent_mou=parent_mou,
        created_by=by_user,
    )
    _snapshot_mou(new_mou, by_user=by_user, notes="Amendment draft.")
    link_signals.smehub_mou_amended.send(
        sender=MoU, mou=new_mou, parent_mou=parent_mou, by_user=by_user,
    )
    return new_mou


# ---------------------------------------------------------------------------
# Advisory bookings  (FRSME-MPL017 → MPL019)
# ---------------------------------------------------------------------------

@transaction.atomic
def book_advisory_session(
    *,
    entrepreneur: EntrepreneurProfile,
    service_provider: ServiceProviderEntry,
    slot: AdvisorySlot | None = None,
    starts_at=None,
    duration_minutes: int = 60,
    type: str = AdvisorySession.Type.VIRTUAL,
    location: str = "",
    agenda: str = "",
    booked_by=None,
) -> AdvisorySession:
    if service_provider.verification_status != ServiceProviderEntry.Status.VERIFIED:
        raise LinkageStateError("Service provider must be verified before booking.")
    # FRSME-MPL017 — booking is only available with a connected service provider.
    ct = ContentType.objects.get_for_model(ServiceProviderEntry)
    has_connection = Connection.objects.filter(
        entrepreneur=entrepreneur,
        target_content_type=ct,
        target_object_id=str(service_provider.pk),
        status=Connection.Status.ACTIVE,
    ).exists()
    if not has_connection:
        raise LinkageStateError(
            "Send a connection request and wait for it to be accepted before booking an advisory session."
        )
    # FRSME-MPL017 — when a slot is chosen, validate + consume it under a row
    # lock so two entrepreneurs can't grab the same window concurrently. The
    # slot's own time/duration win over any free-text values.
    if slot is not None:
        slot = (
            AdvisorySlot.objects.select_for_update()
            .filter(pk=slot.pk, service_provider=service_provider)
            .first()
        )
        if slot is None:
            raise LinkageStateError("That time slot is not offered by this service provider.")
        if slot.is_booked:
            raise LinkageStateError("That time slot has just been booked — please pick another.")
        starts_at = slot.starts_at
        duration_minutes = slot.duration_minutes
    elif starts_at is None:
        raise LinkageStateError("Pick an available time slot to book an advisory session.")
    session = AdvisorySession.objects.create(
        entrepreneur=entrepreneur,
        service_provider=service_provider,
        starts_at=starts_at,
        duration_minutes=duration_minutes,
        type=type,
        location=location,
        agenda=agenda,
    )
    if slot is not None:
        slot.session = session
        slot.save(update_fields=["session"])
    _audit(booked_by or entrepreneur.user, AuditLog.Action.CREATE, session)
    link_signals.smehub_advisory_booked.send(sender=AdvisorySession, session=session)
    body = (
        f"Advisory session booked with {service_provider.organisation_name} "
        f"on {starts_at:%d %b %Y %H:%M}."
    )
    notify_smehub(entrepreneur.user, body, verb=Verb.SMEHUB_ADVISORY_BOOKED, target=session)
    if service_provider.managed_by is not None:
        notify_smehub(
            service_provider.managed_by,
            body,
            verb=Verb.SMEHUB_ADVISORY_BOOKED,
            target=session,
        )
    return session


@transaction.atomic
def complete_advisory_session(
    session: AdvisorySession, *, notes: str = "", by_user=None
) -> AdvisorySession:
    if session.status == AdvisorySession.Status.COMPLETED:
        return session
    session.status = AdvisorySession.Status.COMPLETED
    session.completed_at = timezone.now()
    if notes:
        session.notes = notes
    session.save(update_fields=["status", "completed_at", "notes", "updated_at"])
    _audit(
        by_user or session.service_provider.managed_by,
        AuditLog.Action.UPDATE,
        session,
        metadata={"status": "completed"},
    )
    link_signals.smehub_advisory_completed.send(sender=AdvisorySession, session=session)
    return session


@transaction.atomic
def cancel_advisory_session(session: AdvisorySession, *, reason: str = "", by_user=None) -> AdvisorySession:
    if session.status == AdvisorySession.Status.CANCELLED:
        return session
    session.status = AdvisorySession.Status.CANCELLED
    session.cancellation_reason = reason
    session.save(update_fields=["status", "cancellation_reason", "updated_at"])
    # FRSME-MPL017/MPL018 — free the slot so the window is bookable again.
    AdvisorySlot.objects.filter(session=session).update(session=None)
    link_signals.smehub_advisory_cancelled.send(
        sender=AdvisorySession, session=session, reason=reason,
    )
    body = f"Advisory session on {session.starts_at:%d %b %Y %H:%M} cancelled."
    notify_smehub(
        session.entrepreneur.user, body, verb=Verb.SMEHUB_ADVISORY_CANCELLED, target=session,
    )
    if session.service_provider.managed_by is not None:
        notify_smehub(
            session.service_provider.managed_by,
            body,
            verb=Verb.SMEHUB_ADVISORY_CANCELLED,
            target=session,
        )
    return session


# ---------------------------------------------------------------------------
# Suggestions  (PRD A2)
# ---------------------------------------------------------------------------

@transaction.atomic
def suggest_organisation(
    *,
    suggested_by,
    kind: str,
    organisation_name: str,
    contact_email: str = "",
    contact_phone: str = "",
    sector_focus: Iterable[str] | None = None,
    geographic_coverage: Iterable[str] | None = None,
    rationale: str = "",
) -> OrganisationRecommendation:
    rec = OrganisationRecommendation.objects.create(
        suggested_by=suggested_by,
        kind=kind,
        organisation_name=organisation_name,
        contact_email=contact_email,
        contact_phone=contact_phone,
        sector_focus=list(sector_focus or []),
        geographic_coverage=list(geographic_coverage or []),
        rationale=rationale,
    )
    return rec


@transaction.atomic
def handle_organisation_recommendation(
    rec: OrganisationRecommendation,
    *,
    by_user,
    decision: str,
    note: str = "",
) -> OrganisationRecommendation:
    if decision not in {OrganisationRecommendation.Status.ADOPTED, OrganisationRecommendation.Status.DISMISSED}:
        raise LinkageStateError("Decision must be 'adopted' or 'dismissed'.")
    rec.status = decision
    rec.handled_by = by_user
    rec.handled_at = timezone.now()
    rec.handler_note = note
    rec.save(update_fields=["status", "handled_by", "handled_at", "handler_note"])
    return rec


# ---------------------------------------------------------------------------
# Regulatory & Policy Navigation  (PRD §5.X — prose feature)
# ---------------------------------------------------------------------------

def relevant_regulatory_guides(business, *, limit: int = 20):
    """Return active RegulatoryGuide entries matching the business's sector
    and country.

    Match policy:
      • ``sectors`` is a JSONField list of tokens — a guide matches when its
        list contains the business's sector OR is empty (universal).
      • ``countries`` is matched the same way (empty = applies regionally).

    Returns a queryset (sliced); never raises if the business has no
    sector/country — falls back to the universal subset.
    """
    from apps.smehub.linkage.models import RegulatoryGuide

    qs = RegulatoryGuide.objects.filter(is_active=True)
    sector = (getattr(business, "sector", None) or "").strip()
    # Country is stored as a canonical full name now — canonicalise (don't .upper(),
    # which would turn "Uganda" into "UGANDA" and miss the RegulatoryGuide entries).
    country = to_country_name(getattr(business, "country", None))
    if sector:
        qs = qs.filter(
            models.Q(sectors__contains=[sector]) | models.Q(sectors=[]),
        )
    if country:
        qs = qs.filter(
            models.Q(countries__contains=[country]) | models.Q(countries=[]),
        )
    return qs.order_by("title")[:limit]
