import os
import datetime
import logging
import mimetypes
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.db.models import Q
from django.http import Http404, FileResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_exempt
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .parser import ResumeParser
from .resume_storage import resume_storage, unsign_resume_name, signed_resume_url

from core.pagination import DefaultPagination
from core.responses import success_response, error_response
from .access import CANDIDATE_FORBIDDEN_MESSAGE, can_view_candidate, scope_candidates
from .models import Candidate, CandidateDocument
from .permissions import IsCandidateOwnerOrStaff, IsRecruiterOrAdmin, IsAdmin
from .serializers import (
    CandidateCreateUpdateSerializer,
    CandidateDetailSerializer,
    CandidateDocumentSerializer,
    CandidateListSerializer,
    DeletedCandidateListSerializer,
)
from .services import CandidateService

logger = logging.getLogger(__name__)


def get_scoped_candidate(request, pk, *, include_deleted=False):
    """Fetch a candidate by pk, enforcing recruiter scoping.

    Use this in every endpoint that takes a candidate id — it is the single
    choke point that stops a recruiter reaching a candidate who only applied to
    another recruiter's JD (forwarded email, direct URL, edited parameter).

    Raises Http404 if the candidate doesn't exist, PermissionDenied (403) if the
    caller isn't allowed to see them.
    """
    from rest_framework.exceptions import PermissionDenied

    qs = Candidate.objects.all_with_deleted() if include_deleted else Candidate.objects.all()
    candidate = qs.filter(pk=pk).first()
    if not candidate:
        raise Http404
    if not can_view_candidate(request.user, candidate):
        raise PermissionDenied(CANDIDATE_FORBIDDEN_MESSAGE)
    return candidate


def _assign_candidates_to_job(candidate_ids, job_id, user):
    """Put each candidate into the JD's pipeline at the first stage (skip existing).
    Used by the bulk upload endpoints when called from a job page. Returns count added."""
    from apps.jobs.models import JobDescription
    from apps.pipeline.models import JobApplication, PipelineStage

    job = JobDescription.objects.filter(pk=job_id).first()
    if not job:
        return 0
    first_stage = PipelineStage.objects.filter(is_active=True).order_by("sort_order", "id").first()
    added = 0
    for cid in candidate_ids:
        _, created = JobApplication.objects.get_or_create(
            candidate_id=cid, job=job, defaults={"stage": first_stage, "created_by": user}
        )
        added += int(created)
    return added


def apply_candidate_list_filters(candidates, params):
    """Server-side equivalents of the Candidates page filters, so the list can
    paginate one page at a time instead of downloading every candidate.

    Every param is optional. Comma-separated (multi) params match with OR
    semantics within the filter and AND across filters — mirroring the UI.
    """
    def _multi(name):
        raw = (params.get(name) or "").strip()
        return [v.strip() for v in raw.split(",") if v.strip()]

    status_f = (params.get("status") or "").strip()
    if status_f and status_f.upper() != "ALL":
        candidates = candidates.filter(status=status_f)

    exp = (params.get("experience") or "").strip().upper()
    if exp == "FRESHER":
        candidates = candidates.filter(fresher=True)
    elif exp == "EXPERIENCED":
        candidates = candidates.filter(fresher=False)

    state = (params.get("state") or "").strip()
    if state:
        candidates = candidates.filter(state__icontains=state)

    cities = _multi("cities")
    if cities:
        q = Q()
        for c in cities:
            q |= Q(city__icontains=c)
        candidates = candidates.filter(q)

    # Minimum experience. total_experience is stored as years.months with the
    # tenth digit == months (0-9), so a plain numeric >= comparison matches the
    # UI's month-total comparison exactly.
    exp_years = (params.get("exp_years") or "").strip()
    exp_months = (params.get("exp_months") or "").strip()
    if exp_years or exp_months:
        try:
            y = int(exp_years or 0)
            m = min(int(exp_months or 0), 9)
            candidates = candidates.filter(total_experience__gte=Decimal(f"{y}.{m}"))
        except (ValueError, InvalidOperation):
            pass

    locations = _multi("locations")
    if locations:
        q = Q()
        for L in locations:
            q |= Q(city__icontains=L) | Q(state__icontains=L) | Q(preferred_location__icontains=L)
        candidates = candidates.filter(q)

    companies = _multi("companies")
    if companies:
        q = Q()
        for co in companies:
            q |= Q(current_company__icontains=co)
        candidates = candidates.filter(q)

    skills = _multi("skills")
    if skills:
        q = Q()
        for s in skills:
            q |= Q(skills__name__icontains=s)
        candidates = candidates.filter(q).distinct()

    salary_from = (params.get("salary_from") or "").strip()
    if salary_from:
        try:
            candidates = candidates.filter(current_ctc__gte=Decimal(salary_from))
        except (ValueError, InvalidOperation):
            pass
    salary_to = (params.get("salary_to") or "").strip()
    if salary_to:
        try:
            candidates = candidates.filter(current_ctc__lte=Decimal(salary_to))
        except (ValueError, InvalidOperation):
            pass

    designations = _multi("designations")
    if designations:
        q = Q()
        for d in designations:
            q |= Q(current_role__iexact=d)
        candidates = candidates.filter(q)

    gender = (params.get("gender") or "").strip()
    if gender and gender.upper() != "ALL":
        if gender.lower() == "other":
            candidates = (
                candidates.exclude(gender__iexact="male")
                .exclude(gender__iexact="female")
                .exclude(gender__isnull=True)
                .exclude(gender__exact="")
            )
        else:
            candidates = candidates.filter(gender__iexact=gender)

    created_from = (params.get("created_from") or "").strip()
    if created_from:
        candidates = candidates.filter(created_at__date__gte=created_from)
    created_to = (params.get("created_to") or "").strip()
    if created_to:
        candidates = candidates.filter(created_at__date__lte=created_to)

    source = (params.get("source") or "").strip()
    if source and source.upper() != "ALL":
        candidates = candidates.filter(source=source)

    # Job filter: match candidates assigned (via pipeline applications) to a job
    # by its numeric id (#123 / 123) OR a title substring.
    job_q = (params.get("job") or "").strip().lstrip("#").strip()
    if job_q:
        q = Q(applications__job__title__icontains=job_q)
        if job_q.isdigit():
            q |= Q(applications__job__id=int(job_q))
        candidates = candidates.filter(q).distinct()

    # Card view sort options (server-side so it sorts the whole set, not one page).
    ordering = (params.get("ordering") or "").strip()
    if ordering == "name":
        candidates = candidates.order_by("first_name", "last_name")
    elif ordering == "experience":
        candidates = candidates.order_by("-total_experience")
    else:  # "newest" or unspecified — stable default for pagination
        candidates = candidates.order_by("-created_at", "-id")

    return candidates


