"""
routes/peoplehub_external.py
=============================
Server-side proxy for the three external People Hub POST APIs. The browser
*could* call them directly, but proxying through our backend gives us:

  • a single CORS-allowed origin
  • centralized retry/timeout behaviour
  • the ability to mirror the raw payload into our local data/{access_key}/
    folder for audit / session-resume / fraud-correlation
  • room to inject candidate context into downstream calls later

Each endpoint preserves the upstream payload verbatim under `data` so the
frontend can store it in localStorage exactly as required by the spec.
"""
from __future__ import annotations

import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse

from config import settings
from services import peoplehub_api as ph
from utils.logger import get_logger


# ── Validation patterns (top-level so they compile once at import time) ───
# Access keys: alphanumerics with optional dash/underscore. Rejecting
# anything else keeps SQLi / XSS / path-traversal payloads from reaching
# the upstream JSP and from being mirrored to disk under a crafted filename.
_ACCESS_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$")
_TESTID_RE     = re.compile(r"^[A-Za-z0-9_-]{1,64}$")

log = get_logger(__name__)

router = APIRouter(prefix="/api/external", tags=["external"])


# ── Local mirror (per-access_key audit folder) ────────────────────────────────

def _audit_dir(access_key: str) -> Path:
    safe = "".join(c for c in (access_key or "anonymous") if c.isalnum() or c in "-_") or "anonymous"
    p = settings.DATA_DIR / "by_access_key" / safe
    p.mkdir(parents=True, exist_ok=True)
    return p


def _mirror(access_key: str, kind: str, payload: Any) -> None:
    """Best-effort write of the upstream response to disk for audit."""
    try:
        ts = datetime.now(timezone.utc).isoformat().replace(":", "-")
        path = _audit_dir(access_key) / f"{kind}__{ts}.json"
        path.write_text(json.dumps(payload, indent=2, default=str, ensure_ascii=False), encoding="utf-8")
    except Exception as exc:  # noqa: BLE001
        log.debug("mirror %s failed (ignored): %s", kind, exc)


def _log_upstream_response(kind: str, access_key: str, result: ph.ApiResult) -> None:
    """Pretty-print the upstream response for one of our external API
    proxies. Pairs with `_log_outgoing_request` so each round-trip is
    visible in the server log:

      [API SPEC SUMMARY — KIND] …          ← outgoing request line
      [API SPEC SAMPLE — KIND] {…JSON…}     ← outgoing request body
      [UPSTREAM RESPONSE — KIND] status=200 ok=true url=… data={…}

    Critical when debugging the session-end chain — without this we
    couldn't tell whether the upstream actually accepted the rolePlayId
    we sent, or what scoring the upstream returned.
    """
    try:
        # `_safe_log_url` already strips query strings / credentials.
        url = ph._safe_log_url(result.url) if getattr(result, "url", None) else "(no-url)"
        # Clip large payloads so a verbose upstream response doesn't
        # blow up the log file. 4000 chars is plenty to see status,
        # message, ids, and the scoring summary.
        try:
            # ensure_ascii=False keeps Hindi / other scripts readable
            # in the server log instead of \uXXXX escape soup.
            payload_str = json.dumps(result.data, indent=2, default=str, ensure_ascii=False)
        except Exception:  # noqa: BLE001
            payload_str = str(result.data)
        if len(payload_str) > 4000:
            payload_str = payload_str[:4000] + f"…(+{len(payload_str) - 4000} chars truncated)"

        log.info(
            "[UPSTREAM RESPONSE — %s] ak=%s  status=%s  ok=%s  url=%s  error=%s",
            kind.upper(),
            access_key,
            result.status_code,
            result.ok,
            url,
            result.error or "(none)",
        )
        log.info(
            "\n──────────── [UPSTREAM RESPONSE BODY — %s] ────────────\n%s\n"
            "──────────────────────────────────────────────",
            kind.upper(),
            payload_str,
        )
    except Exception as exc:  # noqa: BLE001
        # Logging must never crash the proxy. If anything in the
        # formatter throws we just emit a short fallback line.
        log.warning("[UPSTREAM RESPONSE — %s] log formatter failed: %s", kind, exc)


