import os
import tempfile

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.db.models import Q, Count, CharField
from django.db.models.functions import Cast
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from drf_spectacular.utils import extend_schema

from core.pagination import DefaultPagination
from core.responses import success_response, error_response
from core.permissions import DjangoModelPermissionsWithView
from core.crypto import encrypt_id, decrypt_id
from .models import JobDescription, JDRecruiterAssignment, JobPosting
from .serializers import (
    JobDescriptionSerializer,
    RecruiterBriefSerializer,
    AssignedJDSerializer,
    JobPostingSerializer,
)
from .services import posting_service
from .services.integrations.factory import supported_channels
from .parser import extract_text, parse_jd, parse_qa

User = get_user_model()
RECRUITER_ROLE = "RECRUITER"


# Fields tracked for the JD change-history diff.
_AUDIT_FIELDS = [
    ("title", "Title"), ("department", "Department"), ("location", "Location"),
    ("experience_band", "Experience"), ("ctc_band", "CTC"), ("notice_period", "Notice Period"),
    ("shift", "Shift"), ("working_days", "Working Days"), ("num_positions", "No. of Positions"),
    ("certification", "Certification"), ("qualifications", "Qualifications"),
    ("must_have_skills", "Must-have Skills"), ("good_to_have_skills", "Good-to-have Skills"),
    ("status", "Status"), ("priority", "Priority"), ("client_id", "Client"),
]


def _log_jd(job, action, user, changes=""):
    """Record a JD change-history entry (JD-012)."""
    from .models import JDAuditLog
    try:
        JDAuditLog.objects.create(
            job=job, job_title=getattr(job, "title", "") or "",
            action=action, changes=changes, performed_by=user,
        )
    except Exception:
        pass  # audit logging must never break the main operation


def _diff_jd(before: dict, after) -> str:
    """Build a 'Field: old → new' summary of changed fields."""
    parts = []
    for f, label in _AUDIT_FIELDS:
        old = before.get(f)
        new = getattr(after, f, None)
        if str(old or "") != str(new or ""):
            parts.append(f"{label}: '{old or '—'}' → '{new or '—'}'")
    return "; ".join(parts)


def _notify_approver_jd_submitted(jd, approver, submitted_by):
    """Email a single assigned Hiring Manager that a JD needs their approval (JD-014).

    Uses the shared, reusable email service in the notifications app so future
    workflow emails (approval / rejection) share the same template. Only the
    passed `approver` is emailed — never other hiring managers. Never raises.
    """
    from django.utils import timezone
    from apps.notifications.services import send_email_notification

    to_email = getattr(approver, "email", "") or ""
    if not to_email:
        return

    hm_name = (getattr(approver, "full_name", "") or "").strip() or to_email
    pm_name = (getattr(submitted_by, "full_name", "") or "").strip() or getattr(submitted_by, "email", "") or "Unknown"
    pm_email = getattr(submitted_by, "email", "") or "—"
    department = (getattr(jd, "department", "") or "").strip()
    project = jd.client.name if getattr(jd, "client", None) else ""
    submitted_at = jd.submitted_for_approval_at or timezone.now()
    submitted_on = timezone.localtime(submitted_at).strftime("%d %b %Y, %I:%M %p")

    details = [
        ("Project Manager", pm_name),
        ("Email", pm_email),
        ("JD Title", jd.title),
    ]
    if project:
        details.append(("Project", project))
    if department:
        details.append(("Department", department))
    details += [
        ("Submitted On", submitted_on),
        ("Status", "Pending Hiring Manager Approval"),
    ]

    # The JD's raw ID is deliberately kept out of the URL too — it's encrypted
    # into an opaque token so the link doesn't expose/guess internal record IDs.
    view_url = f"{settings.FRONTEND_URL}/jobs?review={encrypt_id(jd.id)}"

    send_email_notification(
        to_emails=to_email,
        subject="JD Approval Request – Action Required",
        heading=f"Hello {hm_name},",
        intro="A new Job Description has been submitted to you for approval. Please review the Job Description and take the necessary action.",
        details=details,
        cta_label="View JD",
        cta_url=view_url,
    )


def _notify_creator_jd_decision(jd, decider, decision, remarks=""):
    """Email the JD's creator (Project Manager) the Hiring Manager's decision (JD-015).

    `decision` is "APPROVED" or "REJECTED". Sent only to `jd.created_by` — the
    Project Manager who created/submitted this specific JD — never to anyone
    else. Reuses the shared notifications email service. Never raises.
    """
    from django.utils import timezone
    from apps.notifications.services import send_email_notification

    creator = getattr(jd, "created_by", None)
    to_email = getattr(creator, "email", "") or ""
    if not to_email:
        return

    approved = decision.upper() == "APPROVED"
    pm_name = (getattr(creator, "full_name", "") or "").strip() or to_email
    hm_name = (getattr(decider, "full_name", "") or "").strip() or getattr(decider, "email", "") or "Unknown"
    hm_email = getattr(decider, "email", "") or "—"
    department = (getattr(jd, "department", "") or "").strip()
    project = jd.client.name if getattr(jd, "client", None) else ""
    decided_at = (jd.approved_at if approved else jd.rejected_at) or timezone.now()
    decided_on = timezone.localtime(decided_at).strftime("%d %b %Y, %I:%M %p")
    remarks = (remarks or "").strip()

    details = [
        ("Hiring Manager", hm_name),
        ("Email", hm_email),
        ("JD Title", jd.title),
        ("JD ID", f"JD-{jd.id:04d}"),
    ]
    if project:
        details.append(("Project", project))
    if department:
        details.append(("Department", department))
    details.append(("Decision", "Approved" if approved else "Rejected"))
    details.append((("Approved On" if approved else "Rejected On"), decided_on))
    if not approved and remarks:
        details.append(("Rejection Remarks", remarks))

    if approved:
        subject = "Your Job Description Has Been Approved"
        outro = "You can view the JD using the link below.\n\nThank you."
    else:
        subject = "Your Job Description Has Been Rejected"
        outro = "Please review the feedback, update the JD if necessary, and resubmit it.\n\nThank you."

    view_url = f"{settings.FRONTEND_URL}/jobs/{jd.id}"

    send_email_notification(
        to_emails=to_email,
        subject=subject,
        heading=f"Hello {pm_name},",
        intro="The Job Description you submitted has been reviewed by the assigned Hiring Manager.",
        details=details,
        cta_label="View JD",
        cta_url=view_url,
        outro=outro,
    )


def _is_manager(user):
    """Only admins and manager roles may assign recruiters.

    We gate on ROLE (not the `jobs.change_jobdescription` permission) because
    recruiters also hold that permission — they can edit JDs but must NOT be
    able to assign recruiters. Any role containing "MANAGER"
    (PROJECT_MANAGER, TA_MANAGER, HIRING_MANAGER, …) qualifies, plus ADMIN.
    """
    if not (user and user.is_authenticated):
        return False
    role = (user.role or "").upper()
    return role == "ADMIN" or "MANAGER" in role


def _eligible_approvers():
    """Users who may approve/reject a JD: the HIRING_MANAGER role, admins, or
    any role/user granted the JD-approval permission from Groups & Permissions."""
    return User.objects.filter(is_active=True).filter(
        Q(role__in=["HIRING_MANAGER", "ADMIN"])
        | Q(groups__permissions__codename="approve_reject_jd")
        | Q(user_permissions__codename="approve_reject_jd")
    ).distinct()


def _visible_jds(user):
    """The Job Descriptions a user is allowed to see — the single source of
    truth for JD visibility, applied by every JD-listing surface so they can
    never disagree:

      - Candidates            → only Published JDs.
      - Hiring Managers        → ONLY JDs assigned to them for approval (routed
                                 via an approval request of any status, the
                                 legacy current_approver, or one they already
                                 decided) plus any they created. Never the full
                                 list — even though their role contains
                                 "MANAGER" — so one HM cannot see another HM's
                                 assigned JDs.
      - Admins & other managers→ every JD (unchanged oversight access).
      - Recruiters / others    → JDs assigned to them or created by them.

    Assignment/permission filtering only — the approval workflow, roles,
    permissions and APIs are unchanged.
    """
    role = (getattr(user, "role", "") or "").upper()
    qs = JobDescription.objects.all()
    if role == "CANDIDATE":
        return qs.filter(status="Published")
    if role == "HIRING_MANAGER":
        return qs.filter(
            Q(approval_requests__approver=user)
            | Q(current_approver=user)
            | Q(approved_by=user)
            | Q(created_by=user)
        ).distinct()
    if _is_manager(user):
        return qs
    return qs.filter(
        Q(recruiter_assignments__recruiter=user) | Q(created_by=user)
    ).distinct()


