import logging

from django.conf import settings
from django.db.models import Avg
from django.http import HttpResponse
from django.utils import timezone
from rest_framework.permissions import AllowAny, BasePermission
from rest_framework.views import APIView

from core.responses import error_response, success_response

from apps.jobs.models import JobDescription
from apps.pipeline.models import JobApplication

from . import services
from .agent_directory import describe_agent, get_agent_directory
from .models import AICall
from .providers import ProviderNotConfigured, get_provider
from .report import render_call_report
from .security import HEADER as SIGNATURE_HEADER, verify_signature

logger = logging.getLogger(__name__)


class CanRunScreening(BasePermission):
    """AI screening is a recruiter workflow — recruiters, managers, admins."""

    message = "You do not have permission to run AI screening."

    def has_permission(self, request, view):
        user = request.user
        if not (user and user.is_authenticated):
            return False
        role = (user.role or "").upper()
        return user.is_superuser or role == "ADMIN" or role == "RECRUITER" or "MANAGER" in role


RUNNING = (AICall.Status.QUEUED, AICall.Status.DIALING, AICall.Status.IN_PROGRESS)


def _recording_url(call) -> str:
    """Hunar's audio recording URL for a call, or "" when it has none.

    Read from the value the sync already stores in
    `CallEvaluation.rubric["hunar_metadata"]["recording_url"]` — this only surfaces
    existing data, it never fetches or derives anything.
    """
    evaluation = getattr(call, "evaluation", None)
    rubric = getattr(evaluation, "rubric", None)
    if not isinstance(rubric, dict):
        return ""
    meta = rubric.get("hunar_metadata")
    if not isinstance(meta, dict):
        return ""
    return str(meta.get("recording_url") or "")


def _call_dict(call):
    """Serialise a call for the UI.

    A call that did not connect and complete carries NO screening result: score,
    recommendation, summary and transcript are withheld regardless of what the
    provider sent, so the UI can never present a result for an unsuccessful call.
    The status, timings and error reason are still returned — that is what tells
    the recruiter why there is nothing to show.
    """
    completed = call.status == AICall.Status.COMPLETED
    return {
        "call_id": call.id,
        "status": call.status,
        "attempts": call.attempts,
        "score": call.score if completed else None,
        "recommendation": call.recommendation if completed else "",
        "duration": call.duration,
        "started_at": call.started_at.strftime("%d/%m/%Y %H:%M") if call.started_at else "",
        "ended_at": call.ended_at.strftime("%d/%m/%Y %H:%M") if call.ended_at else "",
        "has_transcript": bool(call.transcript) and completed,
        "summary": call.summary if completed else "",
        "error_message": call.error_message,
        # True only for a connected call that finished — the single flag the UI
        # uses to decide whether a Call Summary exists at all.
        "is_completed": completed,
    }