class CandidateListCreateAPIView(APIView):
    """API view to list all candidates (Staff only) or create a profile."""
    permission_classes = [IsCandidateOwnerOrStaff]

    def get(self, request):
        # Recruiters only see candidates tied to their own assigned JDs (plus the
        # unapplied sourcing pool). Applied before every other filter so no
        # query parameter can widen the result set.
        candidates = scope_candidates(Candidate.objects.all(), request.user)
        # Dashboard drill-down: filter to exact candidate IDs on the backend.
        ids_str = request.query_params.get("ids")
        if ids_str:
            ids_list = [int(x.strip()) for x in ids_str.split(",") if x.strip().isdigit()]
            if ids_list:
                candidates = candidates.filter(id__in=ids_list)
        # Dashboard drill-down: a dashboard KPI card (Submitted / Shortlisted /
        # Offered / Joined / Offered Rejected / Rejected, or an exact-stage
        # card like Admin's "Offers Made") narrows the list to exactly the
        # candidates behind that card's count. Every dashboard reuses the same
        # two building blocks so a count and its drill-down can never disagree:
        #   - job scope:   `owner=me` (JDs created by the user — Project
        #     Manager) / `assigned=me` (JDs assigned to the user — Recruiter)
        #     / neither (every job — Admin/TA, org-wide).
        #   - classification: `pm_metric=<key>` (apps.pipeline.pm_metrics:
        #     "reached this stage or beyond" — Project Manager/TA) or
        #     `stage_codes=<comma list>` (exact CURRENT stage match — Admin/
        #     Recruiter). `period=` (TA only) additionally restricts to
        #     applications created within that calendar window.
        pm_metric = (request.query_params.get("pm_metric") or "").strip().lower()
        stage_codes_str = (request.query_params.get("stage_codes") or "").strip()
        if pm_metric or stage_codes_str:
            from apps.jobs.models import JobDescription
            from apps.pipeline.pm_metrics import (
                METRIC_KEYS, candidate_ids_for_metric, candidate_ids_for_stage_codes,
            )

            owner_param = (request.query_params.get("owner") or "").strip().lower()
            assigned_param = (request.query_params.get("assigned") or "").strip().lower()
            job_ids = None
            if owner_param == "me":
                job_ids = JobDescription.objects.filter(created_by=request.user).values_list("id", flat=True)
            elif assigned_param == "me":
                job_ids = JobDescription.objects.filter(assigned_recruiters=request.user).values_list("id", flat=True)

            if pm_metric and pm_metric in METRIC_KEYS:
                date_from = date_to = None
                period_param = (request.query_params.get("period") or "").strip().lower()
                if period_param:
                    from apps.dashboard.period_utils import PERIODS, period_window
                    if period_param in PERIODS:
                        date_from, date_to, _, _ = period_window(period_param)
                matched_ids = candidate_ids_for_metric(job_ids, pm_metric, date_from, date_to)
                candidates = candidates.filter(id__in=matched_ids)
            elif stage_codes_str:
                stage_codes = [c.strip() for c in stage_codes_str.split(",") if c.strip()]
                matched_ids = candidate_ids_for_stage_codes(stage_codes, job_ids)
                candidates = candidates.filter(id__in=matched_ids)
        # Hide candidates already assigned to a JD (Select Candidates tab).
        exclude_job = request.query_params.get("exclude_job")
        if exclude_job:
            candidates = candidates.exclude(applications__job_id=exclude_job)
        telegram_chat_id = (request.query_params.get("telegram_chat_id") or "").strip()
        if telegram_chat_id:
            candidates = candidates.filter(telegram_chat_id=telegram_chat_id)
        whatsapp_number = (request.query_params.get("whatsapp_number") or "").strip()
        if whatsapp_number:
            candidates = candidates.filter(whatsapp_number=whatsapp_number)
        search = (request.query_params.get("search") or "").strip()
        if search:
            candidates = candidates.filter(
                Q(first_name__icontains=search)
                | Q(last_name__icontains=search)
                | Q(email__icontains=search)
                | Q(phone_number__icontains=search)
                | Q(current_role__icontains=search)
            )
        # Full-text résumé search (OpenCATS-style): match against extracted text
        # stored on candidate attachments.
        resume_q = (request.query_params.get("resume_search") or "").strip()
        if resume_q:
            candidates = candidates.filter(
                attachments__extracted_text__icontains=resume_q
            ).distinct()
        # Advanced list filters (status, experience, location, skills, salary…).
        candidates = apply_candidate_list_filters(candidates, request.query_params)
        # Paginated only when ?page= is passed — existing consumers rely on the full list.
        if request.query_params.get("page"):
            paginator = DefaultPagination()
            page = paginator.paginate_queryset(candidates, request, view=self)
            serializer = CandidateListSerializer(page, many=True, context={"request": self.request})
            return paginator.get_paginated_response(serializer.data)
        serializer = CandidateListSerializer(candidates, many=True, context={"request": self.request})
        return success_response(serializer.data)

    def post(self, request):
        serializer = CandidateCreateUpdateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        
        data = serializer.validated_data
        skills = data.pop("skills", [])
        languages = data.pop("languages", [])
        projects = data.pop("projects", [])
        references = data.pop("references", [])
        experiences = data.pop("experiences", [])
        educations = data.pop("educations", [])

        # SRC-007: tag the source. Manual add → UPLOAD if a CV was attached, else SELF.
        if not data.get("source"):
            data["source"] = "UPLOAD" if data.get("resume") else "SELF"

        # Candidates are not tied to a login account by default. A user is linked
        # only if one is explicitly provided (optional, admin/recruiter only).
        user_id = request.data.get("user_id") or request.data.get("user")
        if user_id and request.user.role in ["ADMIN", "RECRUITER"]:
            from django.contrib.auth import get_user_model
            user = get_user_model().objects.filter(id=user_id).first()
        else:
            user = None

        try:
            candidate = CandidateService.create_candidate(
                user=user,
                candidate_data=data,
                skills=skills,
                languages=languages,
                projects=projects,
                references=references,
                experiences=experiences,
                educations=educations,
                created_by=request.user
            )
            from apps.audit_logs.services import log_activity
            log_activity(request.user, "CANDIDATE_UPLOADED", f"Uploaded candidate: {candidate.first_name} {candidate.last_name}", request=request)
        except DjangoValidationError as e:
            msg = e.messages[0] if getattr(e, "messages", None) else str(e)
            return error_response(msg, status_code=status.HTTP_400_BAD_REQUEST)

        # Optional: assign the candidate to one or more Job Order pipelines.
        # The Add-Candidate form posts `assign_job_ids`; accept `job_ids` too.
        job_ids = request.data.get("assign_job_ids") or request.data.get("job_ids") or []
        if isinstance(job_ids, (str, int)):
            job_ids = [job_ids]
        if job_ids:
            from apps.jobs.models import JobDescription
            from apps.pipeline.models import PipelineStage, JobApplication
            first_stage = PipelineStage.objects.filter(is_active=True).order_by("sort_order", "id").first()
            for jid in job_ids:
                try:
                    jid = int(jid)
                except (TypeError, ValueError):
                    continue
                job = JobDescription.objects.filter(id=jid).first()
                if job and not JobApplication.objects.filter(candidate=candidate, job=job).exists():
                    JobApplication.objects.create(candidate=candidate, job=job, stage=first_stage, created_by=request.user)

        out_serializer = CandidateDetailSerializer(candidate, context={"request": self.request})
        return success_response(
            out_serializer.data,
            "Candidate profile created successfully",
            status_code=status.HTTP_201_CREATED
        )