def _payload_response(result: ph.ApiResult, access_key: str, kind: str) -> JSONResponse:
    """Build the JSON envelope returned to the browser.

    The raw upstream URL is mirrored to disk for audit but never echoed
    back to the browser — that would leak internal infrastructure details
    (host, path) into the client. The browser only sees `kind`.
    """
    audit_url = ph._safe_log_url(result.url) if result.url else ""

    # Pretty-print the upstream response so the operator can see exactly
    # what the People Hub backend replied with. Especially important on
    # the session-end chain (roleplay/save, roleplay/report) where we
    # need to confirm rolePlayId round-tripped.
    _log_upstream_response(kind, access_key, result)

    body = {
        "ok":            result.ok,
        "status_code":   result.status_code,
        "kind":          kind,
        "data":          result.data,           # full upstream payload — store this in localStorage
        "is_empty":      ph.is_empty_response(result.data) if result.ok else False,
        "candidate":     ph.extract_candidate_summary(result.data) if result.ok else None,
        "error":         result.error,
    }
    _mirror(access_key, kind, {"request": {"url": audit_url}, "response": body})

    # Bubble up upstream errors with a real status so the frontend can show them.
    # Internal 5xx is normalized to 502 so candidates never see a "500" in DevTools.
    if result.ok:
        http_status = 200
    elif 400 <= result.status_code < 500:
        http_status = result.status_code
    else:
        http_status = 502
    return JSONResponse(content=body, status_code=http_status)


# ── Endpoints ─────────────────────────────────────────────────────────────────


def _validate_key(access_key: str) -> str:
    key = (access_key or "").strip()
    if not key:
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "access_key is required")
    if not _ACCESS_KEY_RE.fullmatch(key):
        # Generic message — never echo the rejected value back to the browser.
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "access_key has an invalid format")
    return key


@router.post("/login/{access_key}")
def external_login(access_key: str):
    """
    Proxy → POST {PEOPLEHUB_BASE_URL}/login/by-accesskey/{access_key}

    The frontend must store the entire `data` payload in localStorage and
    use `current_stage` (extracted defensively) to decide where to navigate.
    """
    key = _validate_key(access_key)
    return _payload_response(ph.login(key), key, "login")


@router.post("/roleplays/{access_key}")
def external_roleplays(access_key: str):
    """
    Proxy → POST {PEOPLEHUB_BASE_URL}/roleplays/by-accesskey/{access_key}

    `data` is the raw API output (typically a list of role-play challenges
    or `{ data: [...] }`). The frontend should render an empty-state UI
    when `is_empty=true`.
    """
    key = _validate_key(access_key)
    return _payload_response(ph.roleplays(key), key, "roleplays")


@router.post("/coding/{access_key}")
def external_coding(access_key: str):
    """
    Proxy → POST {PEOPLEHUB_BASE_URL}/codingassessment/by-accesskey/{access_key}
    """
    key = _validate_key(access_key)
    return _payload_response(ph.coding_assessment(key), key, "coding")


@router.post("/assessment/{access_key}")
@router.get("/assessment/{access_key}")
def get_assessment(access_key: str):
    """
    Proxy → POST {assess_base}/test/gsonTestResponse.jsp?accesskey={access_key}

    Returns all sections + questions for the candidate's current assessment test.
    `data.caseStudyQuestDis` being empty means use sections[0].questions (MCQ).
    `data.caseStudyQuestDis` having items means use those (descriptive/case study).

    Both GET and POST are accepted on the proxy so existing frontend code
    keeps working; the upstream JSP is always called with POST (the only
    method it accepts — calling GET upstream returns HTTP 405).
    """
    key = _validate_key(access_key)
    return _payload_response(ph.get_assessment(key), key, "assessment")


@router.post("/assessment/submit/{access_key}/{testid}")
def submit_assessment(access_key: str, testid: str):
    """
    Proxy → POST {assess_base}/test/updateAssessmentScoreApi.jsp
                    ?accesskey={access_key}&testid={testid}

    Called ONLY on the final Submit Assessment action — never on every
    Next click. (Per-question saves go to /assessment/answer/{access_key}.)
    Responds with totalScore + resultStatus from upstream.
    """
    key = _validate_key(access_key)
    if not testid or not _TESTID_RE.fullmatch(str(testid).strip()):
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "testid has an invalid format")
    return _payload_response(
        ph.submit_assessment_score(key, str(testid).strip()),
        key,
        "assessment_submit",
    )