class StartScreeningView(APIView):
    """POST /ai-calls/start/  { "job": <id>, "candidate_ids": [..] }

    Places a call directly via the Hunar API — returns immediately."""

    permission_classes = [CanRunScreening]

    def post(self, request):
        import httpx
        from apps.candidates.models import Candidate
        from apps.pipeline.models import PipelineStage

        job = JobDescription.objects.filter(pk=request.data.get("job")).first()
        if not job:
            return error_response("Job description not found.", status_code=404)
        candidate_ids = request.data.get("candidate_ids") or []
        if not isinstance(candidate_ids, list) or not candidate_ids:
            return error_response("Pass candidate_ids as a non-empty list.")

        import os
        HUNAR_BASE_URL = (os.getenv("HUNAR_BASE_URL", "") or getattr(settings, "HUNAR_BASE_URL", "https://api.voice.hunar.ai") or "").strip()
        HUNAR_API_KEY = (os.getenv("HUNAR_API_KEY", "") or getattr(settings, "HUNAR_API_KEY", "") or "").strip().strip('"').strip("'")
        HUNAR_AGENT_ID = (os.getenv("HUNAR_AGENT_ID", "") or getattr(settings, "HUNAR_AGENT_ID", "d57196d8-9043-4472-92eb-4b41b38caeb5") or "").strip().strip('"').strip("'")

        api_key = HUNAR_API_KEY
        agent_id = HUNAR_AGENT_ID
        hunar_base_url = HUNAR_BASE_URL

        if not api_key:
            return error_response("HUNAR_API_KEY is not configured.", status_code=503)

        queued = []
        errors = []

        for cid in candidate_ids:
            candidate = Candidate.objects.filter(pk=cid).first()
            if not candidate:
                errors.append(f"Candidate {cid} not found")
                continue
            if not (candidate.phone_number or "").strip():
                errors.append(f"{candidate.first_name} {candidate.last_name}: no phone number")
                continue

            application, _ = JobApplication.objects.get_or_create(
                candidate=candidate,
                job=job,
                defaults={"stage": PipelineStage.objects.filter(code="ai_calling", is_active=True).first(), "created_by": request.user},
            )
            call, _ = AICall.objects.get_or_create(
                candidate=candidate,
                job=job,
                defaults={"application": application, "created_by": request.user},
            )

            if call.status in (AICall.Status.QUEUED, AICall.Status.DIALING, AICall.Status.IN_PROGRESS) and call.provider_call_id:
                errors.append(f"{candidate.first_name} {candidate.last_name}: call already running")
                continue

            call.application = application
            call.attempts += 1
            call.started_at = timezone.now()
            call.created_by = request.user
            call.error_message = ""
            # Remember which Hunar agent ran this call so the Call History tab can
            # show it. Additive: nothing reads agent_variables for control flow.
            call.agent_variables = {**(call.agent_variables or {}), "agent_id": agent_id}
            call.save()

            phone = candidate.phone_number.strip()
            if not phone.startswith("+"):
                phone = f"+91{phone}" if not phone.startswith("91") else f"+{phone}"

            request_id = f"ats-{call.id}-{call.attempts}"
            payload = {
                "agent_id": agent_id,
                "callee_name": f"{candidate.first_name} {candidate.last_name}".strip(),
                "mobile_number": phone,
                "custom_data": {
                    "date": timezone.now().strftime("%Y-%m-%d"),
                    "email": candidate.email or "",
                    "time": timezone.now().strftime("%I:%M %p"),
                    "timezone": "Asia/Kolkata",
                    "ai_call_id": str(call.id),
                },
                "request_id": request_id,
            }

            url = hunar_base_url.rstrip("/") + "/external/v1/calls/"

            try:
                response = httpx.post(
                    url,
                    json=payload,
                    headers={
                        "Authorization": f"Bearer {api_key}",
                        "x-api-key": api_key,
                        "api-key": api_key,
                    },
                    timeout=30,
                )
                if response.status_code >= 400:
                    raise RuntimeError(f"Hunar API returned status {response.status_code}: {response.text[:200]}")
                
                data = response.json()
                # Hunar's create response names the call `id` (a UUID). Reading
                # only `call_id` fell through to our own request_id, and the detail
                # endpoint then rejected it with HTTP 422 — which is why finished
                # calls stayed on Dialing. `id` is checked first now; request_id
                # remains the last resort so a live call is never lost.
                inner = data.get("data") if isinstance(data.get("data"), dict) else {}
                call_id = (
                    data.get("id") or data.get("call_id") or data.get("call_uuid")
                    or inner.get("id") or inner.get("call_id")
                    or request_id
                )
                call.provider_call_id = str(call_id)
                call.status = AICall.Status.DIALING
                call.save()

                # Move application stage to AI Calling
                stage = PipelineStage.objects.filter(code="ai_calling", is_active=True).first()
                if stage:
                    application.stage = stage
                    application.save(update_fields=["stage", "updated_at"])

                # Log activity
                from apps.audit_logs.services import log_activity
                log_activity(
                    request.user,
                    "AI_CALL_STARTED",
                    f"AI Call started for candidate: {candidate.first_name} {candidate.last_name} (JD: {job.title})"
                )

                queued.append(call)

            except httpx.ConnectTimeout as e:
                err_msg = f"Connection to Hunar API timed out: {e}"
                call.status = AICall.Status.FAILED
                call.error_message = err_msg
                call.save()
                errors.append(f"{candidate.first_name} {candidate.last_name}: {err_msg}")
            except httpx.ConnectError as e:
                err_msg = f"Connection failed to Hunar API: {e}"
                call.status = AICall.Status.FAILED
                call.error_message = err_msg
                call.save()
                errors.append(f"{candidate.first_name} {candidate.last_name}: {err_msg}")
            except Exception as e:
                call.status = AICall.Status.FAILED
                call.error_message = str(e)[:1000]
                call.save()
                errors.append(f"{candidate.first_name} {candidate.last_name}: {str(e)}")

        if len(queued) == 0 and errors:
            return error_response(
                message=errors[0],
                errors=errors,
                status_code=502,
            )

        return success_response(
            {"queued": len(queued), "errors": errors},
            message=f"AI screening started for {len(queued)} candidate(s)",
        )