class DeletedCandidateListAPIView(APIView):
    """Admin-only read-only list of soft-deleted candidates (the 'Draft' view).

    Returns ONLY rows with is_deleted=True — active candidates are never
    included. Mirrors the active list's search + pagination behaviour.
    """
    permission_classes = [IsAdmin]

    def get(self, request):
        candidates = (
            Candidate.objects.all_with_deleted()
            .filter(is_deleted=True)
            .select_related("updated_by", "user")
            .prefetch_related("skills")
            .order_by("-updated_at")
        )

        search = (request.query_params.get("search") or "").strip()
        if search:
            candidates = candidates.filter(
                Q(first_name__icontains=search)
                | Q(last_name__icontains=search)
                | Q(email__icontains=search)
                | Q(phone_number__icontains=search)
            )

        # Paginated only when ?page= is passed, matching CandidateListCreateAPIView.
        if request.query_params.get("page"):
            paginator = DefaultPagination()
            page = paginator.paginate_queryset(candidates, request, view=self)
            serializer = DeletedCandidateListSerializer(page, many=True)
            return paginator.get_paginated_response(serializer.data)
        serializer = DeletedCandidateListSerializer(candidates, many=True)
        return success_response(serializer.data)


class CandidateBulkUploadAPIView(APIView):
    """POST a CSV file to bulk-create candidates and optionally assign them to
    Job Order pipelines (via the 'Joborder ID' column). Staff only."""
    permission_classes = [IsRecruiterOrAdmin]

    def post(self, request):
        f = request.FILES.get("file")
        if not f:
            return error_response("No file uploaded.", status_code=400)
        if not f.name.lower().endswith((".csv", ".xlsx")):
            return error_response("Please upload a .xlsx or .csv file.", status_code=400)
        from .bulk_import import import_candidates_csv
        try:
            summary = import_candidates_csv(f, created_by=request.user, filename=f.name)
        except Exception as e:
            return error_response(f"Could not process the file: {e}", status_code=400)

        # Optional: assign every candidate in the file to a JD (upload from a job page)
        job_id = request.data.get("job")
        if job_id:
            summary["pipeline_entries"] += _assign_candidates_to_job(
                summary.get("candidate_ids") or [], job_id, request.user
            )

        msg = (
            f"Imported {summary['created']} candidate(s); {summary.get('updated', 0)} existing updated; "
            f"{summary['pipeline_entries']} pipeline entr(ies); {summary['skipped']} skipped."
        )
        return success_response(summary, msg)


