"""Recruiter Quick Overview + Action Points.

GET /api/[v1/]dashboard/recruiter/overview/

An ADDITIVE, read-only endpoint for the recruiter's own daily snapshot. It does
not touch the existing /dashboard/recruiter/ endpoint, any workflow, schema or
permission — it only aggregates data the recruiter can already see.

Everything is scoped SERVER-SIDE to the logged-in user's assigned JDs, so a
recruiter only ever sees their own numbers (and a direct API call can't widen
that). Returns both the summary cards and the action points in ONE call to keep
dashboard chatter low.

Summary cards
    assigned_jds         all JDs assigned to the recruiter (any status)
    active_jds           assigned JDs that are Published (live)
    screened_candidates  distinct candidates AI-screened (have a RankScore) on
                         those JDs
    pipeline_candidates  applications on those JDs still in progress

Action points (each carries a count + the existing route to act on it, and is
dropped when the recruiter lacks the View permission for that route — see
apps/dashboard/action_points.py)
    jds_awaiting_candidates   active JDs with nobody in the pipeline yet
    pending_ai_screening      pipeline candidates with no AI score yet
    awaiting_notification     screened candidates never notified
    pending_shortlist         'selected' candidates not on a customer shortlist
    pending_interviews        upcoming scheduled interviews
    overdue_interviews        scheduled interviews whose time has passed
    pending_offers            candidates sitting at the offer stage
    high_priority_jds         active High-priority JDs
"""

from django.utils import timezone
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView

from core.responses import success_response

from apps.jobs.models import JobDescription
from apps.pipeline.models import (
    InterviewSchedule,
    JobApplication,
    PipelineStage,
    RankScore,
    ShortlistItem,
)

from .action_points import (
    can_view_route,
    filter_action_points,
    make_point,
    required_permissions,
)

# Rejection Analytics reports on candidate records, so it is gated on the View
# permission of the Candidates module — resolved from the menus table like every
# other route (see action_points.py), never from a hardcoded role.
REJECTION_ANALYTICS_ROUTE = "/candidates"