@router.post("/assessment/answer/{access_key}")
async def update_assessment_answer(access_key: str, body: dict):
    """
    Proxy → POST {assess_base}/test/updateAssessmentApi.jsp

    Per-question save invoked on every Next button click. Body fields:
        accesskey, sectionid, questionid, question_flag, counter,
        Answer, testid

    The frontend builds this body via PHConfig.buildAnswerBody().
    Returns the upstream envelope verbatim under `data` so the caller
    can update its `counter`, `sectionid`, `questionid`, `question_flag`
    state from `data.data.*`.
    """
    key = _validate_key(access_key)
    body = body or {}

    # The body uses upstream-style snake-cased names (matching the JSP
    # parameters) so the request log matches the upstream contract verbatim.
    testid = str(body.get("testid", "")).strip()
    if not testid or not _TESTID_RE.fullmatch(testid):
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "testid has an invalid format")

    questionid    = str(body.get("questionid", "")).strip()
    sectionid     = str(body.get("sectionid", "")).strip()
    question_flag = str(body.get("question_flag", "0")).strip()
    counter       = str(body.get("counter", "")).strip()
    answer        = body.get("Answer", body.get("answer", ""))
    if answer is None:
        answer = ""

    if not questionid:
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "questionid is required")
    if not sectionid:
        sectionid = "1"

    result = ph.update_assessment_answer(
        access_key=key,
        sectionid=sectionid,
        questionid=questionid,
        question_flag=question_flag,
        counter=counter,
        answer=str(answer),
        testid=testid,
    )
    return _payload_response(result, key, "assessment_answer")


@router.post("/coding/submit/{access_key}")
async def submit_coding(access_key: str, body: dict):
    """
    Proxy → POST {PEOPLEHUB_BASE_URL}/coding/submit

    Called by the coding editor when the candidate clicks "Submit Answer"
    on a single question. Body shape (per the upstream Postman sample):

        {
          "kind":        "coding_submit",
          "access_key":  "<KEY>",
          "received_at": "<ISO-8601>",
          "request": {
            "access_key":    "<KEY>",
            "candidate_id":  "<id>",
            "question_id":   <int>,
            "question_text": "...",
            "language":      "python|java|...",
            "code":          "<source>",
            "submitted_at":  "<ISO-8601>"
          }
        }

    Upstream response:
        {
          "status":  true,
          "message": "Coding submission saved successfully",
          "data": { "id": <int>, "kind": "coding_submit",
                    "accessKey": "...", "questionId": <int>, ... }
        }

    Beyond proxying, this handler also mirrors the request locally under
    data/by_access_key/{key}/coding_submit.json so the per-candidate audit
    trail matches what the upstream API received.
    """
    key = _validate_key(access_key)
    payload = dict(body or {})

    # Fill envelope defaults so the upstream contract is honoured even if the
    # frontend forgot a field (defensive — keeps the spec sample valid).
    payload["access_key"] = key
    payload.setdefault("kind", "coding_submit")
    payload.setdefault("received_at", datetime.now(timezone.utc).isoformat())
    if isinstance(payload.get("request"), dict):
        payload["request"].setdefault("access_key",   key)
        payload["request"].setdefault("submitted_at", payload["received_at"])

    _log_outgoing_request("coding_submit", key, payload)

    result = ph.submit_coding(access_key=key, payload=payload)

    # Mirror the request body for audit (best-effort — never raises).
    try:
        path = _audit_dir(key) / "coding_submit.json"
        existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
        if not isinstance(existing, list):
            existing = [existing]
        record = dict(payload)
        record["access_key"]       = key
        record["saved_at"]         = datetime.now(timezone.utc).isoformat()
        record["upstream_status"]  = result.status_code
        existing.append(record)
        path.write_text(json.dumps(existing, indent=2, default=str, ensure_ascii=False), encoding="utf-8")
    except Exception as exc:  # noqa: BLE001
        log.debug("coding_submit mirror failed (ignored): %s", exc)

    return _payload_response(result, key, "coding_submit")