class CandidateMeAPIView(APIView):
    """API view for the authenticated candidate to manage their own profile."""
    permission_classes = [permissions.IsAuthenticated]

    def get(self, request):
        candidate = Candidate.get_for_user(request.user)
        if not candidate:
            return Response({"detail": "Candidate profile not found."}, status=status.HTTP_404_NOT_FOUND)
        serializer = CandidateDetailSerializer(candidate, context={"request": self.request})
        return success_response(serializer.data)

    def put(self, request):
        candidate = Candidate.get_for_user(request.user)
        if not candidate:
            return Response({"detail": "Candidate profile not found."}, status=status.HTTP_404_NOT_FOUND)


        # instance=candidate so the duplicate email/phone/name checks exclude self
        serializer = CandidateCreateUpdateSerializer(instance=candidate, data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
        
        data = serializer.validated_data
        skills = data.pop("skills", None)
        languages = data.pop("languages", None)
        projects = data.pop("projects", None)
        references = data.pop("references", None)
        experiences = data.pop("experiences", None)
        educations = data.pop("educations", None)

        updated_candidate = CandidateService.update_candidate(
            candidate=candidate,
            candidate_data=data,
            skills=skills,
            languages=languages,
            projects=projects,
            references=references,
            experiences=experiences,
            educations=educations,
            updated_by=request.user
        )
        out_serializer = CandidateDetailSerializer(updated_candidate, context={"request": self.request})
        return success_response(out_serializer.data, "Candidate profile updated successfully")

    def patch(self, request):
        return self.put(request)


class CandidateDetailAPIView(APIView):
    """API view to retrieve, update or soft-delete a candidate profile by ID."""
    permission_classes = [IsCandidateOwnerOrStaff]

    def get_object(self, pk):
        # get_scoped_candidate enforces recruiter scoping; check_object_permissions
        # keeps the candidate-owner rules for non-staff roles.
        obj = get_scoped_candidate(self.request, pk, include_deleted=True)
        self.check_object_permissions(self.request, obj)
        return obj

    def get(self, request, pk):
        candidate = self.get_object(pk)
        serializer = CandidateDetailSerializer(candidate, context={"request": self.request})
        return success_response(serializer.data)

    def put(self, request, pk):
        candidate = self.get_object(pk)
        serializer = CandidateCreateUpdateSerializer(instance=candidate, data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
        
        data = serializer.validated_data
        skills = data.pop("skills", None)
        languages = data.pop("languages", None)
        projects = data.pop("projects", None)
        references = data.pop("references", None)
        experiences = data.pop("experiences", None)
        educations = data.pop("educations", None)

        updated_candidate = CandidateService.update_candidate(
            candidate=candidate,
            candidate_data=data,
            skills=skills,
            languages=languages,
            projects=projects,
            references=references,
            experiences=experiences,
            educations=educations,
            updated_by=request.user
        )
        out_serializer = CandidateDetailSerializer(updated_candidate, context={"request": self.request})
        return success_response(out_serializer.data, "Candidate profile updated successfully")

    def patch(self, request, pk):
        return self.put(request, pk)

    def delete(self, request, pk):
        candidate = self.get_object(pk)
        CandidateService.soft_delete_candidate(candidate, deleted_by=request.user)
        return success_response(None, "Candidate profile deleted successfully")


class CandidateDocumentUploadAPIView(APIView):
    """API view to upload a verification document for a candidate."""
    permission_classes = [IsCandidateOwnerOrStaff]

    def get_candidate(self, pk):
        obj = get_scoped_candidate(self.request, pk)
        self.check_object_permissions(self.request, obj)
        return obj

    def post(self, request, pk):
        candidate = self.get_candidate(pk)
        document_type = request.data.get("document_type")
        file_obj = request.FILES.get("file")
        if not document_type or not file_obj:
            return Response({"detail": "document_type and file are required."}, status=status.HTTP_400_BAD_REQUEST)
            
        doc = CandidateService.create_candidate_document(
            candidate=candidate,
            document_type=document_type,
            file_obj=file_obj
        )
        serializer = CandidateDocumentSerializer(doc)
        return success_response(serializer.data, "Document uploaded successfully", status_code=status.HTTP_201_CREATED)


class CandidateDocumentStatusAPIView(APIView):
    """API view to approve or reject a candidate document's status (Staff only)."""
    permission_classes = [IsRecruiterOrAdmin]

    def get_document(self, pk, doc_pk):
        get_scoped_candidate(self.request, pk)   # 403 before touching the document
        try:
            return CandidateDocument.objects.get(pk=doc_pk, candidate_id=pk)
        except CandidateDocument.DoesNotExist:
            raise Http404

    def put(self, request, pk, doc_pk):
        document = self.get_document(pk, doc_pk)
        status_val = request.data.get("status") or request.data.get("verification_status")
        if not status_val:
            return Response({"detail": "status or verification_status is required."}, status=status.HTTP_400_BAD_REQUEST)
            
        updated_doc = CandidateService.update_document_status(
            document=document,
            status=status_val,
            updated_by=request.user
        )
        serializer = CandidateDocumentSerializer(updated_doc)
        return success_response(serializer.data, "Document status updated successfully")

    def patch(self, request, pk, doc_pk):
        return self.put(request, pk, doc_pk)


class CandidateResumeUploadAPIView(APIView):
    """API View to handle resume upload and parse structured information."""
    permission_classes = [permissions.IsAuthenticated]

    def post(self, request):
        file_obj = request.FILES.get("file")
        if not file_obj:
            return Response(
                {"success": False, "detail": "No file uploaded."},
                status=status.HTTP_400_BAD_REQUEST
            )

        # Validate extension — matches the profile upload inputs (.pdf/.doc/.docx)
        ext = os.path.splitext(file_obj.name)[1].lower()
        if ext not in (".pdf", ".doc", ".docx"):
            return Response(
                {"success": False, "detail": "Only PDF, DOC or DOCX resumes are supported."},
                status=status.HTTP_400_BAD_REQUEST
            )

        # Validate file size (max 5 MB)
        if file_obj.size > 5 * 1024 * 1024:
            return Response(
                {"success": False, "detail": "File size exceeds the 5 MB limit."},
                status=status.HTTP_400_BAD_REQUEST
            )

        candidate_id = request.data.get("candidate_id") or request.query_params.get("candidate_id")
        user_id = request.data.get("user_id") or request.query_params.get("user_id")

        # Store résumés in PRIVATE media (never served statically) — reachable
        # only via the signed download endpoint.
        resumes_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, "resumes")
        os.makedirs(resumes_dir, exist_ok=True)

        # Format filename: candidateID_timestamp.pdf
        timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        prefix = "temp"

        # Resolve candidate/prefix
        if candidate_id:
            prefix = str(candidate_id)
        elif user_id:
            cand = Candidate.objects.filter(user_id=user_id).first()
            prefix = str(cand.id) if cand else f"user_{user_id}"
        elif request.user.is_authenticated:
            cand = Candidate.get_for_user(request.user)
            prefix = str(cand.id) if cand else f"user_{request.user.id}"

        filename = f"{prefix}_{timestamp}_resume{ext}"
        file_path = os.path.join(resumes_dir, filename)

        # Save uploaded resume
        try:
            with open(file_path, "wb+") as destination:
                for chunk in file_obj.chunks():
                    destination.write(chunk)
        except Exception as e:
            return Response(
                {"success": False, "detail": f"Failed to save resume: {str(e)}"},
                status=status.HTTP_500_INTERNAL_SERVER_ERROR
            )

        # Check and update database if candidate already exists
        candidate = None
        if candidate_id:
            candidate = Candidate.objects.filter(id=candidate_id).first()
        elif user_id:
            candidate = Candidate.objects.filter(user_id=user_id).first()
        elif request.user.is_authenticated:
            candidate = Candidate.get_for_user(request.user)


        db_resume_path = f"resumes/{filename}"
        if candidate:
            candidate.resume = db_resume_path
            candidate.save(update_fields=["resume", "updated_at"])

        # Two-stage parse: LLM primary, package parser fallback. Never blocks
        # the upload — a failed parse still returns the saved résumé.
        parsed_data, parse_source = {}, "none"
        try:
            from .llm_parser import parse_resume
            parsed_data, parse_source = parse_resume(
                file_path, user=request.user if request.user.is_authenticated else None
            )
        except Exception:
            logger.exception("Résumé parsing crashed for %s", filename)

        if parse_source == "llm":
            message = "Resume parsed with AI."
        elif parse_source == "regex":
            message = "Resume uploaded and parsed successfully."
        else:
            message = "Resume uploaded, but it could not be parsed. Please fill the form manually."

        # Signed, short-lived download URL (private storage — not a static path).
        resume_url = signed_resume_url(request, db_resume_path)

        return Response({
            "success": True,
            "message": message,
            "resume_url": resume_url,
            "resume_path": db_resume_path,
            "parse_source": parse_source,
            "parsed_data": parsed_data
        }, status=status.HTTP_200_OK)


@method_decorator(xframe_options_exempt, name="dispatch")
class CandidateResumeDownloadView(APIView):
    """Serve a résumé from PRIVATE storage, gated by a signed short-lived token.

    The signed token is the capability — it is issued only to authenticated users
    (via the serializer / upload response) and expires (settings.RESUME_URL_TTL).
    Because auth is carried by the signature, no session/JWT header is needed, so
    the URL works directly in a cross-origin <iframe> / download link.
    """
    permission_classes = [permissions.AllowAny]
    authentication_classes = []

    def get(self, request, token):
        name = unsign_resume_name(token)
        if not name:
            raise Http404("Invalid or expired résumé link.")
        # Path safety: only serve names under the private resumes/ prefix.
        if ".." in name or name.startswith("/") or not name.startswith("resumes/"):
            raise Http404("Invalid résumé path.")
        if not resume_storage.exists(name):
            raise Http404("Résumé not found.")
        content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
        resp = FileResponse(resume_storage.open(name, "rb"), content_type=content_type)
        disposition = "attachment" if request.query_params.get("dl") else "inline"
        resp["Content-Disposition"] = f'{disposition}; filename="{os.path.basename(name)}"'
        return resp

    def head(self, request, token):
        resp = self.get(request, token)
        if isinstance(resp, FileResponse):
            resp.content = b""
        return resp


class CandidateAttachmentAPIView(APIView):
    """List (GET) and upload (POST) attachments for a candidate.
    Files go to PRIVATE storage; résumé text is extracted for search."""
    permission_classes = [permissions.IsAuthenticated]

    def get(self, request, pk):
        from .serializers import CandidateAttachmentSerializer
        # Attachments include signed résumé URLs, so this must be scoped too.
        cand = get_scoped_candidate(request, pk)
        data = CandidateAttachmentSerializer(
            cand.attachments.all(), many=True, context={"request": request}
        ).data
        return success_response(data)

    def post(self, request, pk):
        import tempfile
        from .models import CandidateAttachment
        from .serializers import CandidateAttachmentSerializer
        cand = get_scoped_candidate(request, pk)
        f = request.FILES.get("file")
        if not f:
            return error_response("No file uploaded.", status_code=400)
        kind = (request.data.get("kind") or "resume").strip()

        # Best-effort text extraction (for full-text résumé search).
        text = ""
        try:
            ext = os.path.splitext(f.name)[1]
            with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
                for c in f.chunks():
                    tmp.write(c)
                tmp_path = tmp.name
            text = ResumeParser.extract_text(tmp_path) or ""
            os.unlink(tmp_path)
        except Exception:  # noqa: BLE001 — extraction is optional
            text = ""
        f.seek(0)

        att = CandidateAttachment.objects.create(
            candidate=cand, file=f, original_filename=(f.name or "")[:255],
            content_type=getattr(f, "content_type", "") or "", size=f.size or 0,
            kind=kind, extracted_text=text, uploaded_by=request.user,
        )
        # A new résumé becomes the primary one; keep Candidate.resume in sync.
        if kind == "resume":
            cand.attachments.filter(kind="resume").exclude(pk=att.pk).update(is_primary=False)
            att.is_primary = True
            att.save(update_fields=["is_primary"])
            cand.resume = att.file.name
            cand.save(update_fields=["resume"])
        return success_response(
            CandidateAttachmentSerializer(att, context={"request": request}).data,
            "Attachment uploaded", status_code=201,
        )


class CandidateAttachmentDetailAPIView(APIView):
    """Delete an attachment, or PATCH to mark it primary."""
    permission_classes = [permissions.IsAuthenticated]

    def _get(self, pk, att_id):
        from .models import CandidateAttachment
        get_scoped_candidate(self.request, pk)   # 403 before touching the attachment
        att = CandidateAttachment.objects.filter(pk=att_id, candidate_id=pk).first()
        if not att:
            raise Http404
        return att

    def delete(self, request, pk, att_id):
        from .models import CandidateAttachment
        att = self._get(pk, att_id)
        was_primary = att.is_primary
        try:
            att.file.delete(save=False)   # remove the file from private storage
        except Exception:  # noqa: BLE001
            pass
        att.delete()
        if was_primary:
            cand = Candidate.objects.filter(pk=pk).first()
            nxt = CandidateAttachment.objects.filter(candidate_id=pk, kind="resume").order_by("-created_at").first()
            if cand:
                if nxt:
                    nxt.is_primary = True
                    nxt.save(update_fields=["is_primary"])
                    cand.resume = nxt.file.name
                else:
                    cand.resume = None
                cand.save(update_fields=["resume"])
        return success_response(message="Attachment deleted")

    def patch(self, request, pk, att_id):
        from .models import CandidateAttachment
        from .serializers import CandidateAttachmentSerializer
        att = self._get(pk, att_id)
        if request.data.get("is_primary"):
            CandidateAttachment.objects.filter(candidate_id=pk, kind=att.kind).exclude(pk=att.pk).update(is_primary=False)
            att.is_primary = True
            att.save(update_fields=["is_primary"])
            if att.kind == "resume":
                Candidate.objects.filter(pk=pk).update(resume=att.file.name)
        return success_response(
            CandidateAttachmentSerializer(att, context={"request": request}).data, "Updated",
        )


class CandidateContactRevealView(APIView):
    """GET /candidates/<pk>/reveal-contact/ — full phone + email for one candidate.

    PII is masked in list views; this endpoint returns the real values on an
    explicit, authenticated request and writes an audit record (DPDP).
    """
    permission_classes = [permissions.IsAuthenticated]

    def get(self, request, pk):
        cand = Candidate.objects.filter(pk=pk).first()
        if not cand:
            raise Http404
        try:
            from apps.audit_logs.services import log_activity
            log_activity(
                request.user, "PII_REVEAL",
                f"Revealed contact of candidate #{pk} ({cand.first_name} {cand.last_name})",
                request=request,
            )
        except Exception:  # noqa: BLE001 — audit must never block the reveal
            pass
        return success_response({
            "phone_number": cand.phone_number or "",
            "email": cand.email or (cand.user.email if cand.user_id else "") or "",
        })


def _post_gateway(url, payload):
    import json as _json
    import urllib.request
    req = urllib.request.Request(
        url, data=_json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
    )
    urllib.request.urlopen(req, timeout=15)


def dispatch_notifications(candidate, channels, subject, message, user, cc=None, wa_template="", sms_content_id="", job_id=None):
    """Send Email/WhatsApp/SMS to one candidate; log every attempt. Returns results list.
    `cc` — optional list of extra email addresses copied on the mail."""
    from django.core.mail import EmailMessage
    from .models import NotificationLog

    cc = [c.strip() for c in (cc or []) if c and c.strip()]

    results = []

    def log(channel, ok, err=""):
        note = f" (cc: {', '.join(cc)})" if (channel == "EMAIL" and cc) else ""
        NotificationLog.objects.create(
            candidate=candidate, channel=channel,
            status="SENT" if ok else "FAILED",
            subject=subject + note if channel == "EMAIL" else subject,
            message=message, error=err, sent_by=user,
        )
        results.append({"channel": channel, "status": "SENT" if ok else "FAILED", "error": err})

    if "EMAIL" in channels:
        from .models import EmailOptOut   # noqa: F401  (kept for the disabled opt-out check below)
        email = candidate.email or (candidate.user.email if candidate.user_id else None)
        if not email:
            log("EMAIL", False, "Candidate has no email address.")
        elif not subject:
            log("EMAIL", False, "Subject is required for email.")
        # --- TEMPORARILY DISABLED FOR TESTING (OUT-007 unsubscribe enforcement) ---
        # Re-enable by uncommenting the two lines below. While commented out,
        # email sends proceed as if no unsubscribe check existed; every other
        # part of the unsubscribe feature (model, token, API, page, admin
        # status field, email footer link) stays fully intact and functional.
        # elif EmailOptOut.objects.filter(email__iexact=email).exists():
        #     # OUT-007: unsubscribed recipients are skipped, and the skip itself is
        #     # recorded in the existing send history so it stays auditable.
        #     log("EMAIL", False, "Skipped (Candidate Unsubscribed)")
        # --- END TEMPORARILY DISABLED ---
        elif "console" in settings.EMAIL_BACKEND:
            log("EMAIL", False, "SMTP is not configured — set EMAIL_HOST_USER / EMAIL_HOST_PASSWORD in backend/.env. (Mail printed to server console only.)")
        else:
            try:
                # OUT-007: professional footer + a clickable, token-based
                # unsubscribe link (the URL never carries the email or any id).
                from django.core.mail import EmailMultiAlternatives
                from django.utils.html import escape
                from .unsubscribe_tokens import unsubscribe_url

                unsub = unsubscribe_url(email)
                body = message + (
                    "\n\n—\n"
                    "You are receiving this email because you applied through TA-ATS.\n\n"
                    "If you no longer wish to receive recruitment emails from us, you can "
                    "unsubscribe at any time:\n"
                    f"{unsub}"
                )
                # Plain text stays the primary body (unchanged sending path); the
                # HTML alternative is what renders "Unsubscribe" as a hyperlink.
                html_body = (
                    '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;'
                    'line-height:1.6;color:#212529;">'
                    f'<div style="white-space:pre-line;">{escape(message)}</div>'
                    '<hr style="border:none;border-top:1px solid #e9ebec;margin:24px 0 12px;">'
                    '<div style="font-size:12px;line-height:1.6;color:#878a99;">'
                    '<p style="margin:0 0 8px;">You are receiving this email because you '
                    'applied through TA-ATS.</p>'
                    '<p style="margin:0 0 12px;">If you no longer wish to receive recruitment '
                    'emails from us, you can unsubscribe at any time.</p>'
                    f'<p style="margin:0;"><a href="{escape(unsub)}" '
                    'style="color:#405189;font-weight:600;text-decoration:underline;">'
                    'Unsubscribe</a></p>'
                    '</div></div>'
                )
                msg = EmailMultiAlternatives(
                    subject, body, settings.DEFAULT_FROM_EMAIL, [email], cc=cc or None,
                )
                msg.attach_alternative(html_body, "text/html")
                msg.send(fail_silently=False)
                log("EMAIL", True)
            except Exception as e:
                log("EMAIL", False, str(e))

    if "WHATSAPP" in channels:
        phone = candidate.phone_number
        if not phone:
            log("WHATSAPP", False, "Candidate has no contact number.")
        else:
            from .gateways import send_whatsapp
            ok, err = send_whatsapp(phone, message, template_name=wa_template)
            log("WHATSAPP", ok, err)

    if "SMS" in channels:
        phone = candidate.phone_number
        if not phone:
            log("SMS", False, "Candidate has no contact number.")
        else:
            from .gateways import send_sms
            ok, err = send_sms(phone, message, content_id=sms_content_id)
            log("SMS", ok, err)

    # Synchronize JobApplication email_status if EMAIL was dispatched
    if "EMAIL" in channels:
        try:
            from django.utils import timezone
            from apps.pipeline.models import JobApplication
            email_res = next((r for r in results if r["channel"] == "EMAIL"), None)
            if email_res:
                apps_qs = JobApplication.objects.filter(candidate=candidate)
                if job_id:
                    apps_qs = apps_qs.filter(job_id=job_id)
                if email_res["status"] == "SENT":
                    apps_qs.update(
                        email_status=JobApplication.EmailStatus.SENT,
                        email_sent_at=timezone.now(),
                        updated_at=timezone.now(),
                    )
                elif email_res["status"] == "FAILED":
                    apps_qs.exclude(email_status=JobApplication.EmailStatus.SENT).update(
                        email_status=JobApplication.EmailStatus.FAILED,
                        updated_at=timezone.now(),
                    )
        except Exception:
            pass

    return results


class CandidateNotifyAPIView(APIView):
    """POST /candidates/<pk>/notify/ — send Email/WhatsApp/SMS to one candidate.
    Body: {channels: ["EMAIL","WHATSAPP","SMS"], subject, message}.
    Every attempt is recorded in NotificationLog (SENT / FAILED)."""
    permission_classes = [IsRecruiterOrAdmin]

    def post(self, request, pk):
        candidate = get_scoped_candidate(request, pk)

        channels = request.data.get("channels") or []
        subject = (request.data.get("subject") or "").strip()
        message = (request.data.get("message") or "").strip()
        cc = request.data.get("cc") or []
        if isinstance(cc, str):
            cc = [x for x in cc.replace(";", ",").split(",")]
        cc = [x.strip() for x in cc if x and x.strip()]
        import re as _re
        bad = [x for x in cc if not _re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", x)]
        if bad:
            return error_response(f"Invalid CC email(s): {', '.join(bad)}", status_code=400)
        if not channels:
            return error_response("Select at least one channel.", status_code=400)
        if not message:
            return error_response("Message is required.", status_code=400)

        wa_template = (request.data.get("wa_template") or "").strip()
        sms_content_id = (request.data.get("sms_content_id") or "").strip()
        results = dispatch_notifications(
            candidate, channels, subject, message, request.user, cc=cc,
            wa_template=wa_template, sms_content_id=sms_content_id,
        )
        sent = sum(1 for r in results if r["status"] == "SENT")
        failed = len(results) - sent
        return success_response(
            {"results": results, "sent": sent, "failed": failed},
            f"{sent} notification(s) sent, {failed} failed.",
        )


class CandidateBulkNotifyAPIView(APIView):
    """POST /candidates/notify-bulk/ — send to MANY candidates in one call.
    Body: {items: [{id, subject, message}], channels: ["EMAIL",...]}.
    Per-candidate subject/message allows {name}/{job} personalisation done client-side."""
    permission_classes = [IsRecruiterOrAdmin]

    def post(self, request):
        items = request.data.get("items") or []
        channels = request.data.get("channels") or []
        job_id = request.data.get("job_id")
        cc = request.data.get("cc") or []
        if isinstance(cc, str):
            cc = [x.strip() for x in cc.replace(";", ",").split(",") if x.strip()]
        if not items:
            return error_response("No candidates selected.", status_code=400)
        if not channels:
            return error_response("Select at least one channel.", status_code=400)

        summary, sent, failed = [], 0, 0
        # Scope the queryset once so a recruiter can't reach outside their JDs by
        # passing arbitrary ids in the bulk payload.
        allowed = scope_candidates(Candidate.objects.all(), request.user)
        for item in items:
            cand = allowed.filter(pk=item.get("id")).first()
            if not cand:
                summary.append({"id": item.get("id"), "results": [], "error": "Candidate not found"})
                failed += 1
                continue
            results = dispatch_notifications(
                cand, channels,
                (item.get("subject") or "").strip(),
                (item.get("message") or "").strip(),
                request.user,
                cc=cc,
                job_id=job_id,
            )
            sent += sum(1 for r in results if r["status"] == "SENT")
            failed += sum(1 for r in results if r["status"] == "FAILED")
            summary.append({"id": cand.id, "name": f"{cand.first_name} {cand.last_name}", "results": results})

        return success_response(
            {"summary": summary, "sent": sent, "failed": failed},
            f"{sent} notification(s) sent, {failed} failed.",
        )


class AllNotificationsListAPIView(APIView):
    """GET /candidates/notifications/ — recent notification log across ALL candidates."""
    permission_classes = [IsRecruiterOrAdmin]

    def get(self, request):
        from .models import NotificationLog
        # This log exposes candidate names/emails/phones, so restrict it to the
        # candidates the caller is allowed to see.
        allowed = scope_candidates(Candidate.objects.all(), request.user)
        logs = (
            NotificationLog.objects.filter(candidate__in=allowed)
            .select_related("candidate", "sent_by")[:200]
        )
        data = [{
            "id": l.id,
            "candidate_id": l.candidate_id,
            "candidate_name": f"{l.candidate.first_name} {l.candidate.last_name}",
            "email": l.candidate.email,
            "phone": l.candidate.phone_number,
            "channel": l.channel,
            "status": l.status,
            "subject": l.subject,
            "message": l.message,
            "error": l.error,
            "sent_by": l.sent_by.email if l.sent_by else None,
            "created_at": l.created_at.isoformat(),
        } for l in logs]
        return success_response(data)


class CandidateNotificationListAPIView(APIView):
    """GET /candidates/<pk>/notifications/ — history of notifications for one candidate."""
    permission_classes = [IsRecruiterOrAdmin]

    def get(self, request, pk):
        from .models import NotificationLog
        get_scoped_candidate(request, pk)
        logs = NotificationLog.objects.filter(candidate_id=pk).select_related("sent_by")[:100]
        data = [{
            "id": l.id,
            "channel": l.channel,
            "status": l.status,
            "subject": l.subject,
            "message": l.message,
            "error": l.error,
            "sent_by": l.sent_by.email if l.sent_by else None,
            "created_at": l.created_at.isoformat(),
        } for l in logs]
        return success_response(data)


class CandidateCommentAPIView(APIView):
    """GET  /candidates/<pk>/comments/  — list comments (newest first)
    POST /candidates/<pk>/comments/  — add a comment {comment: "..."}
    e.g. call outcomes: "Called candidate, connected — interested, will share docs."
    """
    permission_classes = [IsRecruiterOrAdmin]

    def get(self, request, pk):
        from .models import CandidateComment
        get_scoped_candidate(request, pk)
        logs = CandidateComment.objects.filter(candidate_id=pk).select_related("created_by")[:200]
        return success_response([{
            "id": l.id,
            "comment": l.comment,
            "by": (l.created_by.full_name or l.created_by.email) if l.created_by else None,
            "created_at": l.created_at.isoformat(),
        } for l in logs])

    def post(self, request, pk):
        from .models import CandidateComment
        candidate = get_scoped_candidate(request, pk)
        text = (request.data.get("comment") or "").strip()
        if not text:
            return error_response("Comment text is required.", status_code=400)
        l = CandidateComment.objects.create(candidate=candidate, comment=text, created_by=request.user)
        return success_response({
            "id": l.id, "comment": l.comment,
            "by": (request.user.full_name or request.user.email),
            "created_at": l.created_at.isoformat(),
        }, "Comment added", status_code=201)


class CandidateCommentDeleteAPIView(APIView):
    """DELETE /candidates/<pk>/comments/<comment_id>/ — author or admin only."""
    permission_classes = [IsRecruiterOrAdmin]

    def delete(self, request, pk, comment_id):
        from .models import CandidateComment
        get_scoped_candidate(request, pk)
        l = CandidateComment.objects.filter(candidate_id=pk, id=comment_id).first()
        if not l:
            return error_response("Comment not found.", status_code=404)
        if l.created_by_id != request.user.id and request.user.role != "ADMIN":
            return error_response("You can only delete your own comments.", status_code=403)
        l.delete()
        return success_response(None, "Comment deleted")


class CandidateBulkCVUploadAPIView(APIView):
    """POST multiple CV files (PDF/DOCX) → parse each and create a candidate.
    SRC-004: bulk multi-CV upload. Returns a per-file result summary."""
    permission_classes = [IsRecruiterOrAdmin]

    def post(self, request):
        import os, re, tempfile
        from decimal import Decimal, InvalidOperation
        from django.contrib.auth.models import Group  # noqa
        from .models import Candidate, Skill

        files = request.FILES.getlist("files") or request.FILES.getlist("file")
        if not files:
            return error_response("No CV files uploaded.", status_code=400)

        def _digits(p):
            return re.sub(r"\D", "", p or "")

        created, skipped, results = 0, 0, []

        for f in files:
            name = f.name
            ext = os.path.splitext(name)[1].lower()
            if ext not in (".pdf", ".docx"):
                skipped += 1
                results.append({"file": name, "status": "SKIPPED", "reason": "Only PDF/DOCX allowed"})
                continue
            if f.size == 0 or f.size > 10 * 1024 * 1024:
                skipped += 1
                results.append({"file": name, "status": "SKIPPED", "reason": "Empty or larger than 10MB"})
                continue

            # write to temp and parse
            tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
            try:
                for chunk in f.chunks():
                    tmp.write(chunk)
                tmp.close()
                parsed = ResumeParser.parse(tmp.name)
            except Exception as e:
                skipped += 1
                results.append({"file": name, "status": "FAILED", "reason": f"Parse error: {e}"})
                continue
            finally:
                try:
                    os.unlink(tmp.name)
                except OSError:
                    pass

            first = (parsed.get("first_name") or "").strip()
            last = (parsed.get("last_name") or "").strip()
            email = (parsed.get("email") or "").strip() or None
            phone = (parsed.get("phone_number") or "").strip() or None

            if not (first or email or phone):
                skipped += 1
                results.append({"file": name, "status": "FAILED", "reason": "Could not read name/email/phone from CV"})
                continue

            # duplicate check (email or phone)
            dup = None
            if email and Candidate.objects.filter(email__iexact=email).exists():
                dup = f"email {email}"
            elif phone and _digits(phone) and Candidate.objects.filter(phone_number__contains=_digits(phone)[-10:]).exists():
                dup = f"phone {phone}"
            if dup:
                skipped += 1
                results.append({"file": name, "status": "DUPLICATE", "reason": f"Already exists ({dup})"})
                continue

            def _dec(v):
                try:
                    return Decimal(str(v)) if v not in (None, "") else None
                except (InvalidOperation, TypeError):
                    return None

            try:
                cand = Candidate.objects.create(
                    first_name=first or "Unknown",
                    last_name=last or "-",
                    email=email,
                    phone_number=phone or "0000000000",
                    current_company=parsed.get("current_company") or None,
                    current_location=parsed.get("current_location") or None,
                    city=parsed.get("current_location") or None,
                    total_experience=_dec(parsed.get("total_experience")),
                    fresher=not bool(parsed.get("total_experience")),
                    status=Candidate.StatusChoices.PROFILE_COMPLETED,
                    source=Candidate.SourceChoices.UPLOAD,
                    created_by=request.user,
                )
                # skills
                for sk in (parsed.get("skills") or "").split(","):
                    sk = sk.strip()
                    if sk:
                        obj, _ = Skill.objects.get_or_create(name=sk)
                        cand.skills.add(obj)
                created += 1
                from apps.audit_logs.services import log_activity
                log_activity(request.user, "CANDIDATE_UPLOADED", f"Uploaded candidate via bulk CV: {cand.first_name} {cand.last_name}", request=request)
                results.append({"file": name, "status": "CREATED", "candidate_id": cand.id,
                                "name": f"{cand.first_name} {cand.last_name}".strip()})
            except Exception as e:
                skipped += 1
                results.append({"file": name, "status": "FAILED", "reason": str(e)})

        # Optional: assign the new candidates to a JD (upload from a job page)
        job_id = request.data.get("job")
        assigned = 0
        if job_id:
            ids = [r["candidate_id"] for r in results if r.get("candidate_id")]
            assigned = _assign_candidates_to_job(ids, job_id, request.user)

        return success_response(
            {"created": created, "skipped": skipped, "results": results, "assigned": assigned},
            f"{created} candidate(s) created from CVs, {skipped} skipped.",
        )


class UnsubscribeAPIView(APIView):
    """OUT-007: public opt-out.

    Token flow (used by the emails' "Unsubscribe" link — no PII in the URL):
        GET  /candidates/unsubscribe/?token=<t>   -> validate + describe (no change)
        POST /candidates/unsubscribe/  {token}    -> confirm the unsubscribe

    The legacy `?email=<addr>` form is still accepted so nothing that relied on
    it breaks. Tokens are signed, so a tampered/expired/forged token is rejected
    and nobody can unsubscribe an address they weren't sent a link for.
    """
    permission_classes = [permissions.AllowAny]

    def _resolve(self, token, email):
        """-> (email, error_response). Token wins when both are supplied."""
        from .unsubscribe_tokens import unsign_unsubscribe_token
        token = (token or "").strip()
        if token:
            resolved = unsign_unsubscribe_token(token)
            if not resolved:
                return None, error_response(
                    "This unsubscribe link is invalid or has expired.", status_code=400,
                )
            return resolved, None
        email = (email or "").strip()
        if not email:
            return None, error_response("Email is required.", status_code=400)
        return email, None

    def _do(self, email):
        from .models import EmailOptOut
        email = (email or "").strip()
        if not email:
            return error_response("Email is required.", status_code=400)
        EmailOptOut.objects.get_or_create(email=email)
        return success_response({"email": email}, "You have been unsubscribed from TA-ATS emails.")

    def get(self, request):
        """Validate the link and report status — never changes anything, so the
        confirmation page can be shown (and email clients that pre-fetch links
        can't unsubscribe anyone by accident)."""
        token = request.query_params.get("token")
        if token:
            from .models import EmailOptOut
            from .unsubscribe_tokens import mask_email
            email, err = self._resolve(token, None)
            if err:
                return err
            return success_response({
                "valid": True,
                "masked_email": mask_email(email),
                "already_unsubscribed": EmailOptOut.objects.filter(email__iexact=email).exists(),
            })
        # Legacy behaviour preserved: ?email= unsubscribes directly.
        return self._do(request.query_params.get("email"))

    def post(self, request):
        email, err = self._resolve(request.data.get("token"), request.data.get("email"))
        if err:
            return err
        return self._do(email)


class CandidateDetailAPIView(APIView):
    """GET /api/v1/candidates/<pk>/ — Candidate Detail
    PUT /api/v1/candidates/<pk>/ — Update Candidate
    DELETE /api/v1/candidates/<pk>/ — Soft Delete Candidate
    """
    def get(self, request, pk):
        candidate = Candidate.objects.filter(pk=pk, is_deleted=False).first()
        if not candidate:
            return error_response("Candidate not found.", status_code=404)
        # Argument order matters: can_view_candidate(user, candidate). Passing
        # these the other way round raised AttributeError on
        # `user.is_authenticated` (a Candidate has no such attribute), so every
        # request 500'd and the UI rendered its generic "Candidate not found".
        if not can_view_candidate(request.user, candidate):
            return error_response(CANDIDATE_FORBIDDEN_MESSAGE, status_code=403)
        serializer = CandidateDetailSerializer(candidate, context={"request": request})
        return success_response(serializer.data)

    def put(self, request, pk):
        candidate = Candidate.objects.filter(pk=pk, is_deleted=False).first()
        if not candidate:
            return error_response("Candidate not found.", status_code=404)
        # Argument order matters: can_view_candidate(user, candidate). Passing
        # these the other way round raised AttributeError on
        # `user.is_authenticated` (a Candidate has no such attribute), so every
        # request 500'd and the UI rendered its generic "Candidate not found".
        if not can_view_candidate(request.user, candidate):
            return error_response(CANDIDATE_FORBIDDEN_MESSAGE, status_code=403)
        serializer = CandidateCreateUpdateSerializer(candidate, data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
        # CandidateCreateUpdateSerializer is a plain Serializer with no update();
        # the nested collections + M2M are handled by the service (same path the
        # main update view uses), so pop them before passing the scalar fields.
        data = serializer.validated_data
        skills = data.pop("skills", None)
        languages = data.pop("languages", None)
        projects = data.pop("projects", None)
        references = data.pop("references", None)
        experiences = data.pop("experiences", None)
        educations = data.pop("educations", None)
        updated_candidate = CandidateService.update_candidate(
            candidate=candidate,
            candidate_data=data,
            skills=skills,
            languages=languages,
            projects=projects,
            references=references,
            experiences=experiences,
            educations=educations,
            updated_by=request.user,
        )

        # Optional: assign the candidate to one or more Job Order pipelines.
        # The edit form posts `assign_job_ids`; accept `job_ids` too. Additive —
        # never removes existing applications, only creates missing ones.
        job_ids = request.data.get("assign_job_ids") or request.data.get("job_ids") or []
        if isinstance(job_ids, (str, int)):
            job_ids = [job_ids]
        if job_ids:
            from apps.jobs.models import JobDescription
            from apps.pipeline.models import PipelineStage, JobApplication
            first_stage = PipelineStage.objects.filter(is_active=True).order_by("sort_order", "id").first()
            for jid in job_ids:
                try:
                    jid = int(jid)
                except (TypeError, ValueError):
                    continue
                job = JobDescription.objects.filter(id=jid).first()
                if job and not JobApplication.objects.filter(candidate=updated_candidate, job=job).exists():
                    JobApplication.objects.create(candidate=updated_candidate, job=job, stage=first_stage, created_by=request.user)

        out_serializer = CandidateDetailSerializer(updated_candidate, context={"request": request})
        return success_response(out_serializer.data, message="Candidate updated successfully.")

    def delete(self, request, pk):
        candidate = Candidate.objects.filter(pk=pk, is_deleted=False).first()
        if not candidate:
            return error_response("Candidate not found.", status_code=404)
        # Argument order matters: can_view_candidate(user, candidate). Passing
        # these the other way round raised AttributeError on
        # `user.is_authenticated` (a Candidate has no such attribute), so every
        # request 500'd and the UI rendered its generic "Candidate not found".
        if not can_view_candidate(request.user, candidate):
            return error_response(CANDIDATE_FORBIDDEN_MESSAGE, status_code=403)
        candidate.is_deleted = True
        candidate.updated_by = request.user
        candidate.save()
        return success_response(message="Candidate deleted successfully.")