class ScreeningStatusView(APIView):
    """GET /ai-calls/?job=<id> — one row per candidate in the JD's pipeline,
    joined with their AI call state. The frontend polls this for live updates."""

    permission_classes = [CanRunScreening]

    def get(self, request):
        job_id = request.query_params.get("job")
        if not job_id:
            return error_response("Pass ?job=<id>.")

        calls = {
            c.candidate_id: c
            for c in AICall.objects.filter(job_id=job_id)
        }
        rows = []
        applications = (
            JobApplication.objects.filter(job_id=job_id, candidate__is_deleted=False)
            .select_related("candidate", "stage")
            .order_by("-created_at")
        )
        for app in applications:
            cand = app.candidate
            call = calls.get(cand.id)
            if call and call.provider_call_id and not call.provider_call_id.startswith("mock-") and call.status in RUNNING:
                services.sync_hunar_call_details(call)

            rows.append({
                "candidate_id": cand.id,
                "name": f"{cand.first_name} {cand.last_name}".strip(),
                "email": cand.email or "",
                "phone": cand.phone_number or "",
                "experience": float(cand.total_experience) if cand.total_experience else 0,
                "stage": app.stage.name if app.stage else "",
                "application_score": app.score,
                "call": _call_dict(call) if call else None,
            })

        running = sum(1 for r in rows if r["call"] and r["call"]["status"] in RUNNING)
        return success_response({"rows": rows, "running": running})

class CandidateCallsView(APIView):
    """GET /ai-calls/candidate/<candidate_id>/ — calling context for ONE candidate.

    Additive and read-only. Every other read endpoint here is job-scoped
    (`?job=`), so the Candidate Details page had no way to find a candidate's
    calls. This exposes exactly that, reusing the same `_call_dict` shape and the
    same `CanRunScreening` permission — no new integration, no schema change.

    The call itself is still placed through the existing POST /ai-calls/start/,
    so a candidate-level call is the same code path the AI Screening tab uses.
    """

    permission_classes = [CanRunScreening]

    def get(self, request, candidate_id):
        from apps.candidates.models import Candidate

        candidate = Candidate.objects.filter(pk=candidate_id, is_deleted=False).first()
        if not candidate:
            return error_response("Candidate not found.", status_code=404)

        # Locally simulated calls (mock provider) are NOT real call results, so
        # they are never reported here — otherwise a fabricated Completed +
        # transcript would be indistinguishable from a genuine Hunar call. The
        # rows are left untouched in the database; they are simply not presented
        # as this candidate's call history.
        calls = [
            c for c in AICall.objects.filter(candidate=candidate)
            .select_related("job").order_by("-updated_at")
            if not (c.provider_call_id or "").startswith("mock-")
        ]
        # Auto-sync live status/summary from Hunar for active calls
        for c in calls:
            if c.provider_call_id and c.status in RUNNING:
                services.sync_hunar_call_details(c)

        running = next((c for c in calls if c.status in RUNNING), None)
        latest = calls[0] if calls else None

        # A call needs a JD for its agent variables (job title, skills, …), so the
        # candidate can only be called on a JD they are already in the pipeline
        # for. Ordered newest-first; the UI defaults to the first.
        jobs, seen = [], set()
        for app in (
            JobApplication.objects.filter(candidate=candidate, job__isnull=False)
            .select_related("job")
            .order_by("-created_at")
        ):
            if app.job_id in seen:
                continue
            seen.add(app.job_id)
            jobs.append({"id": app.job_id, "title": app.job.title, "status": app.job.status})

        def _with_job(call):
            if not call:
                return None
            return {
                **_call_dict(call),
                "job_id": call.job_id,
                "job_title": call.job.title if call.job else "",
            }

        return success_response({
            "candidate_id": candidate.id,
            "name": f"{candidate.first_name} {candidate.last_name}".strip(),
            "phone": candidate.phone_number or "",
            "has_phone": bool((candidate.phone_number or "").strip()),
            "jobs": jobs,
            "running_call_id": running.id if running else None,
            "latest_call": _with_job(latest),
            "calls": [_with_job(c) for c in calls],
        })