class RecruiterOverviewView(APIView):
    """Quick Overview + Action Points for the logged-in recruiter."""

    permission_classes = [IsAuthenticated]

    def get(self, request):
        user = request.user

        # --- Scope: only the JDs assigned to this recruiter -------------------
        # Optional `?jd=<id>` narrows the whole overview to a single JD. It is
        # applied ON TOP of the recruiter's own assignments, so an unassigned or
        # unknown id yields an empty scope rather than another JD's data.
        my_jds = JobDescription.objects.filter(assigned_recruiters=user).distinct()
        from_date = request.query_params.get("from_date")
        to_date = request.query_params.get("to_date")
        if from_date:
            my_jds = my_jds.filter(created_at__date__gte=from_date)
        if to_date:
            my_jds = my_jds.filter(created_at__date__lte=to_date)
        # Optional `?client=` / `?status=` narrow the same scope. Both are
        # additive: omitting them keeps the previous behaviour exactly, and
        # neither can widen the scope beyond the recruiter's own assignments.
        client_param = (request.query_params.get("client") or "").strip()
        if client_param:
            my_jds = my_jds.filter(client_id=client_param) if client_param.isdigit() else my_jds.none()
        status_param = (request.query_params.get("status") or "").strip()
        if status_param and status_param.lower() != "all":
            my_jds = my_jds.filter(status=status_param)

        jd_param = (request.query_params.get("jd") or "").strip()
        if jd_param and jd_param.lower() != "all":
            my_jds = my_jds.filter(id=jd_param) if jd_param.isdigit() else my_jds.none()
        my_jd_ids = list(my_jds.values_list("id", flat=True))
        active_jd_ids = list(my_jds.filter(status="Published").values_list("id", flat=True))
        # JD lifecycle buckets, using the app's existing status vocabulary
        # (Draft is shown to recruiters as "On Hold" — see MyAssignedJDs).
        # Derived from the same scoped queryset, so they follow the JD filter
        # and the recruiter's own assignments automatically.
        on_hold_jd_ids = list(my_jds.filter(status="Draft").values_list("id", flat=True))
        closed_jd_ids = list(my_jds.filter(status="Closed").values_list("id", flat=True))

        apps_qs = JobApplication.objects.filter(
            job_id__in=my_jd_ids, candidate__is_deleted=False
        )

        # --- Summary cards ----------------------------------------------------
        # Candidates AI-screened = those with a rank score on one of these JDs.
        screened_candidate_ids = set(
            RankScore.objects.filter(run__job_id__in=my_jd_ids)
            .values_list("candidate_id", flat=True)
        )
        pipeline_candidates = apps_qs.filter(
            stage__outcome=PipelineStage.Outcome.IN_PROGRESS
        ).count()

        # --- Performance metrics: distinct candidate ids per pipeline bucket ----
        # Stage-code lists mirror RecruiterDashboardView (/dashboard/recruiter/)
        # exactly, so these buckets mean the same thing as that dashboard's
        # metrics — but returned as DISTINCT CANDIDATE ids, which is what the
        # candidate list can display (that endpoint counts application rows, so a
        # candidate on two JDs counts twice there and cannot match a record list).
        PERF_STAGES = {
            "in_process": ["contacted", "candidate_responded", "ai_calling", "ai_screened",
                           "ai_qualified", "qualifying", "interviewing", "selected"],
            "ai_screening": ["ai_calling", "ai_screened"],
            "submitted": ["submitted"],
            "shortlisted": ["selected"],
            "offered": ["offered"],
            "joined": ["placed"],
            "offer_rejected": ["offer_declined", "offered_not_joined"],
            "rejected": ["not_in_consideration", "client_declined"],
        }
        performance = {}
        for key, codes in PERF_STAGES.items():
            ids = sorted(set(
                apps_qs.filter(stage__code__in=codes)
                .order_by().values_list("candidate_id", flat=True)
            ))
            performance[key] = {"count": len(ids), "ids": ids}

        # --- JD-wise recruitment metrics -------------------------------------
        # Reflects whatever JD scope is active (all assigned JDs, or the single
        # JD chosen with ?jd=). Counts are DISTINCT CANDIDATES throughout, so
        # they stay consistent with the candidate lists these cards open.
        def _cands(**flt):
            return set(
                apps_qs.filter(**flt).order_by().values_list("candidate_id", flat=True)
            )

        total_received = _cands()
        interviewed = _cands(stage__code="interviewing")
        rejected = _cands(stage__outcome=PipelineStage.Outcome.LOST)
        shortlisted_ids = set(performance["shortlisted"]["ids"])
        
        # "Remaining" = received minus those already shortlisted (meaning they
        # reached "selected" or any stage after it).
        shortlisted_cumulative_ids = _cands(stage__code__in=[
            "selected", "offered", "placed", "offer_declined", "offered_not_joined"
        ])
        remaining = total_received - shortlisted_cumulative_ids

        ai_screened = screened_candidate_ids & total_received

        jd_metrics = {
            "total_received": len(total_received),
            "ai_screened": len(ai_screened),
            "shortlisted": len(shortlisted_ids),
            "submitted": performance["submitted"]["count"],
            "interviewed": len(interviewed),
            "offered": performance["offered"]["count"],
            "joined": performance["joined"]["count"],
            "rejected": len(rejected),
            "remaining": len(remaining),
        }
        # The candidate ids behind each count above, so every metric card can
        # drill through to the existing candidate list (/candidates?ids=…) the
        # same way the `performance` cards already do.
        #
        # ADDITIVE: `jd_metrics` keeps its exact shape (plain integers) so no
        # existing consumer changes. These are the SAME sets the counts are taken
        # from — nothing is recomputed, so a count can never disagree with the
        # list it opens.
        jd_metrics_ids = {
            "total_received": sorted(total_received),
            "ai_screened": sorted(ai_screened),
            "shortlisted": sorted(shortlisted_ids),
            "submitted": performance["submitted"]["ids"],
            "interviewed": sorted(interviewed),
            "offered": performance["offered"]["ids"],
            "joined": performance["joined"]["ids"],
            "rejected": sorted(rejected),
            "remaining": sorted(remaining),
        }
        # pipeline stage the candidate ended in. Anything without either is
        # reported as "Not Specified" rather than guessed at.
        ROUND1_LABELS = {
            "SCREENED_OUT": "Screened Out",
            "NOT_INTERESTED": "Not Interested",
            "NO_RESPONSE": "No Response",
            "DEALBREAKER": "Dealbreaker",
        }
        STAGE_LABELS = {
            "not_in_consideration": "Not in Consideration",
            "offer_declined": "Offer Declined",
            "offered_not_joined": "Offered but Not Joined",
            "client_declined": "Client Declined",
        }
        reason_counts = {}
        rejected_rows = (
            apps_qs.filter(stage__outcome=PipelineStage.Outcome.LOST)
            .order_by().values_list("candidate_id", "round1_outcome", "stage__code", "job_id")
        )
        seen_rejected = set()
        # One (candidate, job, reason) row per rejected candidate — same pick
        # rule as reason_counts above (first row seen per candidate), so the
        # per-candidate list and the reason totals can never disagree.
        rejected_picks = []
        for cand_id, r1, stage_code, job_id in rejected_rows:
            if cand_id in seen_rejected:      # one reason per candidate
                continue
            seen_rejected.add(cand_id)
            reason = ROUND1_LABELS.get(r1) or STAGE_LABELS.get(stage_code) or "Not Specified"
            reason_counts[reason] = reason_counts.get(reason, 0) + 1
            rejected_picks.append((cand_id, job_id, reason))

        # Candidate names + JD titles for the per-candidate table — two lookups
        # total (not one per row), so this stays cheap regardless of how many
        # candidates were rejected.
        from apps.candidates.models import Candidate
        cand_name_by_id = {
            cid: f"{first} {last}".strip()
            for cid, first, last in Candidate.objects.filter(
                id__in=[cid for cid, _, _ in rejected_picks]
            ).values_list("id", "first_name", "last_name")
        }
        jd_title_by_id = dict(
            JobDescription.objects.filter(
                id__in=[jid for _, jid, _ in rejected_picks]
            ).values_list("id", "title")
        )
        rejected_records = [
            {
                "candidate_id": cid,
                "candidate_name": cand_name_by_id.get(cid) or f"Candidate #{cid}",
                "jd_id": jid,
                "jd_title": jd_title_by_id.get(jid, ""),
                "reason": reason,
            }
            for cid, jid, reason in rejected_picks
        ]

        rejection_analytics = {
            "total_rejected": len(seen_rejected),
            "reasons": [
                {"reason": r, "count": c}
                for r, c in sorted(reason_counts.items(), key=lambda kv: (-kv[1], kv[0]))
            ],
            # Per-candidate breakdown — who was rejected, from which JD, and
            # why. Drives the Rejection Analytics card's candidate list; the
            # aggregated `reasons` above is kept for any other consumer.
            "records": rejected_records,
            # Codes that unlocked this widget, so the UI can apply the same check.
            "permissions": required_permissions(REJECTION_ANALYTICS_ROUTE),
        }

        # Every metric ships the EXACT ids it counted, so the destination list
        # can show precisely those records — count == len(ids) == rows shown,
        # with no duplicates (all id collections here are sets/distinct).
        # .order_by() clears JobApplication's default ordering — otherwise Django
        # adds created_at to the SELECT and DISTINCT dedupes on (candidate, date),
        # letting the same candidate appear twice.
        pipeline_app_ids = sorted(set(
            apps_qs.filter(stage__outcome=PipelineStage.Outcome.IN_PROGRESS)
            .order_by().values_list("candidate_id", flat=True)
        ))
        summary = {
            "assigned_jds": len(my_jd_ids),
            "assigned_jds_ids": my_jd_ids,
            "active_jds": len(active_jd_ids),
            "active_jds_ids": active_jd_ids,
            "on_hold_jds": len(on_hold_jd_ids),
            "on_hold_jds_ids": on_hold_jd_ids,
            "closed_jds": len(closed_jd_ids),
            "closed_jds_ids": closed_jd_ids,
            "screened_candidates": len(screened_candidate_ids),
            "screened_candidates_ids": sorted(screened_candidate_ids),
            # Distinct candidates currently in the pipeline (not application rows),
            # so the number matches the candidate records the link opens.
            "pipeline_candidates": len(pipeline_app_ids),
            "pipeline_candidates_ids": pipeline_app_ids,
        }

        # --- Action points ----------------------------------------------------
        # 1. Active JDs with nobody in the pipeline yet.
        jds_with_apps = set(
            JobApplication.objects.filter(job_id__in=active_jd_ids)
            .values_list("job_id", flat=True)
        )
        jds_awaiting_candidates = [j for j in active_jd_ids if j not in jds_with_apps]

        # 2. Pipeline candidates with no AI score yet (distinct candidates).
        scored_app_ids = set(
            RankScore.objects.filter(run__job_id__in=my_jd_ids, application__isnull=False)
            .values_list("application_id", flat=True)
        )
        in_progress_apps = list(
            apps_qs.filter(stage__outcome=PipelineStage.Outcome.IN_PROGRESS)
            .values_list("id", "candidate_id")
        )
        pending_ai_ids = sorted({cid for aid, cid in in_progress_apps if aid not in scored_app_ids})

        # 3. Screened candidates who have never been notified.
        notified_ids: set = set()
        if screened_candidate_ids:
            from apps.candidates.models import NotificationLog
            notified_ids = set(
                NotificationLog.objects.filter(candidate_id__in=screened_candidate_ids)
                .values_list("candidate_id", flat=True)
            )
        awaiting_notification_ids = sorted(screened_candidate_ids - notified_ids)

        # 4. 'Selected' candidates not yet on a customer shortlist.
        selected_apps = list(
            apps_qs.filter(stage__code="selected").values_list("id", "candidate_id")
        )
        shortlisted_app_ids = set(
            ShortlistItem.objects.filter(application_id__in=[a for a, _ in selected_apps])
            .values_list("application_id", flat=True)
        )
        pending_shortlist_ids = sorted(
            {cid for aid, cid in selected_apps if aid not in shortlisted_app_ids}
        )

        # 5/6. Interviews still open — upcoming vs already overdue (distinct candidates).
        now = timezone.now()
        open_interviews = InterviewSchedule.objects.filter(
            application__job_id__in=my_jd_ids,
            status__in=[InterviewSchedule.Status.SCHEDULED, InterviewSchedule.Status.RESCHEDULED],
        )
        pending_interview_ids = sorted(set(
            open_interviews.filter(scheduled_at__gte=now)
            .values_list("application__candidate_id", flat=True)
        ))
        overdue_interview_ids = sorted(set(
            open_interviews.filter(scheduled_at__lt=now)
            .values_list("application__candidate_id", flat=True)
        ))

        # 7. Candidates sitting at the offer stage.
        pending_offer_ids = sorted(set(
            apps_qs.filter(stage__code="offered").values_list("candidate_id", flat=True)
        ))

        # 8. Active High-priority JDs.
        high_priority_ids = list(
            my_jds.filter(status="Published", priority="High").values_list("id", flat=True)
        )

        # Each action point carries the exact ids it counted and the list that
        # can display them, so clicking always opens precisely those records.
        # Every point gets its own tone so the card colour identifies the type
        # at a glance; `filter_action_points` then drops any whose target module
        # this recruiter has no View permission for.
        action_points = [
            make_point("jds_awaiting_candidates", "JDs awaiting candidate upload",
                       jds_awaiting_candidates, "/jobs", "fa-solid fa-folder-open", "amber",
                       description="Active JDs with nobody in the pipeline yet."),
            make_point("pending_ai_screening", "Candidates pending AI screening",
                       pending_ai_ids, "/candidates", "fa-solid fa-wand-magic-sparkles", "indigo",
                       description="Pipeline candidates with no AI score yet."),
            make_point("awaiting_notification", "Candidates awaiting notification",
                       awaiting_notification_ids, "/candidates", "fa-solid fa-bell", "sky",
                       description="Screened candidates who were never notified."),
            make_point("pending_shortlist", "Candidates pending customer shortlist",
                       pending_shortlist_ids, "/candidates", "fa-solid fa-share-nodes", "emerald",
                       description="Selected candidates not yet shared with the customer."),
            make_point("pending_interviews", "Pending interviews",
                       pending_interview_ids, "/candidates", "fa-solid fa-calendar-check", "violet",
                       description="Interviews scheduled and still to happen."),
            make_point("pending_offers", "Pending offers",
                       pending_offer_ids, "/candidates", "fa-solid fa-file-signature", "teal",
                       description="Candidates sitting at the offer stage."),
            make_point("overdue_interviews", "Overdue interviews",
                       overdue_interview_ids, "/candidates", "fa-solid fa-triangle-exclamation",
                       "rose", urgent=True,
                       description="Scheduled interviews whose slot has already passed."),
            make_point("high_priority_jds", "High-priority JDs open",
                       high_priority_ids, "/jobs", "fa-solid fa-fire", "orange", urgent=True,
                       description="Published High-priority JDs assigned to you."),
        ]
        action_points = filter_action_points(user, action_points)

        # --- Per-JD breakdown -------------------------------------------------
        # One row per JD in scope: opening progress + the same candidate funnel
        # and rejection reasons as above, scoped to that JD. Built from a single
        # pass over the applications (no per-JD queries).
        SHORTLISTED_CODES = set(PERF_STAGES["shortlisted"])
        SUBMITTED_CODES = set(PERF_STAGES["submitted"])
        OFFERED_CODES = set(PERF_STAGES["offered"])
        INTERVIEWED_CODES = {"interviewing"}
        rows = apps_qs.order_by().values_list(
            "job_id", "candidate_id", "stage__code", "stage__outcome", "round1_outcome",
        )
        # jd -> bucket -> set(candidate_id); reasons counted once per candidate.
        buckets: dict = {}
        for jid in my_jd_ids:
            buckets[jid] = {
                "applied": set(), "shortlisted": set(), "shortlisted_cumulative": set(), "submitted": set(),
                "interviewed": set(), "offered": set(), "joined": set(),
                "rejected": set(), "reasons": {}, "reason_seen": set(),
            }
        for jid, cid, code, outcome, r1 in rows:
            b = buckets.get(jid)
            if b is None:
                continue
            b["applied"].add(cid)
            if code in SHORTLISTED_CODES:
                b["shortlisted"].add(cid)
            if code in {"selected", "offered", "placed", "offer_declined", "offered_not_joined"}:
                b["shortlisted_cumulative"].add(cid)
            if code in SUBMITTED_CODES:
                b["submitted"].add(cid)
            if code in INTERVIEWED_CODES:
                b["interviewed"].add(cid)
            if code in OFFERED_CODES:
                b["offered"].add(cid)
            if code == "placed":
                b["joined"].add(cid)
            if outcome == PipelineStage.Outcome.LOST:
                b["rejected"].add(cid)
                if cid not in b["reason_seen"]:
                    b["reason_seen"].add(cid)
                    reason = ROUND1_LABELS.get(r1) or STAGE_LABELS.get(code) or "Not Specified"
                    b["reasons"][reason] = b["reasons"].get(reason, 0) + 1
 
        # AI-screened candidates per JD (rank scores are keyed by the run's job).
        screened_by_jd: dict = {}
        for jid, cid in RankScore.objects.filter(run__job_id__in=my_jd_ids).values_list(
            "run__job_id", "candidate_id"
        ):
            screened_by_jd.setdefault(jid, set()).add(cid)
 
        jd_breakdown = []
        for jd in my_jds.only("id", "title", "status", "num_positions"):
            b = buckets.get(jd.id) or {}
            applied = b.get("applied", set())
            shortlisted = b.get("shortlisted", set())
            joined = b.get("joined", set())
            total_openings = jd.num_positions or 0
            filled = len(joined)
            jd_breakdown.append({
                "jd_id": jd.id,
                "jd_code": f"JD-{jd.id:04d}",
                "title": jd.title,
                "status": jd.status,
                # Opening progress — filled is driven by candidates who reached
                # the WON stage, so it updates as the pipeline advances.
                "openings_total": total_openings,
                "openings_filled": filled,
                "openings_remaining": max(0, total_openings - filled) if total_openings else 0,
                # Candidate progress
                "applied": len(applied),
                "ai_screened": len(screened_by_jd.get(jd.id, set()) & applied),
                "shortlisted": len(shortlisted),
                "submitted": len(b.get("submitted", set())),
                "interviewed": len(b.get("interviewed", set())),
                "offered": len(b.get("offered", set())),
                "joined": filled,
                "rejected": len(b.get("rejected", set())),
                "remaining": len(applied - b.get("shortlisted_cumulative", set())),
                # Rejection matrix for this JD
                "rejection_reasons": [
                    {"reason": r, "count": c}
                    for r, c in sorted(b.get("reasons", {}).items(), key=lambda kv: (-kv[1], kv[0]))
                ],
            })
        jd_breakdown.sort(key=lambda r: r["jd_id"])

        payload = {
            "summary": summary,
            "action_points": action_points,
            "performance": performance,
            "jd_metrics": jd_metrics,
            "jd_metrics_ids": jd_metrics_ids,
            "jd_breakdown": jd_breakdown,
        }
        # Rejection Analytics is only sent to users who may view the Candidates
        # module, so its numbers never reach a browser that isn't allowed to see
        # them (a direct API call can't reveal them either). The key is simply
        # absent otherwise, which the dashboard already treats as "hide the card".
        if can_view_route(user, REJECTION_ANALYTICS_ROUTE):
            payload["rejection_analytics"] = rejection_analytics
        return success_response(payload)