@router.post("/roleplay/save/{access_key}")
async def save_roleplay(access_key: str, body: dict):
    """
    Proxy → POST {PEOPLEHUB_BASE_URL}/roleplay/save

    Called when the candidate ends a role play session. Body shape per the
    upstream contract:
        {
          "kind":        "roleplay",
          "access_key":  "<KEY>",
          "received_at": "<ISO-8601>",
          "request": {
            "scenario":         { "id": "2", "title": "..." },
            "session_id":       "...",
            "duration_seconds": 14,
            "turns_completed":  0,
            "mode":             "voice",
            "end_reason":       "completed"
          }
        }

    Response (loose, opaque):
        { "status": "success", "message": "saved", "saved": true }

    Beyond proxying, this handler also mirrors the request locally under
    data/by_access_key/{key}/roleplay.json — same as /results/{key}/roleplay
    used for audit / resume.
    """
    key = _validate_key(access_key)
    payload = dict(body or {})

    _log_outgoing_request("roleplay_save", key, payload)

    result = ph.save_roleplay(access_key=key, payload=payload)

    # Mirror the request body for audit (mirrors are best-effort and
    # never raise — the proxy still returns the upstream response).
    try:
        path = _audit_dir(key) / "roleplay.json"
        existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
        if not isinstance(existing, list):
            existing = [existing]
        record = dict(payload)
        record["access_key"] = key
        record["saved_at"]   = datetime.now(timezone.utc).isoformat()
        record["upstream_status"] = result.status_code
        existing.append(record)
        path.write_text(json.dumps(existing, indent=2, default=str, ensure_ascii=False), encoding="utf-8")
    except Exception as exc:  # noqa: BLE001
        log.debug("roleplay mirror failed (ignored): %s", exc)

    return _payload_response(result, key, "roleplay_save")


@router.post("/roleplay/report/{access_key}")
async def save_roleplay_report(access_key: str, body: dict):
    """
    Proxy → POST {PEOPLEHUB_BASE_URL}/roleplay/report

    Called immediately after the role play report is generated on the
    frontend. The body mirrors the upstream contract documented in the
    Postman sample:
        {
          "kind":        "roleplay",
          "access_key":  "<KEY>",
          "received_at": "<ISO-8601>",
          "request": {
            "scenario": {
              "id": "2",
              "title": "Java Technical Interview",
              "description": "...",
              "context": "...",
              "category": "technical",
              "cat_label": "Technical",
              "learner_role": "Interviewer",
              "learner_emoji": "...",
              "ai_character": "...",
              "ai_emoji": "...",
              "ai_personality": "...",
              "difficulty": "Advanced",
              "turns": 8
            },
            "session_id":       "...",
            "duration_seconds": 0,
            "turns_completed":  0,
            "mode":             "voice|video|text",
            "end_reason":       "completed|time_expired|abandoned",
            "report":           { ... full generated report ... }
          }
        }

    Beyond proxying, this handler also mirrors the request locally under
    data/by_access_key/{key}/roleplay_report.json for audit / resume.
    """
    key = _validate_key(access_key)
    payload = dict(body or {})

    _log_outgoing_request("roleplay_report", key, payload)

    result = ph.save_roleplay_report(access_key=key, payload=payload)

    # Mirror the request body for audit (best-effort).
    try:
        path = _audit_dir(key) / "roleplay_report.json"
        existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
        if not isinstance(existing, list):
            existing = [existing]
        record = dict(payload)
        record["access_key"] = key
        record["saved_at"]   = datetime.now(timezone.utc).isoformat()
        record["upstream_status"] = result.status_code
        existing.append(record)
        path.write_text(json.dumps(existing, indent=2, default=str, ensure_ascii=False), encoding="utf-8")
    except Exception as exc:  # noqa: BLE001
        log.debug("roleplay_report mirror failed (ignored): %s", exc)

    return _payload_response(result, key, "roleplay_report")


# ── Per-access_key local persistence ──────────────────────────────────────────
# The spec requires storing coding results, role play results, and fraud logs
# against the access_key. These endpoints provide a simple JSON-on-disk store
# the frontend can use until/unless a real DB-backed endpoint exists.