class CallHistoryView(APIView):
    """GET /ai-calls/history/?job=<id>&sync=1 — Call History log for a JD.

    Returns every AICall row created for the given JD, newest first. When
    sync=1 is passed, active calls in flight are auto-synced with Hunar API
    before returning so status is always up-to-date and never stays stuck at Dialing.
    """
    permission_classes = [CanRunScreening]

    def get(self, request):
        job_id = request.query_params.get("job")
        if not job_id or not str(job_id).isdigit():
            return error_response("Pass job as an integer ID.")

        job = JobDescription.objects.filter(pk=job_id).first()
        if not job:
            return error_response("Job description not found.", status_code=404)

        do_sync = request.query_params.get("sync") == "1"
        calls = (
            AICall.objects.filter(job=job)
            .select_related("candidate", "job", "created_by")
            .order_by("-updated_at")
        )

        # Agent id -> name, fetched once for the whole page from the existing
        # Hunar Agents API (cached). A call row only stores the agent's UUID,
        # which is not presentable; this is where its name comes from. An empty
        # directory (Hunar unreachable) leaves the names blank — the UI then says
        # the agent information is unavailable rather than inventing one.
        agent_directory = get_agent_directory()

        rows = []
        for call in calls:
            if do_sync and call.provider_call_id and not call.provider_call_id.startswith("mock-") and call.status in RUNNING:
                services.sync_hunar_call_details(call)

            # A call that never connected and completed carries no screening
            # result, so score/recommendation/summary are withheld — the same rule
            # _call_dict applies everywhere else. Status, timings and the error
            # reason still come through; that is what explains the absence.
            completed = call.status == AICall.Status.COMPLETED
            eval_obj = getattr(call, "evaluation", None)
            summary_val = (call.summary or (eval_obj.summary if eval_obj else "")) if completed else ""
            agent_info = describe_agent(
                (call.agent_variables or {}).get("agent_id")
                or getattr(settings, "HUNAR_AGENT_ID", ""),
                agent_directory,
            )
            rows.append({
                "call_id": call.id,
                "candidate_id": call.candidate_id,
                "candidate_name": f"{call.candidate.first_name} {call.candidate.last_name}".strip(),
                "mobile_number": call.candidate.phone_number or "",
                "job_title": job.title,
                "recruiter": f"{call.created_by.first_name} {call.created_by.last_name}".strip() if (call.created_by and (call.created_by.first_name or call.created_by.last_name)) else (call.created_by.email if call.created_by else "—"),
                # The agent that actually ran THIS call (recorded at dispatch);
                # falls back to the configured agent for older rows. Unchanged —
                # still the raw id, so any existing consumer keeps working.
                "ai_agent": str((call.agent_variables or {}).get("agent_id")
                                or getattr(settings, "HUNAR_AGENT_ID", "")),
                # Additive: the agent's real name/code from Hunar, so the UI can
                # show "Nisha (IN03)" instead of a truncated UUID. Blank when the
                # agent could not be resolved — never a placeholder name.
                "ai_agent_name": agent_info["name"],
                "ai_agent_code": agent_info["agent_code"],
                "status": call.status,
                "duration": call.duration,
                "score": call.score if completed else None,
                "recommendation": call.recommendation if completed else "",
                "summary": summary_val,
                # Same existing URL the summary popup uses (stored by the sync).
                "recording_url": _recording_url(call) if completed else "",
                "error_message": call.error_message or "",
                "is_completed": completed,
                # True when Hunar has finished the call but not yet returned its
                # summary — the UI says "being processed" rather than showing blank.
                "summary_pending": completed and not summary_val,
                "started_at": call.started_at.strftime("%Y-%m-%d %I:%M %p") if call.started_at else (call.created_at.strftime("%Y-%m-%d %I:%M %p") if call.created_at else "—"),
                "updated_at": call.updated_at.strftime("%Y-%m-%d %I:%M %p") if call.updated_at else "—",
            })

        return success_response({"rows": rows, "count": len(rows)})