class JobDescriptionViewSet(ModelViewSet):
    queryset = JobDescription.objects.all()
    serializer_class = JobDescriptionSerializer
    permission_classes = [DjangoModelPermissionsWithView]
    parser_classes = [MultiPartParser, FormParser, JSONParser]

    def get_queryset(self):
        # Visibility (role + assignment) is centralized in _visible_jds so this
        # list, retrieve, and every detail action (approve/reject/approval-
        # requests) share one rule. Prefetch to avoid N+1 queries.
        user = self.request.user
        qs = (
            _visible_jds(user)
            .select_related("created_by", "client", "current_approver", "approved_by")
            .prefetch_related("assigned_recruiters", "postings")
            # Annotate the count once in SQL instead of a COUNT(*) per row (N+1).
            .annotate(candidates_count=Count("applications", distinct=True))
        )
        ids_str = self.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:
                qs = qs.filter(id__in=ids_list)
        # Dashboard drill-down: "Total Requirements" card (Project Manager)
        # narrows to exactly the JDs *created by* the current user — the same
        # filter the PM dashboard's count uses (apps.dashboard.pm_views), so
        # the number on the card always equals the number of rows shown here.
        owner_param = (self.request.query_params.get("owner") or "").strip().lower()
        if owner_param == "me":
            qs = qs.filter(created_by=user)
        # Dashboard drill-down: "Assigned JDs"/"Active JDs" etc. (Recruiter
        # dashboard) narrows to JDs assigned to the current user — same
        # relation apps.dashboard.views.RecruiterDashboardView counts from.
        assigned_param = (self.request.query_params.get("assigned") or "").strip().lower()
        if assigned_param == "me":
            qs = qs.filter(assigned_recruiters=user)
        # Dashboard drill-down: TA dashboard's "Pending" card — not Closed and
        # nobody has joined yet. Mirrors apps.dashboard.ta_views._pending_jds
        # (which reports the same rows one at a time with a derived sub-status
        # label; here we only need the matching set of JDs).
        if (self.request.query_params.get("pending") or "") == "1":
            from apps.pipeline.models import PipelineStage
            qs = qs.exclude(status="Closed").exclude(
                applications__stage__outcome=PipelineStage.Outcome.WON
            ).distinct()
        # Dashboard drill-down: TA dashboard's period-scoped "Total
        # Requirements" card — JDs created within the selected calendar
        # window (apps.dashboard.ta_views / apps.dashboard.period_utils).
        # `period=` is resolved fresh, server-side, using the exact same
        # calendar-aligned window TARequirementsSummaryView's count uses —
        # avoids the frontend having to compute/pass "today" itself (which
        # could drift a day from the server's local date near midnight).
        # Explicit `created_from`/`created_to` still take precedence if given.
        period_param = (self.request.query_params.get("period") or "").strip().lower()
        created_from = self.request.query_params.get("created_from")
        created_to = self.request.query_params.get("created_to")
        if not created_from and not created_to and period_param:
            from apps.dashboard.period_utils import PERIODS, period_window
            if period_param in PERIODS:
                start, end, _, _ = period_window(period_param)
                created_from, created_to = start.isoformat(), end.isoformat()
        if created_from:
            qs = qs.filter(created_at__date__gte=created_from)
        if created_to:
            qs = qs.filter(created_at__date__lte=created_to)
        status_param = self.request.query_params.get("status")
        if status_param and status_param.upper() != "ALL":
            qs = qs.filter(status__iexact=status_param)
        # Explicit newest-first ordering — the annotate()/distinct() above can
        # otherwise interfere with the model's default Meta.ordering, and
        # -id breaks ties for JDs created in the same second.
        return qs.order_by("-created_at", "-id")

    def get_permissions(self):
        if self.action in ["submit_approval", "approve", "reject", "pending_approvals", "approval_history", "approvers", "approval_requests"]:
            return [IsAuthenticated()]
        if self.action in ["list", "retrieve"] and self.request.user and getattr(self.request.user, "role", "").upper() == "CANDIDATE":
            return [IsAuthenticated()]
        return super().get_permissions()


    @action(detail=False, methods=["post"], url_path="parse-document")
    def parse_document(self, request):
        """Upload a JD file (PDF/DOCX/TXT) → return best-effort form field values.
        Nothing is saved; parsed values pre-fill the form, and the file is attached
        only when the job itself is saved."""
        f = request.FILES.get("file")
        if not f:
            return error_response("No file uploaded.", status_code=400)
        ext = os.path.splitext(f.name)[1].lower()
        if ext not in (".pdf", ".doc", ".docx", ".txt"):
            return error_response(
                "Unsupported file type. Please upload a PDF, DOC, DOCX or TXT file.",
                status_code=400,
            )
        # Guard against empty / oversized uploads (max 10 MB).
        if f.size == 0:
            return error_response("The uploaded file is empty.", status_code=400)
        if f.size > 10 * 1024 * 1024:
            return error_response("File is too large. Maximum allowed size is 10 MB.", status_code=400)

        # Legacy .doc can't be text-extracted here — attach it, but skip auto-fill.
        if ext == ".doc":
            return success_response(
                parse_jd(""),
                "Legacy .doc files can't be auto-read. The file will be attached — please fill the fields manually.",
            )

        tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
        try:
            for chunk in f.chunks():
                tmp.write(chunk)
            tmp.close()
            text = extract_text(tmp.name)
            if not text.strip():
                return error_response(
                    "Could not read any text from this document. It may be scanned/image-only — "
                    "please fill the fields manually or upload a text-based PDF/DOCX.",
                    status_code=422,
                )
            return success_response(parse_jd(text), "Document parsed")
        except Exception as e:
            return error_response(f"Could not parse document: {e}", status_code=500)
        finally:
            try:
                os.unlink(tmp.name)
            except OSError:
                pass


    @action(detail=False, methods=["post"], url_path="parse-questions")
    def parse_questions(self, request):
        """Upload a filled Q&A template (PDF/DOCX/TXT) → return the extracted
        screening questions & answers. Nothing is saved; the parsed pairs
        pre-fill the JD Questions & Answers section."""
        f = request.FILES.get("file")
        if not f:
            return error_response("No file uploaded.", status_code=400)
        ext = os.path.splitext(f.name)[1].lower()
        if ext not in (".pdf", ".docx", ".txt"):
            return error_response(
                "Unsupported file type. Please upload a PDF, DOCX or TXT file.",
                status_code=400,
            )
        if f.size == 0:
            return error_response("The uploaded file is empty.", status_code=400)
        if f.size > 10 * 1024 * 1024:
            return error_response("File is too large. Maximum allowed size is 10 MB.", status_code=400)

        tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
        try:
            for chunk in f.chunks():
                tmp.write(chunk)
            tmp.close()
            text = extract_text(tmp.name)
            if not text.strip():
                return error_response(
                    "Could not read any text from this document. Please upload a "
                    "text-based PDF, DOCX or TXT file.",
                    status_code=422,
                )
            questions = parse_qa(text)
            if not questions:
                return error_response(
                    'No questions found. Use the sample template format — each pair on '
                    'its own lines, prefixed with "Q:" and "A:".',
                    status_code=422,
                )
            return success_response({"questions": questions}, f"Parsed {len(questions)} question(s)")
        except Exception as e:
            return error_response(f"Could not parse document: {e}", status_code=500)
        finally:
            try:
                os.unlink(tmp.name)
            except OSError:
                pass

    @action(
        detail=False,
        methods=["get"],
        url_path="recruiters",
        permission_classes=[IsAuthenticated],
    )
    def recruiters(self, request):
        """List all active recruiters (for the assignment dropdown).

        Managers/admins by role, plus any role granted the jd-recruiter-assignment
        permissions from Groups & Permissions."""
        if not (
            _is_manager(request.user)
            or request.user.has_perm("jobs.add_jdrecruiterassignment")
            or request.user.has_perm("jobs.view_jdrecruiterassignment")
        ):
            return error_response("You are not allowed to view recruiters.", status_code=403)
        qs = (
            User.objects.filter(role=RECRUITER_ROLE, is_active=True)
            .order_by("full_name", "email")
            .values("id", "email", "full_name", "role", "phone", "location", "experience_years")
        )
        search = (request.query_params.get("search") or "").strip()
        if search:
            qs = qs.filter(Q(full_name__icontains=search) | Q(email__icontains=search))

        exclude_job = request.query_params.get("exclude_job")
        if exclude_job:
            qs = qs.exclude(jd_assignments__jd_id=exclude_job)

        assigned_job = request.query_params.get("assigned_job")
        if assigned_job:
            qs = qs.filter(jd_assignments__jd_id=assigned_job)

        # 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(qs, request, view=self)
            return paginator.get_paginated_response(
                RecruiterBriefSerializer(list(page), many=True).data
            )
        return success_response(RecruiterBriefSerializer(list(qs), many=True).data)

    @action(
        detail=True,
        methods=["post"],
        url_path="assign-recruiters",
        permission_classes=[IsAuthenticated],
    )
    def assign_recruiters(self, request, pk=None):
        """Assign a set of recruiters to a JD (replace semantics).

        Body: {"recruiter_ids": [1, 2, 3]}. The given list becomes the JD's
        complete recruiter set — new ones are added, omitted ones are removed.

        Allowed for managers/admins by role, plus any role granted the
        jd-recruiter-assignment permission from Groups & Permissions.
        """
        if not (
            _is_manager(request.user)
            or request.user.has_perm("jobs.add_jdrecruiterassignment")
            or request.user.has_perm("jobs.change_jdrecruiterassignment")
        ):
            return error_response("You are not allowed to assign recruiters.", status_code=403)

        jd = self.get_object()  # 404 if JD does not exist

        recruiter_ids = request.data.get("recruiter_ids")
        if not isinstance(recruiter_ids, list):
            return error_response("`recruiter_ids` must be a list.", status_code=400)

        # Normalise to a unique set of ints.
        try:
            target_ids = {int(rid) for rid in recruiter_ids}
        except (TypeError, ValueError):
            return error_response("`recruiter_ids` must contain integer IDs.", status_code=400)

        if target_ids:
            found = User.objects.filter(id__in=target_ids)
            found_ids = set(found.values_list("id", flat=True))

            missing = target_ids - found_ids
            if missing:
                return error_response(
                    f"Recruiter(s) not found: {sorted(missing)}", status_code=404
                )

            non_recruiters = [
                u.email for u in found if u.role != RECRUITER_ROLE or not u.is_active
            ]
            if non_recruiters:
                return error_response(
                    f"Only active recruiters can be assigned: {non_recruiters}",
                    status_code=400,
                )

        # Replace-set: add new, remove dropped, keep existing (no duplicates).
        current_ids = set(
            jd.recruiter_assignments.values_list("recruiter_id", flat=True)
        )
        to_add = target_ids - current_ids
        to_remove = current_ids - target_ids

        from apps.notifications.services import send_notification, send_jd_assignment_email
        jd_code = f"JD-{jd.id:04d}"
        assigned_by_name = request.user.full_name or request.user.email

        if to_remove:
            unassigned_users = list(User.objects.filter(id__in=to_remove))
            for r_user in unassigned_users:
                send_notification(
                    user=r_user,
                    title="Job Unassigned",
                    message=f"You have been unassigned from {jd.title} ({jd_code}) by {assigned_by_name}.",
                    type_code="jd_unassigned",
                    metadata={
                        "jd_id": jd.id,
                        "jd_title": jd.title,
                        "assigned_by": assigned_by_name
                    }
                )
            jd.recruiter_assignments.filter(recruiter_id__in=to_remove).delete()

        for rid in to_add:
            assignment, created = JDRecruiterAssignment.objects.get_or_create(
                jd=jd, recruiter_id=rid, defaults={"assigned_by": request.user}
            )
            if created:
                r_user = User.objects.filter(id=rid).first()
                if r_user:
                    send_notification(
                        user=r_user,
                        title="New Job Assigned",
                        message=f"You have been assigned to {jd.title} ({jd_code}) by {assigned_by_name}.",
                        type_code="jd_assigned",
                        metadata={
                            "jd_id": jd.id,
                            "jd_title": jd.title,
                            "assigned_by": assigned_by_name
                        }
                    )
                    try:
                        send_jd_assignment_email(
                            recruiter=r_user,
                            jd=jd,
                            assigned_by=request.user,
                            assigned_at=assignment.assigned_at,
                        )
                    except Exception as e:
                        logger.exception("Error sending assignment email to recruiter %s: %s", r_user.email, e)

        assigned = (
            User.objects.filter(id__in=target_ids)
            .order_by("full_name", "email")
            .values("id", "email", "full_name", "role", "phone", "location", "experience_years")
        )
        names = ", ".join(a["full_name"] or a["email"] for a in assigned) or "none"
        _log_jd(jd, "RECRUITERS_ASSIGNED", request.user, f"Assigned recruiters: {names}")
        from apps.audit_logs.services import log_activity
        log_activity(request.user, "JD_ASSIGNED", f"Assigned recruiters {names} to JD: {jd.title}", request=request)
        return success_response(
            RecruiterBriefSerializer(list(assigned), many=True).data,
            "Recruiters assigned successfully",
        )

    @action(
        detail=False,
        methods=["get"],
        url_path="my-assigned",
        permission_classes=[IsAuthenticated],
    )
    def my_assigned(self, request):
        """Return only the JDs assigned to the logged-in recruiter."""
        assignments = (
            JDRecruiterAssignment.objects.filter(recruiter=request.user)
            .select_related("jd", "jd__client")
            .order_by("-assigned_at")
        )
        from_date = request.query_params.get("from_date")
        to_date = request.query_params.get("to_date")
        if from_date:
            assignments = assignments.filter(jd__created_at__date__gte=from_date)
        if to_date:
            assignments = assignments.filter(jd__created_at__date__lte=to_date)
        # Optional client / status narrowing, mirroring the recruiter dashboard
        # filters. Additive — omitting them keeps the previous behaviour.
        client_param = (request.query_params.get("client") or "").strip()
        if client_param:
            assignments = (
                assignments.filter(jd__client_id=client_param)
                if client_param.isdigit() else assignments.none()
            )
        status_param = (request.query_params.get("status") or "").strip()
        if status_param and status_param.lower() != "all":
            assignments = assignments.filter(jd__status=status_param)
        return success_response(AssignedJDSerializer(assignments, many=True).data)

    @action(
        detail=True,
        methods=["post"],
        url_path="post",
        permission_classes=[IsAuthenticated],
    )
    def post_to_channels(self, request, pk=None):
        """Publish an active (Published) JD to the selected job boards.

        Body: {"channels": ["LINKEDIN", "NAUKRI", "CAREER_PORTAL"]}.
        Channels already POSTED are skipped ("ALREADY_POSTED"); a failing
        provider never blocks the rest.

        Allowed for managers/admins by role, plus any role granted the
        job-posting permission from Groups & Permissions.
        """
        if not (_is_manager(request.user) or request.user.has_perm("jobs.add_jobposting")):
            return error_response("Only admins and managers can post jobs.", status_code=403)

        job = self.get_object()
        if job.status != "Published":
            return error_response(
                f"Only Published Job Descriptions can be posted. This JD is {job.status}.",
                status_code=400,
            )

        channels = request.data.get("channels")
        if not isinstance(channels, list) or not channels:
            return error_response("`channels` must be a non-empty list.", status_code=400)

        valid = set(supported_channels())
        unknown = [c for c in channels if (c or "").upper() not in valid]
        if unknown:
            return error_response(
                f"Unsupported channel(s): {unknown}. Supported: {sorted(valid)}",
                status_code=400,
            )

        # force = re-post even if already POSTED (an "Update" from the UI).
        if request.data.get("force"):
            JobPosting.objects.filter(
                job=job, channel__in=[(c or "").upper() for c in channels]
            ).delete()

        results = posting_service.post_job(job, channels, request.user)
        posted = [r["channel"] for r in results if r.get("status") in ("POSTED", "ALREADY_POSTED")]
        _log_jd(job, "POSTED", request.user, f"Posted to: {', '.join(posted) or 'none (all failed)'}")
        return success_response({"results": results}, "Posting complete")

    @action(
        detail=True,
        methods=["post"],
        url_path="generate-agent-prompt",
        permission_classes=[IsAuthenticated],
    )
    def generate_agent_prompt(self, request, pk=None):
        """POST /api/v1/jobs/<id>/generate-agent-prompt/ — Generate Hunar AI agent prompt & config JSON from JD using Gemini."""
        import json
        job = self.get_object()

        title_str = job.title or "Position"
        client_name = ""
        if hasattr(job, "client") and job.client:
            client_name = job.client.name
        elif hasattr(job, "client_name"):
            client_name = getattr(job, "client_name", "") or ""

        dept_str = job.department or ""
        exp_str = job.experience_band or ""
        skills_str = job.must_have_skills or ""
        good_skills_str = job.good_to_have_skills or ""
        qual_str = job.qualifications or ""
        location_str = job.location or ""
        ctc_str = job.ctc_band or ""
        notice_str = job.notice_period or ""
        shift_str = job.shift or ""
        work_details_str = job.work_details or ""
        questions_str = str(job.questions) if job.questions else ""

        # Default fallback values if LLM is unavailable
        agent_prompt = f"""You are Neha, an AI Recruiter conducting an initial telephone screening for the {title_str} position.

Objective:
Screen the candidate for the {title_str} role. Evaluate their total experience ({exp_str or 'relevant'}), technical proficiency in {skills_str or 'required skills'}, communication skills, and availability.

Instructions:
1. Warmly introduce yourself as Neha, AI Talent Specialist.
2. Verify candidate identity and interest in the {title_str} position.
3. Ask about their background, total experience, and current role.
4. Assess technical competency in key skills: {skills_str or 'required skills'}.
5. Inquire about current CTC, expected CTC, notice period, and preferred work location ({location_str or 'any'}).
6. Maintain a professional, polite, and engaging tone throughout the call."""

        objective = f"Telephone screening for {title_str} candidate application."
        introduction = f"Hello! I am Neha, AI Talent Specialist calling regarding your application for the {title_str} role."
        result_prompt = "Evaluate the candidate's answers and generate an overall score (0-100), classification (Strong Match / Potential Match / Not a Match), recommendation (QUALIFIED / REVIEW / NOT_QUALIFIED), key strengths, weaknesses, and a concise summary."
        result_schema = {
            "type": "object",
            "properties": {
                "overall_score": {"type": "integer"},
                "classification": {"type": "string"},
                "recommendation": {"type": "string"},
                "summary": {"type": "string"},
                "strengths": {"type": "array", "items": {"type": "string"}},
                "weaknesses": {"type": "array", "items": {"type": "string"}}
            }
        }

        # Build comprehensive JD prompt input for Gemini LLM using all available JD fields
        prompt_input = (
            f"You are an expert AI Voice Screening Specialist. Generate the complete configuration JSON for a telephone screening voice agent based on the following Job Description (JD):\n\n"
            f"--- JOB DESCRIPTION METADATA ---\n"
            f"Job Title: {title_str}\n"
            f"Client: {client_name}\n"
            f"Department: {dept_str}\n"
            f"Location: {location_str}\n"
            f"Experience Required: {exp_str}\n"
            f"CTC Band: {ctc_str}\n"
            f"Notice Period: {notice_str}\n"
            f"Employment Type / Shift: {shift_str}\n"
            f"Qualifications: {qual_str}\n"
            f"Must-Have Skills: {skills_str}\n"
            f"Good-to-Have Skills: {good_skills_str}\n"
            f"Key Screening Questions: {questions_str}\n"
            f"Responsibilities & Work Details:\n{work_details_str}\n\n"
            f"--- INSTRUCTIONS ---\n"
            f"Generate a JSON object with the following keys:\n"
            f'1. "objective": A concise single-sentence business objective for the screening call.\n'
            f'2. "introduction": The exact opening spoken line by Neha (AI Talent Specialist).\n'
            f'3. "agent_prompt": A comprehensive, multi-paragraph system prompt for the AI voice agent specifying persona (Neha), step-by-step interview structure, skill evaluation guidelines, candidate questions, and tone.\n'
            f'4. "result_prompt": Evaluation instructions for assessing candidate answers and producing score/recommendation.\n'
            f'5. "result_schema": A JSON schema object specifying expected fields (overall_score, classification, recommendation, summary, strengths, weaknesses).\n\n'
            f"Return ONLY valid JSON with these keys."
        )

        try:
            from apps.llm.service import call_llm
            llm_res = call_llm(prompt_input, provider_type="GEMINI", purpose="generate_ai_agent_prompt", user=request.user)
            if llm_res.get("ok") and llm_res.get("text"):
                raw_text = llm_res["text"].strip()
                if raw_text.startswith("```"):
                    raw_text = raw_text.split("```")[1]
                    if raw_text.startswith("json"):
                        raw_text = raw_text[4:]
                    raw_text = raw_text.strip()
                try:
                    parsed = json.loads(raw_text)
                    if isinstance(parsed, dict):
                        if parsed.get("agent_prompt"):
                            agent_prompt = str(parsed["agent_prompt"]).strip()
                        if parsed.get("objective"):
                            objective = str(parsed["objective"]).strip()
                        if parsed.get("introduction"):
                            introduction = str(parsed["introduction"]).strip()
                        if parsed.get("result_prompt"):
                            result_prompt = str(parsed["result_prompt"]).strip()
                        if parsed.get("result_schema") and isinstance(parsed["result_schema"], dict):
                            result_schema = parsed["result_schema"]
                except Exception:
                    if len(raw_text) > 50:
                        agent_prompt = raw_text
        except Exception as exc:
            logger.warning("[GEMINI] Prompt generation LLM call error: %s", exc)

        payload = {
            "name": f"AI Agent - {title_str[:40]}",
            "language": "ENGLISH",
            "voice_persona": "NEHA",
            "persona_name": "Neha",
            "objective": objective,
            "introduction": introduction,
            "agent_prompt": agent_prompt,
            "result_prompt": result_prompt,
            "result_schema": result_schema,
        }
        return success_response(payload, "Agent prompt generated successfully.")

    @action(
        detail=True,
        methods=["post"],
        url_path="link-agent",
        permission_classes=[IsAuthenticated],
    )
    def link_agent(self, request, pk=None):
        """POST /api/v1/jobs/<id>/link-agent/ — Link a Hunar AI Agent ID to this JD."""
        job = self.get_object()
        agent_id = str(request.data.get("agent_id") or "").strip()
        if not agent_id:
            return error_response("Pass agent_id in request body.", status_code=400)
        job.hunar_agent_id = agent_id
        job.save(update_fields=["hunar_agent_id", "updated_at"])
        return success_response({"id": job.id, "hunar_agent_id": job.hunar_agent_id}, "Agent linked to JD successfully.")

    @action(
        detail=True,
        methods=["get"],
        url_path="posting-status",
        permission_classes=[IsAuthenticated],
    )
    def posting_status(self, request, pk=None):
        """Per-channel posting status for a JD (visible to any authenticated viewer)."""
        job = self.get_object()
        postings = job.postings.select_related("posted_by").all()
        return success_response(JobPostingSerializer(postings, many=True).data)

    @action(
        detail=True,
        methods=["get"],
        url_path="publish-preview",
        permission_classes=[IsAuthenticated],
    )
    def publish_preview(self, request, pk=None):
        """Pre-publish preview shown in the Publish dialog — status + application URLs.

        GET /api/v1/jobs/<pk>/publish-preview/[?channels=LINKEDIN,SMS]

        Read-only and additive: nothing is created, posted or changed. Returns

          - the JD's existing lifecycle status (`status`, `jd_status`) untouched, and
          - `publication_status`: whether this JD has actually been published to
            any channel yet ("Draft" until the first successful post, "Published"
            after), derived from the existing JobPosting rows.
          - `links`: the public application URL per platform, each carrying only
            an encrypted tracking token (no readable `?source=`), so the
            recruiter can verify the exact URLs before publishing.

        Without ?channels, every platform in the integration registry is
        previewed — a newly registered platform appears here automatically.

        Restricted to the same users who may publish (see post_to_channels):
        generating a link means minting a tracking token, so a candidate must
        never be able to mint one and choose their own source.
        """
        from .tracking_tokens import build_application_url

        if not (_is_manager(request.user) or request.user.has_perm("jobs.add_jobposting")):
            return error_response("Only admins and managers can preview job posting links.", status_code=403)

        job = self.get_object()

        requested = (request.query_params.get("channels") or "").strip()
        valid = supported_channels()
        if requested:
            wanted = [c.strip().upper() for c in requested.split(",") if c.strip()]
            unknown = [c for c in wanted if c not in valid]
            if unknown:
                return error_response(
                    f"Unsupported channel(s): {unknown}. Supported: {sorted(valid)}",
                    status_code=400,
                )
            channels = wanted
        else:
            channels = list(valid)

        labels = dict(JobPosting.Channel.choices)
        existing = {p.channel: p for p in job.postings.all()}

        links = [{
            "channel": ch,
            "channel_label": labels.get(ch, ch.replace("_", " ").title()),
            # Read-only preview of the exact URL that will be distributed.
            "url": build_application_url(job, ch),
            "posting_status": existing[ch].status if ch in existing else None,
            "is_posted": ch in existing and existing[ch].status == JobPosting.Status.POSTED,
        } for ch in channels]

        published_anywhere = any(
            p.status == JobPosting.Status.POSTED for p in existing.values()
        )
        serializer = self.get_serializer(job)
        return success_response({
            "jd_id": job.id,
            "jd_code": f"JD-{job.id:04d}",
            "title": job.title,
            # Existing lifecycle values, unchanged.
            "status": job.status,
            "jd_status": serializer.data.get("jd_status"),
            # Publish/posting state of this JD — "Draft" before it goes out.
            "publication_status": "published" if published_anywhere else "draft",
            "publication_status_label": "Published" if published_anywhere else "Draft",
            "links": links,
        })

    @action(
        detail=True,
        methods=["get"],
        url_path="audit-log",
        permission_classes=[IsAuthenticated],
    )
    def audit_log(self, request, pk=None):
        """JD change history (JD-012) — every create/update/status/assign/post, newest first."""
        from .models import JDAuditLog
        logs = JDAuditLog.objects.filter(job_id=pk).select_related("performed_by")[:200]
        data = [{
            "id": l.id,
            "action": l.action,
            "changes": l.changes,
            "by": (l.performed_by.full_name or l.performed_by.email) if l.performed_by else None,
            "created_at": l.created_at.isoformat(),
        } for l in logs]
        return success_response(data)

    @action(
        detail=True,
        methods=["post"],
        url_path="copy",
        permission_classes=[IsAuthenticated],
    )
    def copy_jd(self, request, pk=None):
        """Duplicate a JD with all its fields, questions, rank weights, attachment
        and recruiter assignments. The copy is always created as a Draft."""
        from django.db import transaction
        from django.core.files.base import ContentFile

        original = self.get_object()

        with transaction.atomic():
            base_title = f"{original.title} Copy"
            title = base_title
            counter = 2
            while JobDescription.objects.filter(
                title__iexact=title.strip(),
                location__iexact=original.location.strip(),
                client=original.client,
            ).exists():
                title = f"{base_title} {counter}"
                counter += 1

            new_jd = JobDescription(
                title=title,
                department=original.department,
                location=original.location,
                experience_band=original.experience_band,
                ctc_band=original.ctc_band,
                notice_period=original.notice_period,
                shift=original.shift,
                must_have_skills=original.must_have_skills,
                good_to_have_skills=original.good_to_have_skills,
                qualifications=original.qualifications,
                working_days=original.working_days,
                num_positions=original.num_positions,
                certification=original.certification,
                questions=original.questions,
                rank_weights=original.rank_weights,
                work_details=original.work_details,
                status="Draft",
                priority=original.priority,
                created_by=request.user,
                client=original.client,
            )
            new_jd.save()

            if original.attachment:
                try:
                    original_file = original.attachment
                    if original_file.storage.exists(original_file.name):
                        original_file.open('rb')
                        new_file_content = ContentFile(original_file.read())
                        original_file.close()
                        filename = os.path.basename(original_file.name)
                        new_jd.attachment.save(filename, new_file_content, save=True)
                except Exception:
                    pass

            from apps.notifications.services import send_jd_assignment_email
            for assignment in original.recruiter_assignments.all():
                new_assignment = JDRecruiterAssignment.objects.create(
                    jd=new_jd,
                    recruiter=assignment.recruiter,
                    assigned_by=request.user,
                )
                try:
                    send_jd_assignment_email(
                        recruiter=assignment.recruiter,
                        jd=new_jd,
                        assigned_by=request.user,
                        assigned_at=new_assignment.assigned_at,
                    )
                except Exception as e:
                    logger.exception("Error sending assignment email to recruiter %s on copy: %s", assignment.recruiter.email, e)

            _log_jd(new_jd, "CREATED", request.user, f"Copied from JD #{original.id} ({original.title})")

        serializer = self.get_serializer(new_jd)
        return success_response(
            serializer.data,
            "JD copied successfully.",
            status_code=status.HTTP_201_CREATED
        )


    def perform_create(self, serializer):
        job = serializer.save(created_by=self.request.user)
        _log_jd(job, "CREATED", self.request.user, f"Created as {job.status}")
        from apps.audit_logs.services import log_activity
        log_activity(self.request.user, "JD_CREATED", f"Created Job Description: {job.title}", request=self.request)
        return job

    def _apply_list_filters(self, queryset, params):
        """Server-side equivalents of the JD list page's filters, so the table
        can paginate one page at a time instead of downloading every JD.

        Supported query params (all optional):
          jd_name   -> title icontains
          jd_id     -> id (as text) icontains  (also matches the JD-#### code)
          skills    -> comma-separated; matched across must_have / good_to_have
                       / work_details. skill_match=AND requires all, else any.
          status    -> published | closed | draft | pending_approval
        """
        jd_name = (params.get("jd_name") or params.get("search") or "").strip()
        if jd_name:
            queryset = queryset.filter(title__icontains=jd_name)

        jd_id = (params.get("jd_id") or "").strip()
        if jd_id:
            digits = "".join(ch for ch in jd_id if ch.isdigit())
            if digits:
                queryset = queryset.annotate(
                    _id_str=Cast("id", CharField())
                ).filter(_id_str__icontains=digits)

        skills_raw = (params.get("skills") or "").strip()
        if skills_raw:
            skills = [s.strip() for s in skills_raw.split(",") if s.strip()]
            match = (params.get("skill_match") or "OR").upper()
            if match == "AND":
                for s in skills:
                    queryset = queryset.filter(
                        Q(must_have_skills__icontains=s)
                        | Q(good_to_have_skills__icontains=s)
                        | Q(work_details__icontains=s)
                    )
            else:
                q = Q()
                for s in skills:
                    q |= (
                        Q(must_have_skills__icontains=s)
                        | Q(good_to_have_skills__icontains=s)
                        | Q(work_details__icontains=s)
                    )
                if q:
                    queryset = queryset.filter(q)

        status_f = (params.get("status") or "").strip().lower().replace(" ", "_")
        if status_f and status_f != "all":
            if status_f == "published":
                queryset = queryset.filter(status="Published")
            elif status_f == "closed":
                queryset = queryset.filter(status="Closed")
            elif status_f == "pending_approval":
                queryset = queryset.filter(approval_status="PENDING_APPROVAL").exclude(
                    status__in=["Published", "Closed"]
                )
            elif status_f == "draft":
                queryset = queryset.exclude(status__in=["Published", "Closed"]).exclude(
                    approval_status="PENDING_APPROVAL"
                )
        return queryset

    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())
        queryset = self._apply_list_filters(queryset, request.query_params)
        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        serializer = self.get_serializer(queryset, many=True)
        return success_response(serializer.data)

    @action(detail=True, methods=["get"], url_path="apply-urls")
    def apply_urls(self, request, pk=None):
        """Per-channel tracked apply URLs for this JD, so outreach links capture
        the source (WhatsApp/Email/SMS/Telegram/LinkedIn/Naukri/Referral) when a
        candidate applies — the same opaque ?t=<token> scheme LinkedIn posts use."""
        from apps.jobs.tracking_tokens import build_application_url
        job = self.get_object()
        channels = ["WHATSAPP", "EMAIL", "SMS", "TELEGRAM", "LINKEDIN", "NAUKRI", "REFERRAL"]
        urls = {ch: build_application_url(job, ch) for ch in channels}
        return success_response(urls)

    def retrieve(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance)
        return success_response(serializer.data)

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        return success_response(
            serializer.data,
            "Job created successfully",
            status_code=status.HTTP_201_CREATED
        )

    def update(self, request, *args, **kwargs):
        partial = kwargs.pop("partial", False)
        instance = self.get_object()
        # snapshot before change for the diff
        before = {f: getattr(instance, f, None) for f, _ in _AUDIT_FIELDS}
        old_status = instance.status
        # Status lifecycle (locked until the JD is approved; approval publishes
        # it and unlocks the field) is enforced in JobDescriptionSerializer.
        serializer = self.get_serializer(instance, data=request.data, partial=partial)
        serializer.is_valid(raise_exception=True)
        job = serializer.save()
        changes = _diff_jd(before, job)
        if changes:
            action = "STATUS_CHANGED" if job.status != old_status else "UPDATED"
            _log_jd(job, action, request.user, changes)
        return success_response(serializer.data, "Job updated successfully")

    def destroy(self, request, *args, **kwargs):
        instance = self.get_object()
        if (instance.status or "").lower() != "draft":
            return error_response(
                "Only Draft job descriptions can be deleted.",
                status_code=400,
            )
        _log_jd(instance, "DELETED", request.user, f"Deleted (was {instance.status})")
        instance.delete()
        return success_response(None, "Job deleted successfully")

    @action(detail=False, methods=["get"], url_path="approvers", permission_classes=[IsAuthenticated])
    def approvers(self, request):
        """Hiring managers / JD-approval permission holders — the recipient
        list for the 'Send for Approval' popup."""
        users = _eligible_approvers().order_by("full_name", "email")
        # Display-only fields (department / employee_id / status) added for the
        # approver-selection data table. Read-only serialization — the approval
        # workflow, permissions, notifications and email triggers are unchanged.
        return success_response([
            {
                "id": u.id,
                "name": u.full_name or u.email.split("@")[0],
                "email": u.email,
                "role": u.role,
                "department": u.department or "",
                "employee_id": u.employee_id or "",
                "status": "Active" if u.is_active else "Inactive",
            }
            for u in users
        ])

    @action(detail=True, methods=["get"], url_path="approval-requests", permission_classes=[IsAuthenticated])
    def approval_requests(self, request, pk=None):
        """GET /jds/<id>/approval-requests/ — read-only send history for this JD
        (one row per approver an approval request was sent to), latest first."""
        jd = self.get_object()
        from .models import JDApprovalRequest
        rows = (
            JDApprovalRequest.objects.filter(job_description=jd)
            .select_related("approver", "sent_by")
            .order_by("-sent_at", "-id")
        )
        data = [
            {
                "id": r.id,
                "approver_id": r.approver_id,
                "approver_name": (r.approver.full_name or r.approver.email.split("@")[0]) if r.approver else "",
                "approver_email": r.approver.email if r.approver else "",
                "sent_by": (r.sent_by.full_name or r.sent_by.email.split("@")[0]) if r.sent_by else "System",
                "sent_at": r.sent_at.isoformat() if r.sent_at else None,
                "status": r.status,
                "acted_at": r.acted_at.isoformat() if r.acted_at else None,
                "comments": r.comments or "",
            }
            for r in rows
        ]
        return success_response(data)

    @action(detail=False, methods=["get"], url_path="resolve-review-token", permission_classes=[IsAuthenticated])
    def resolve_review_token(self, request):
        """Decrypt a JD approval-email 'View JD' link token back to a job ID.

        The email's CTA button encodes the JD's primary key (see
        _notify_approver_jd_submitted) so the raw ID isn't exposed in the URL.
        The frontend calls this once the user is authenticated to resolve the
        token and open the right JD's review modal. Scoped through the same
        _visible_jds queryset as everything else, so a token for a JD outside
        the caller's visibility behaves like "not found".
        """
        token = request.query_params.get("token", "")
        job_id = decrypt_id(token)
        if job_id is None:
            return error_response("Invalid or expired link.", status_code=400)
        if not self.get_queryset().filter(pk=job_id).exists():
            return error_response("Job Description not found.", status_code=404)
        return success_response({"id": job_id})

    @action(detail=True, methods=["post"], url_path="submit-approval", permission_classes=[IsAuthenticated])
    def submit_approval(self, request, pk=None):
        from django.utils import timezone
        jd = self.get_object()
        
        # Permissions check
        role = (request.user.role or "").upper()
        if not (role in ["ADMIN", "TA_MANAGER", "PROJECT_MANAGER"] or request.user == jd.created_by or request.user.has_perm("jobs.submit_for_approval")):
            return error_response("Permission denied.", status_code=403)
            
        # Approvers: the popup sends `approver_ids` (one or more hiring
        # managers). `approver_id` (single) is kept for backward compatibility;
        # with neither, an approver is auto-picked (legacy behaviour).
        approver_ids = request.data.get("approver_ids")
        if not approver_ids and request.data.get("approver_id"):
            approver_ids = [request.data["approver_id"]]

        eligible = _eligible_approvers()
        if approver_ids:
            if not isinstance(approver_ids, list):
                return error_response("`approver_ids` must be a list.", status_code=400)
            approvers = list(eligible.filter(id__in=approver_ids).distinct())
            if len(approvers) != len(set(approver_ids)):
                return error_response(
                    "One or more selected approvers are not valid Hiring Managers / approvers.",
                    status_code=400,
                )
        else:
            # Prefer a hiring manager; fall back to an admin (admins can also
            # approve JDs) so submission isn't blocked when no HIRING_MANAGER
            # user exists yet. Avoid routing the JD to the submitter themselves
            # unless they are the only eligible approver.
            fallback = (
                User.objects.filter(role="HIRING_MANAGER", is_active=True)
                .exclude(pk=request.user.pk)
                .first()
                or User.objects.filter(role="ADMIN", is_active=True)
                .exclude(pk=request.user.pk)
                .first()
                or User.objects.filter(role__in=["HIRING_MANAGER", "ADMIN"], is_active=True).first()
            )
            approvers = [fallback] if fallback else []

        if not approvers:
            return error_response(
                "No approver available: select at least one Hiring Manager "
                "(or create a user with the HIRING_MANAGER role).",
                status_code=400,
            )

        prev_status = jd.status
        jd.status = "pending_approval"
        jd.approval_status = "PENDING_APPROVAL"
        jd.submitted_for_approval_at = timezone.now()
        jd.current_approver = approvers[0]
        jd.save()

        # One approval-request row per selected approver (a re-submission
        # replaces the previous round's still-pending requests).
        from .models import JDApprovalRequest
        JDApprovalRequest.objects.filter(job_description=jd, status="PENDING").delete()
        for approver in approvers:
            JDApprovalRequest.objects.create(
                job_description=jd, approver=approver, sent_by=request.user,
            )
        
        # History
        from .models import JDApprovalHistory
        JDApprovalHistory.objects.create(
            job_description=jd,
            action="SUBMITTED",
            action_by=request.user,
            remarks=request.data.get("remarks", "Submitted for approval"),
            previous_status=prev_status,
            new_status="pending_approval"
        )
        
        # Notify every selected approver — and ONLY the selected approvers.
        jd_code = f"JD-{jd.id:04d}"
        from apps.notifications.models import Notification
        from apps.notifications.services import send_notification, broadcast_event
        submitted_by = request.user.full_name or request.user.email

        # Prevent duplicate notifications for the same approval event: clear any
        # still-unread "pending approval" notifications for this JD before
        # creating fresh ones. A genuine re-submission therefore produces a new
        # notification (as required), while an accidental double-submit or a
        # lingering earlier round does not pile up duplicates.
        Notification.objects.filter(
            type="jd_pending_approval",
            is_read=False,
            metadata__jd_id=jd.id,
        ).delete()

        for approver in approvers:
            send_notification(
                user=approver,
                title="JD Approval Request",
                message=(
                    f'A Job Description titled "{jd.title}" has been submitted to you '
                    f"for approval by {submitted_by}. Please review and take the "
                    f"appropriate action."
                ),
                type_code="jd_pending_approval",
                metadata={
                    "jd_id": jd.id,
                    "jd_code": jd_code,
                    "client": jd.client.name if jd.client else "",
                    "submitted_by": submitted_by,
                    "submitted_at": jd.submitted_for_approval_at.isoformat() if jd.submitted_for_approval_at else "",
                    # Deep-link the recipient straight to this JD (Header resolves jd_id).
                    "approval_link": f"/jobs?review={jd.id}",
                    "jd_link": f"/jobs/{jd.id}",
                }
            )
            # Email only this assigned Hiring Manager (never other approvers).
            # Fired after the JD is saved; failures are logged, never raised.
            _notify_approver_jd_submitted(jd, approver, request.user)

        # Real-time WebSocket event
        broadcast_event("jd_submitted", {
            "jd_id": jd.id,
            "jd_code": jd_code,
            "submitted_by": submitted_by
        })
        broadcast_event("jd_pending_approval", {
            "jd_id": jd.id,
            "jd_code": jd_code,
            "approver_ids": [a.id for a in approvers]
        })
        
        return success_response(self.get_serializer(jd).data, "JD submitted for approval")

    @action(detail=True, methods=["post"], url_path="approve", permission_classes=[IsAuthenticated])
    def approve(self, request, pk=None):
        from django.utils import timezone
        jd = self.get_object()
        
        # Permissions check
        role = (request.user.role or "").upper()
        if not (role in ["ADMIN", "HIRING_MANAGER"] or request.user.has_perm("jobs.approve_reject_jd")):
            return error_response("Permission denied. Only Hiring Managers can approve.", status_code=403)

        from .models import JDApprovalRequest

        # First-approver-wins (multi-approver synchronization). Once any
        # authorized approver has approved this JD, the workflow is complete. A
        # late or duplicate approval — e.g. another selected Hiring Manager
        # acting on a stale page — must NOT overwrite the recorded decision.
        # Report the existing approved state so the client refreshes to it.
        if jd.approval_status == "APPROVED" or jd.status == "Published":
            JDApprovalRequest.objects.filter(
                job_description=jd, approver=request.user, status="PENDING"
            ).update(status="AUTO_CLOSED", acted_at=timezone.now())
            approver_name = (jd.approved_by.full_name or jd.approved_by.email) if jd.approved_by else "another approver"
            when = jd.approved_at.strftime("%d %b %Y, %I:%M %p") if jd.approved_at else ""
            msg = f"This Job Description has already been approved by {approver_name}" + (f" on {when}." if when else ".")
            return error_response(msg, status_code=409)

        prev_status = jd.status
        jd.approval_status = "APPROVED"
        jd.approved_at = timezone.now()
        jd.approved_by = request.user
        # Approval publishes the JD automatically — from here on the status
        # field is unlocked and can be managed by authorized users.
        jd.status = "Published"
        jd.save()

        # Record the decision on this approver's request row (popup flow)
        JDApprovalRequest.objects.filter(
            job_description=jd, approver=request.user, status="PENDING"
        ).update(status="APPROVED", acted_at=timezone.now(), comments=request.data.get("remarks", ""))

        # Multi-approver synchronization: finalize every OTHER still-pending
        # request for this JD. Those approvers can no longer action it, and the
        # Send History now shows "Already Approved" for them instead of Pending.
        JDApprovalRequest.objects.filter(
            job_description=jd, status="PENDING"
        ).exclude(approver=request.user).update(
            status="AUTO_CLOSED",
            acted_at=timezone.now(),
            comments=f"Already approved by {request.user.full_name or request.user.email}",
        )

        # History
        from .models import JDApprovalHistory
        JDApprovalHistory.objects.create(
            job_description=jd,
            action="APPROVED",
            action_by=request.user,
            remarks=request.data.get("remarks", "Approved"),
            previous_status=prev_status,
            new_status="Published"
        )
        
        # Notification to creator
        jd_code = f"JD-{jd.id:04d}"
        from apps.notifications.services import send_notification, broadcast_event
        if jd.created_by:
            approver_name = request.user.full_name or request.user.email
            remarks = (request.data.get("remarks") or "").strip()
            send_notification(
                user=jd.created_by,
                title="JD Approved",
                message=f"Your Job Description '{jd.title}' ({jd_code}) has been approved by {approver_name}."
                        + (f' Comments: "{remarks}"' if remarks and remarks != "Approved" else ""),
                type_code="jd_approved",
                metadata={
                    "jd_id": jd.id,
                    "jd_code": jd_code,
                    "approved_by": approver_name,
                    "approved_at": jd.approved_at.isoformat() if jd.approved_at else "",
                    "comments": remarks,
                }
            )
            # Email the Project Manager (creator) the approval decision.
            _notify_creator_jd_decision(jd, request.user, "APPROVED", remarks)

        # Real-time WebSocket event
        broadcast_event("jd_approved", {
            "jd_id": jd.id,
            "jd_code": jd_code,
            "approved_by": request.user.full_name or request.user.email
        })
        
        return success_response(self.get_serializer(jd).data, "JD approved successfully")

    @action(detail=True, methods=["post"], url_path="reject", permission_classes=[IsAuthenticated])
    def reject(self, request, pk=None):
        from django.utils import timezone
        jd = self.get_object()
        
        # Permissions check
        role = (request.user.role or "").upper()
        if not (role in ["ADMIN", "HIRING_MANAGER"] or request.user.has_perm("jobs.approve_reject_jd")):
            return error_response("Permission denied. Only Hiring Managers can reject.", status_code=403)

        from .models import JDApprovalRequest

        # First-approver-wins (multi-approver synchronization). If another
        # approver already approved this JD, a late rejection must NOT undo that
        # final decision — auto-close this approver's pending row and report the
        # completed approval instead.
        if jd.approval_status == "APPROVED" or jd.status == "Published":
            JDApprovalRequest.objects.filter(
                job_description=jd, approver=request.user, status="PENDING"
            ).update(status="AUTO_CLOSED", acted_at=timezone.now())
            approver_name = (jd.approved_by.full_name or jd.approved_by.email) if jd.approved_by else "another approver"
            when = jd.approved_at.strftime("%d %b %Y, %I:%M %p") if jd.approved_at else ""
            msg = f"This Job Description has already been approved by {approver_name}" + (f" on {when}" if when else "") + " and can no longer be rejected."
            return error_response(msg, status_code=409)

        reason = request.data.get("reason")
        if not reason:
            return error_response("Rejection reason is required.", status_code=400)

        prev_status = jd.status
        jd.status = "Draft"
        jd.approval_status = "REJECTED"
        jd.rejected_at = timezone.now()
        jd.rejection_reason = reason
        jd.save()

        # Record the decision on this approver's request row (popup flow)
        JDApprovalRequest.objects.filter(
            job_description=jd, approver=request.user, status="PENDING"
        ).update(status="REJECTED", acted_at=timezone.now(), comments=reason)

        # History
        from .models import JDApprovalHistory
        JDApprovalHistory.objects.create(
            job_description=jd,
            action="REJECTED",
            action_by=request.user,
            remarks=reason,
            previous_status=prev_status,
            new_status="Draft"
        )
        
        # Notification to creator
        jd_code = f"JD-{jd.id:04d}"
        from apps.notifications.services import send_notification, broadcast_event
        if jd.created_by:
            approver_name = request.user.full_name or request.user.email
            send_notification(
                user=jd.created_by,
                title="JD Rejected",
                message=f"Your Job Description '{jd.title}' ({jd_code}) has been rejected by {approver_name}."
                        f' Comments: "{reason}"',
                type_code="jd_rejected",
                metadata={
                    "jd_id": jd.id,
                    "jd_code": jd_code,
                    "rejected_by": approver_name,
                    "rejected_at": jd.rejected_at.isoformat() if jd.rejected_at else "",
                    "reason": reason
                }
            )
            # Email the Project Manager (creator) the rejection decision + remarks.
            _notify_creator_jd_decision(jd, request.user, "REJECTED", reason)

        # Real-time WebSocket event
        broadcast_event("jd_rejected", {
            "jd_id": jd.id,
            "jd_code": jd_code,
            "rejected_by": request.user.full_name or request.user.email,
            "reason": reason
        })
        
        return success_response(self.get_serializer(jd).data, "JD rejected successfully")

    @action(detail=False, methods=["get"], url_path="pending-approvals", permission_classes=[IsAuthenticated])
    def pending_approvals(self, request):
        from rest_framework.response import Response
        # Permissions check
        role = (request.user.role or "").upper()
        if not (role in ["ADMIN", "HIRING_MANAGER"] or request.user.has_perm("jobs.view_pending_approvals")):
            return error_response("Permission denied.", status_code=403)
            
        qs = JobDescription.objects.filter(approval_status="PENDING_APPROVAL")
        if role != "ADMIN":
            # Visible to anyone the JD was routed to: the legacy single
            # current_approver, or any approver with a pending request row.
            qs = qs.filter(
                Q(current_approver=request.user)
                | Q(approval_requests__approver=request.user, approval_requests__status="PENDING")
            ).distinct()
            
        serializer = self.get_serializer(qs, many=True)
        return Response(serializer.data)

    @action(detail=False, methods=["get"], url_path="approval-history", permission_classes=[IsAuthenticated])
    def approval_history(self, request):
        from rest_framework.response import Response
        # Permissions check
        role = (request.user.role or "").upper()
        if not (role in ["ADMIN", "HIRING_MANAGER", "TA_MANAGER", "RECRUITER"] or request.user.has_perm("jobs.view_approval_history")):
            return error_response("Permission denied.", status_code=403)
            
        from .models import JDApprovalHistory
        from .serializers import JDApprovalHistorySerializer
        
        qs = JDApprovalHistory.objects.all()
        if role == "HIRING_MANAGER":
            qs = qs.filter(Q(action_by=request.user) | Q(job_description__current_approver=request.user) | Q(job_description__approved_by=request.user))
        elif role == "TA_MANAGER":
            qs = qs.filter(job_description__created_by=request.user)
            
        serializer = JDApprovalHistorySerializer(qs, many=True)
        return Response(serializer.data)



