"""Mentorship matcher.

Pure functions — no DB writes. Services call these with prepared querysets
and contexts. Phase 6 SME-Hub linkage can reuse :func:`score_mentor` for
its own matchmaking.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Iterable

from apps.alumni.profiles.models import AlumniProfile


WEIGHT_EXPERTISE = 0.45
WEIGHT_COUNTRY = 0.2
WEIGHT_SECTOR = 0.2
WEIGHT_MENTOR_TOPICS = 0.15
PENALTY_ACTIVE_PAIRING = 0.08


@dataclass
class MenteeContext:
    user_id: int
    expertise_tags: list[str]
    country: str | None = None
    sector: str | None = None
    topic: str | None = None  # Free-text topic the mentee wants support on.


@dataclass
class MatchExplainer:
    """The dimensions that contributed to a mentor's score — for the discover
    page's per-card "Shared: …" transparency line. See :func:`explain_match`."""

    shared_tags: list[str]
    same_country: bool
    same_sector: bool
    shared_topics: list[str]

    @property
    def has_signal(self) -> bool:
        return bool(
            self.shared_tags or self.same_country or self.same_sector or self.shared_topics
        )


def _token_overlap_score(a: set[str], b: set[str]) -> float:
    if not a or not b:
        return 0.0
    return len(a & b) / max(len(a | b), 1)


def score_mentor(
    mentor: AlumniProfile,
    mentee: MenteeContext,
    *,
    mentor_active_pairings: int = 0,
) -> float:
    """Weighted score in 0–1. Negative values are clamped to 0.

    Availability is enforced before scoring — a mentor who isn't accepting or
    is at/above capacity is skipped by :func:`find_candidates`, not scored here.
    """
    score = 0.0
    mentor_tags = {t.lower() for t in (mentor.expertise_tags or [])}
    mentee_tags = {t.lower() for t in (mentee.expertise_tags or [])}
    score += WEIGHT_EXPERTISE * _token_overlap_score(mentor_tags, mentee_tags)

    mentor_country = getattr(getattr(mentor.user, "profile", None), "country", "") or ""
    if mentee.country and mentor_country and mentee.country.lower() == mentor_country.lower():
        score += WEIGHT_COUNTRY

    # Sector is looked up via the most recent employment row when available.
    mentor_sector = ""
    latest = mentor.employments.order_by("-started_on").first() if mentor.pk else None
    if latest is not None:
        mentor_sector = (latest.sector or "").lower()
    if mentee.sector and mentor_sector and mentee.sector.lower() == mentor_sector:
        score += WEIGHT_SECTOR

    # Free-text mentee topic vs the mentor's declared topics — token overlap.
    if mentee.topic and mentor.mentor_topics:
        topic_tokens = {
            t.strip().lower() for t in mentee.topic.replace(",", " ").split() if t.strip()
        }
        declared = {t.lower() for t in mentor.mentor_topics}
        # Full-phrase substring bonus: if any mentor topic string appears in the mentee topic,
        # give a small boost independent of tokenisation quirks.
        phrase_hit = any(
            d and d in mentee.topic.lower() for d in (x.strip().lower() for x in mentor.mentor_topics)
        )
        topic_overlap = _token_overlap_score(topic_tokens, declared)
        score += WEIGHT_MENTOR_TOPICS * (1.0 if phrase_hit else topic_overlap)

    score -= PENALTY_ACTIVE_PAIRING * max(0, mentor_active_pairings)
    return max(0.0, min(1.0, score))


def explain_match(mentor: AlumniProfile, mentee: MenteeContext) -> MatchExplainer:
    """Surface *which* dimensions a mentor matched on — not the score itself.

    Recomputes the same overlaps :func:`score_mentor` uses (cheap; a few set
    operations + one latest-employment lookup). Keep the overlap rules here in
    sync with ``score_mentor`` so the explanation never contradicts the score.
    """
    mentor_tags = {t.lower() for t in (mentor.expertise_tags or [])}
    mentee_tags = {t.lower() for t in (mentee.expertise_tags or [])}
    shared_tags = sorted(mentor_tags & mentee_tags)

    mentor_country = getattr(getattr(mentor.user, "profile", None), "country", "") or ""
    same_country = bool(
        mentee.country
        and mentor_country
        and mentee.country.lower() == mentor_country.lower()
    )

    mentor_sector = ""
    latest = mentor.employments.order_by("-started_on").first() if mentor.pk else None
    if latest is not None:
        mentor_sector = (latest.sector or "").lower()
    same_sector = bool(
        mentee.sector and mentor_sector and mentee.sector.lower() == mentor_sector
    )

    shared_topics: list[str] = []
    if mentee.topic and mentor.mentor_topics:
        topic_tokens = {
            t.strip().lower() for t in mentee.topic.replace(",", " ").split() if t.strip()
        }
        haystack = mentee.topic.lower()
        seen: set[str] = set()
        for raw in mentor.mentor_topics:
            d = raw.strip().lower()
            if d and d not in seen and (d in topic_tokens or d in haystack):
                shared_topics.append(raw)
                seen.add(d)
        shared_topics.sort()

    return MatchExplainer(
        shared_tags=shared_tags,
        same_country=same_country,
        same_sector=same_sector,
        shared_topics=shared_topics,
    )


def find_candidates(
    mentee: MenteeContext,
    *,
    limit: int = 10,
    base_queryset: Iterable[AlumniProfile] | None = None,
    include_unavailable: bool = False,
) -> list[tuple[AlumniProfile, float]]:
    """Return the top-N mentor candidates with scores.

    By default excludes mentors whose ``mentor_accepting`` is False or whose
    active pairing count has reached ``mentor_capacity``.
    """
    from django.db.models import Count

    from apps.alumni.engagement.models import MentorshipPairing, MentorshipStatus

    qs = base_queryset
    if qs is None:
        qs = (
            AlumniProfile.objects.filter(visibility_consent=True)
            .exclude(user_id=mentee.user_id)
        )
        if not include_unavailable:
            qs = qs.filter(mentor_accepting=True)

    active_counts = {
        row["mentor_id"]: row["n"]
        for row in MentorshipPairing.objects.filter(status=MentorshipStatus.ACTIVE)
        .values("mentor_id")
        .annotate(n=Count("pk"))
    }

    scored: list[tuple[AlumniProfile, float]] = []
    for mentor in qs:
        active = active_counts.get(mentor.pk, 0)
        if not include_unavailable and mentor.mentor_capacity and active >= mentor.mentor_capacity:
            continue
        score = score_mentor(mentor, mentee, mentor_active_pairings=active)
        if score > 0:
            scored.append((mentor, score))
    scored.sort(key=lambda pair: pair[1], reverse=True)
    return scored[:limit]