class ScreeningDashboardView(APIView):
    """GET /ai-calls/dashboard/?job=<id> — the screening KPI cards."""

    permission_classes = [CanRunScreening]

    def get(self, request):
        job_id = request.query_params.get("job")
        calls = AICall.objects.all()
        candidates = JobApplication.objects.filter(candidate__is_deleted=False)
        if job_id:
            calls = calls.filter(job_id=job_id)
            candidates = candidates.filter(job_id=job_id)

        completed = calls.filter(status=AICall.Status.COMPLETED)
        avg = completed.aggregate(v=Avg("score"))["v"]
        return success_response({
            "total_candidates": candidates.values("candidate_id").distinct().count(),
            "calls_running": calls.filter(status__in=RUNNING).count(),
            "calls_completed": completed.count(),
            "qualified": completed.filter(recommendation=AICall.Recommendation.QUALIFIED).count(),
            "rejected": completed.filter(recommendation=AICall.Recommendation.NOT_QUALIFIED).count(),
            # Every unsuccessful outcome, so splitting BUSY/CANCELLED out of
            # NO_ANSWER/FAILED does not change this total.
            "failed": calls.filter(status__in=AICall.UNSUCCESSFUL_STATUSES).count(),
            "average_score": round(avg, 1) if avg is not None else 0,
        })


class RetryCallView(APIView):
    """POST /ai-calls/<id>/retry/ — requeue a failed / unanswered / finished call."""

    permission_classes = [CanRunScreening]

    def post(self, request, call_id):
        call = AICall.objects.filter(pk=call_id).select_related("candidate", "job").first()
        if not call:
            return error_response("Call not found.", status_code=404)
        if call.status in RUNNING:
            return error_response("This call is already running.")
        # Same up-front check as starting a call: refuse rather than requeue a
        # call that cannot actually be placed.
        try:
            get_provider().validate_config()
        except (ProviderNotConfigured, RuntimeError) as exc:
            return error_response(str(exc), status_code=503)

        call.status = AICall.Status.QUEUED
        call.provider_call_id = ""
        call.error_message = ""
        call.agent_variables = call.agent_variables or {}
        call.save()
        services.run_ai_call.delay(call.id)
        return success_response(_call_dict(call), message="Call requeued")


class TranscriptView(APIView):
    """GET /ai-calls/<id>/transcript/ — transcript + summary for the viewer modal."""

    permission_classes = [CanRunScreening]

    def get(self, request, call_id):
        call = AICall.objects.filter(pk=call_id).select_related("candidate", "job").first()
        if not call:
            return error_response("Call not found.", status_code=404)

        if call.provider_call_id and not call.provider_call_id.startswith("mock-"):
            services.sync_hunar_call_details(call)
        # No screening artefacts for a call that never connected and completed —
        # transcript, utterances and evaluation are all withheld, matching
        # _call_dict. The status and error reason still come through.
        completed = call.status == AICall.Status.COMPLETED
        evaluation = getattr(call, "evaluation", None) if completed else None
        return success_response({
            "candidate": f"{call.candidate.first_name} {call.candidate.last_name}".strip(),
            "job_title": call.job.title,
            # Hunar's own recording URL, already stored by the sync in
            # evaluation.rubric.hunar_metadata. Lifted to the top level so the
            # popup's Call Audio Recording section can read it directly — same
            # URL, no new API, no new logic.
            "recording_url": _recording_url(call) if completed else "",
            "transcript": call.transcript if completed else "",
            "utterances": [
                {
                    "sequence": u.sequence, "speaker": u.speaker, "message": u.message,
                    "started_ms": u.started_ms, "language": u.language, "interrupted": u.interrupted,
                }
                for u in (call.utterances.all() if completed else [])
            ],
            "evaluation": {
                "technical_score": evaluation.technical_score,
                "communication_score": evaluation.communication_score,
                "experience_score": evaluation.experience_score,
                "confidence_score": evaluation.confidence_score,
                "overall_score": evaluation.overall_score,
                "classification": evaluation.classification,
                "recommendation": evaluation.recommendation,
                "strengths": evaluation.strengths,
                "weaknesses": evaluation.weaknesses,
                "summary": evaluation.summary,
                # Per-question scores + evidence quotes already stored by the
                # evaluation writer — surfaced as "Key Responses". Additive.
                "rubric": evaluation.rubric,
            } if evaluation else None,
            **_call_dict(call),
        })