class LinkedInAuthView(APIView):
    """JD-006 LinkedIn OAuth helper. Example
    GET  /api/v1/jobs/linkedin/auth-url/?author_urn=urn:li:organization:123  -> authorize URL
    GET  /api/v1/jobs/linkedin/callback/?code=...&state=...                  -> exchange + store token
    GET  /api/v1/jobs/linkedin/status/                                       -> is a token stored?
    """
    permission_classes = [IsAuthenticated]

    def get(self, request):
        import os
        from urllib.parse import urlencode
        from apps.jobs.models import LinkedInAuth
        action = request.path.rstrip("/").split("/")[-1]
        client_id = getattr(settings, "LINKEDIN_CLIENT_ID", "") or os.getenv("LINKEDIN_CLIENT_ID", "")
        redirect_uri = getattr(settings, "LINKEDIN_REDIRECT_URI", "") or os.getenv(
            "LINKEDIN_REDIRECT_URI", "http://localhost:8000/api/v1/jobs/linkedin/callback/")

        if action == "status":
            a = LinkedInAuth.current()
            return success_response({"connected": bool(a and a.access_token), "author_urn": a.author_urn if a else ""})

        if action == "connect-token":
            # Paste a token from LinkedIn's Token Generator; we auto-detect the person URN via /userinfo.
            import json as _json
            import urllib.request as _r
            token = (request.query_params.get("token") or request.data.get("token") or "").strip()
            author = (request.query_params.get("author_urn") or request.data.get("author_urn") or "").strip()
            if not token:
                return error_response("Provide ?token=<access token>.", status_code=400)
            if not author:
                try:
                    req = _r.Request("https://api.linkedin.com/v2/userinfo",
                                     headers={"Authorization": f"Bearer {token}"})
                    with _r.urlopen(req, timeout=15) as resp:
                        info = _json.loads(resp.read().decode())
                    if info.get("sub"):
                        author = f"urn:li:person:{info['sub']}"
                except Exception as e:
                    return error_response(f"Could not auto-detect profile URN: {e}. Pass ?author_urn= explicitly.", status_code=400)
            LinkedInAuth.objects.create(access_token=token, author_urn=author, scope="w_member_social")
            return success_response({"connected": True, "author_urn": author}, "LinkedIn connected.")

        if action == "auth-url":
            author = request.query_params.get("author_urn", "")
            if not client_id:
                return error_response("LINKEDIN_CLIENT_ID not set in .env.", status_code=400)
            params = {
                "response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri,
                "scope": "w_organization_social r_organization_social", "state": author or "x",
            }
            return success_response({"auth_url": f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}"})

        # callback
        import urllib.request
        import urllib.parse
        import json as _json
        code = request.query_params.get("code")
        state = request.query_params.get("state", "")
        if not code:
            return error_response("Missing ?code from LinkedIn.", status_code=400)
        client_secret = getattr(settings, "LINKEDIN_CLIENT_SECRET", "") or os.getenv("LINKEDIN_CLIENT_SECRET", "")
        data = urllib.parse.urlencode({
            "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
            "client_id": client_id, "client_secret": client_secret,
        }).encode()
        try:
            req = urllib.request.Request("https://www.linkedin.com/oauth/v2/accessToken", data=data,
                                         headers={"Content-Type": "application/x-www-form-urlencoded"})
            with urllib.request.urlopen(req, timeout=20) as resp:
                tok = _json.loads(resp.read().decode())
        except Exception as e:
            return error_response(f"Token exchange failed: {e}", status_code=400)
        from datetime import timedelta
        from django.utils import timezone
        LinkedInAuth.objects.create(
            access_token=tok.get("access_token", ""),
            author_urn=state if state.startswith("urn:") else "",
            scope=tok.get("scope", ""),
            expires_at=timezone.now() + timedelta(seconds=int(tok.get("expires_in") or 0)),
        )
        return success_response({"connected": True}, "LinkedIn connected. You can close this tab and post JDs now.")


class JDApprovalMonitoringView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        role = (request.user.role or "").upper()
        # Candidates never see approval logs; every other role may (needed for
        # the client-scoped dashboard view).
        if role == "CANDIDATE" and not request.user.has_perm("jobs.view_jd_approval_monitoring"):
            return error_response("Permission denied.", status_code=403)

        # Same assignment/role visibility as the JD list: admins and other
        # managers see all JDs here, but a Hiring Manager sees ONLY the JDs
        # assigned to them — they cannot view another HM's JDs by opening the
        # client-scoped dashboard. Enforced server-side on the logged-in user.
        jds = _visible_jds(request.user).order_by("-created_at")
        client_param = request.query_params.get("client")
        if client_param:
            try:
                jds = jds.filter(client_id=int(client_param))
            except (ValueError, TypeError):
                return error_response("Invalid client id", status_code=400)
        data = []
        for jd in jds:
            jd_code = f"JD-{jd.id:04d}"
            
            created_by_name = jd.created_by.full_name or jd.created_by.email if jd.created_by else "None"
            
            hiring_manager_name = "None"
            hm = jd.current_approver or jd.approved_by
            if hm:
                hiring_manager_name = hm.full_name or hm.email
                
            data.append({
                "id": jd.id,
                "jd_code": jd_code,
                "title": jd.title,
                "created_by_name": created_by_name,
                "hiring_manager_name": hiring_manager_name,
                "approval_status": "APPROVED" if jd.status == "Published" else jd.approval_status,
                "submitted_for_approval_at": jd.submitted_for_approval_at.isoformat() if jd.submitted_for_approval_at else None,
                "approved_at": jd.approved_at.isoformat() if jd.approved_at else None,
                "rejected_at": jd.rejected_at.isoformat() if jd.rejected_at else None,
                "rejection_reason": jd.rejection_reason
            })
            
        from rest_framework.response import Response
        return Response(data)