def _log_outgoing_request(kind: str, access_key: str, body: dict) -> None:
    """Pretty-print a request body that the frontend just sent us.

    Two parts get logged:

      1. A single-line summary banner that surfaces the most-grepped
         identifiers (rolePlayId, session_id, mode, end_reason, …) so
         that "is rolePlayId being sent?" can be answered with one
         `grep` over the log file — no scrolling through 200 lines of
         pretty-printed JSON.

      2. The full body, dumped as-is (no extra `request` wrapper). The
         previous version nested the entire body under another
         `"request"` key, which made fields like `rolePlayId` appear
         two levels deep in the output and easy to miss during review.

    Sensitive fields are redacted via `_safe_for_log`.
    """
    safe = _safe_for_log(body or {})

    # ── Summary banner — surfaces the IDs anyone debugging actually
    # cares about. Looks at both the top level AND request.* because
    # buildRoleplayBody emits each id at both points. Keep this on a
    # single line so log aggregators that key on line breaks don't
    # split it across rows.
    inner = safe.get("request") if isinstance(safe.get("request"), dict) else {}
    summary_bits = [
        f"kind={kind}",
        f"access_key={access_key}",
        # rolePlayId is the new upstream contract field — print it as
        # the first identifier so it's the easiest thing to grep for.
        f"rolePlayId={safe.get('rolePlayId', inner.get('rolePlayId'))}",
        f"id={safe.get('id', inner.get('id'))}",
        f"session_id={inner.get('session_id', safe.get('session_id'))}",
        f"mode={inner.get('mode', safe.get('mode'))}",
        f"end_reason={inner.get('end_reason', safe.get('end_reason'))}",
        f"turns_completed={inner.get('turns_completed', safe.get('turns_completed'))}",
    ]
    log.info("[API SPEC SUMMARY — %s] %s", kind.upper(), " ".join(summary_bits))

    # ── Full body dump for the backend dev. The body is logged
    # verbatim (after redaction) — no wrapper, no rename — so the
    # exact JSON that hits the upstream service is what shows up here.
    log.info(
        "\n──────────── [API SPEC SAMPLE — %s] ────────────\n%s\n"
        "──────────────────────────────────────────────",
        kind.upper(),
        json.dumps(safe, indent=2, default=str, ensure_ascii=False),
    )


_REDACT_KEYS = {"token", "ph_token", "jwt", "password", "authorization"}


def _safe_for_log(obj: Any) -> Any:
    """Recursively scrub auth tokens and clip oversized values for logs."""
    if isinstance(obj, dict):
        out: dict = {}
        for k, v in obj.items():
            if isinstance(k, str) and k.lower() in _REDACT_KEYS:
                out[k] = "<redacted>"
            else:
                out[k] = _safe_for_log(v)
        return out
    if isinstance(obj, list):
        return [_safe_for_log(v) for v in obj]
    if isinstance(obj, str) and len(obj) > 4000:
        return obj[:4000] + f"…(+{len(obj) - 4000} chars truncated)"
    return obj


@router.post("/results/{access_key}/{kind}")
async def save_results(access_key: str, kind: str, body: dict):
    """
    Persist a JSON document under data/by_access_key/{access_key}/{kind}.json

    `kind` must be one of: roleplay, coding, fraud, session, end_session,
    coding_submit, coding_final.

    Side effects:
      • Appends `body` (with access_key + saved_at) to the per-candidate file.
      • Logs the request as a clearly-marked "API SPEC SAMPLE" so the
        backend dev has a copy-pasteable view of what the frontend is
        sending — auth tokens scrubbed, large strings truncated.
    """
    key = _validate_key(access_key)
    allowed = {"roleplay", "coding", "fraud", "session",
               "end_session", "coding_submit", "coding_final",
               "roleplay_report"}
    if kind not in allowed:
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown kind")

    _log_outgoing_request(kind, key, body or {})

    path = _audit_dir(key) / f"{kind}.json"
    try:
        existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
        if not isinstance(existing, list):
            existing = [existing]
    except Exception:
        existing = []

    record = dict(body or {})
    record["access_key"] = key
    record["saved_at"]   = datetime.now(timezone.utc).isoformat()
    existing.append(record)

    path.write_text(json.dumps(existing, indent=2, default=str, ensure_ascii=False), encoding="utf-8")
    log.info("[results] %s/%s appended (%d total)", key, kind, len(existing))

    return {"ok": True, "count": len(existing)}  # path intentionally not returned


@router.get("/results/{access_key}/{kind}")
async def load_results(access_key: str, kind: str):
    """Load all persisted results of a given kind for an access_key."""
    key = _validate_key(access_key)
    path = _audit_dir(key) / f"{kind}.json"
    if not path.exists():
        return {"ok": True, "data": []}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:  # noqa: BLE001
        log.warning("[results] %s/%s read error: %s", key, kind, exc)
        return {"ok": False, "data": [], "error": str(exc)}
    return {"ok": True, "data": data}