class CancelCallView(APIView):
    """POST /ai-calls/<id>/cancel/ — best-effort cancel of a running call."""

    permission_classes = [CanRunScreening]

    def post(self, request, call_id):
        call = AICall.objects.filter(pk=call_id).first()
        if not call:
            return error_response("Call not found.", status_code=404)
        if call.status not in RUNNING:
            return error_response("This call is not running.")

        # Best-effort provider cancel: a missing/unconfigured provider must not
        # stop the recruiter from cancelling the call locally.
        try:
            get_provider().cancel_call(call)
        except ProviderNotConfigured:
            pass
        # CANCELLED rather than FAILED — a recruiter stopping the call is not a
        # provider failure. It still counts in the dashboard's "failed" tile via
        # AICall.UNSUCCESSFUL_STATUSES, so no figure changes.
        call.status = AICall.Status.CANCELLED
        call.error_message = "Cancelled by recruiter"
        call.ended_at = timezone.now()
        call.save()
        return success_response(_call_dict(call), message="Call cancelled")


class CallReportView(APIView):
    """GET /ai-calls/<id>/report/ — downloadable PDF screening report."""

    permission_classes = [CanRunScreening]

    def perform_content_negotiation(self, request, force=False):
        return super().perform_content_negotiation(request, force=True)

    def get(self, request, call_id):
        call = AICall.objects.filter(pk=call_id).select_related("candidate", "job", "job__client").first()
        if not call:
            return error_response("Call not found.", status_code=404)
        if call.status != AICall.Status.COMPLETED:
            return error_response("The call has not completed yet — no report available.")

        pdf = render_call_report(call)
        name = f"{call.candidate.first_name}_{call.candidate.last_name}".strip("_")
        response = HttpResponse(pdf, content_type="application/pdf")
        response["Content-Disposition"] = f'attachment; filename="{name}_ai_screening.pdf"'
        return response


# ─── Platform callbacks ───────────────────────────────────────────────────────


class _WebhookBase(APIView):
    """Signature-guarded endpoints the calling platform reports back into."""

    permission_classes = [AllowAny]
    authentication_classes = []
    handler = None

    def _authenticate_caller(self, request):
        """HMAC X-ATS-Signature over the raw body (see ai_calls.security).
        When no signing secret is configured (local dev/mock) requests pass."""
        secret = settings.AI_PLATFORM_SIGNING_SECRET
        if not secret:
            return True
        return verify_signature(secret, request.body, request.headers.get(SIGNATURE_HEADER, ""))

    def post(self, request):
        if not self._authenticate_caller(request):
            return error_response("Invalid webhook signature.", status_code=403)
        handled = self.__class__.handler(request.data or {})
        if not handled:
            return error_response("Unknown call_id.", status_code=404)
        return success_response(message="ok")


class WebhookStatusView(_WebhookBase):
    handler = staticmethod(services.handle_status_event)


class WebhookTranscriptView(_WebhookBase):
    handler = staticmethod(services.handle_transcript_event)


class WebhookCompletedView(_WebhookBase):
    handler = staticmethod(services.handle_completed_event)
