"""LLM-first résumé parsing with package-parser fallback.

`parse_resume(file_path)` is the main entry point:
  1. Primary — extract the résumé text and ask the configured LLM provider
     (OpenRouter/OpenAI/Gemini via apps.llm) for strict JSON.
  2. Fallback — if the LLM is unavailable, times out, or returns invalid or
     unusable structured output, fall back to the regex-based `ResumeParser`.

Accuracy over completeness: the prompt and the normalisation layer both drop
values that are not explicitly present or fail sanity checks (bad emails,
short phones, malformed dates) instead of filling garbage. Both stages log
their outcome; nothing in this module ever raises.
"""

import datetime
import json
import logging
import os
import re

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Canonical parse shape — mirrors ResumeParser.parse() and the profile forms.
# Every parse result contains every key so callers can refresh-and-clear
# fields when a new résumé no longer mentions them.
# ---------------------------------------------------------------------------
_FLAT_DEFAULTS = {
    "first_name": "", "last_name": "", "email": "", "phone_number": "",
    "alternate_phone_number": "", "date_of_birth": None, "gender": "",
    "city": "", "state": "", "country": "", "current_location": "",
    "current_address": "", "permanent_address": "",
    "linkedin": "", "github": "", "portfolio": "", "personal_website": "",
    "skills": "", "languages": "", "professional_summary": "",
    "highest_qualification": "", "university": "", "college": "",
    "passing_year": None, "percentage_cgpa": "",
    "fresher": True, "total_experience": None, "notice_period": None,
    "current_company": "", "previous_company": "", "current_role": "",
}

_PROMPT = """You are a strict résumé parser. Read the RÉSUMÉ TEXT below and return ONLY one
valid JSON object (no markdown, no code fences, no commentary) with exactly these keys:

{{
  "first_name": "", "last_name": "", "email": "", "phone_number": "",
  "alternate_phone_number": "", "gender": "", "date_of_birth": "YYYY-MM-DD or empty",
  "city": "", "state": "", "country": "", "current_location": "",
  "current_address": "", "permanent_address": "",
  "linkedin": "", "github": "", "portfolio": "", "personal_website": "",
  "professional_summary": "", "skills": "comma-separated", "languages": "comma-separated",
  "highest_qualification": "", "university": "", "college": "",
  "passing_year": null, "percentage_cgpa": "",
  "fresher": true, "total_experience": null, "notice_period": null,
  "current_company": "", "previous_company": "", "current_role": "",
  "educations": [{{"degree_name": "", "field_of_study": "", "institution_name": "", "passing_year": null, "percentage_cgpa": ""}}],
  "experiences": [{{"company_name": "", "role": "", "joining_date": "YYYY-MM-DD", "last_working_date": "YYYY-MM-DD or empty", "is_current_company": false, "responsibilities": ""}}],
  "projects": [{{"project_name": "", "description": "", "technologies_used": "", "duration": "", "role": ""}}],
  "references": [{{"name": "", "company": "", "designation": "", "email": "", "phone": "", "relationship": ""}}]
}}

ACCURACY RULES — accuracy matters more than completeness:
- Fill a field ONLY when the résumé states it explicitly. If a value is absent,
  ambiguous, or you are not confident, use "" / null / [] for it.
- NEVER guess, infer, or fabricate anything (do not derive a city from a phone
  code, do not invent dates, emails, companies, or scores).
- Dates use "YYYY-MM-DD". If only month and year are stated, use day 01.
  If only a year is stated, leave the date empty.
- "fresher" is true only when the résumé shows no professional work experience.
- "total_experience" is a number of years (e.g. 2.5) only if stated or clearly
  computable from listed employment dates; otherwise null.
- "notice_period" is a number of days, only if explicitly stated.
- "skills" and "languages" are single comma-separated strings of items that
  literally appear in the résumé.
- Return valid JSON only.

RÉSUMÉ TEXT:
\"\"\"
{text}
\"\"\"
"""

_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")


# ---------------------------------------------------------------------------
# Small coercion helpers — reject anything that fails a sanity check.
# ---------------------------------------------------------------------------
def _s(v) -> str:
    return str(v).strip() if isinstance(v, (str, int, float)) else ""


def _date(v) -> str:
    s = _s(v)
    if not _DATE_RE.match(s):
        return ""
    try:
        datetime.date.fromisoformat(s)
    except ValueError:
        return ""
    return s


def _year(v):
    m = re.search(r"(?:19|20)\d{2}", str(v or ""))
    return int(m.group()) if m else None


def _num(v):
    if isinstance(v, bool):
        return None
    if isinstance(v, (int, float)):
        return float(v)
    m = re.search(r"\d+(?:\.\d+)?", str(v or ""))
    return float(m.group()) if m else None


def _csv(v) -> str:
    if isinstance(v, list):
        return ", ".join(_s(x) for x in v if _s(x))
    return _s(v)


def _bool(v, default=False) -> bool:
    if isinstance(v, bool):
        return v
    if isinstance(v, str):
        return v.strip().lower() in ("true", "yes", "1")
    return default


def _as_list(v) -> list:
    return v if isinstance(v, list) else []


# ---------------------------------------------------------------------------
# Nested-row normalisers. Alias keys handle older prompt/model variations
# (projects.title, references.contact, …). Rows without their anchor field
# are dropped entirely rather than kept half-empty.
# ---------------------------------------------------------------------------
def _edu(x):
    if not isinstance(x, dict):
        return None
    e = {
        "degree_name": _s(x.get("degree_name") or x.get("degree")),
        "field_of_study": _s(x.get("field_of_study")),
        "institution_name": _s(x.get("institution_name") or x.get("institution")),
        "passing_year": _year(x.get("passing_year")),
        "percentage_cgpa": _s(x.get("percentage_cgpa")),
    }
    return e if (e["degree_name"] or e["institution_name"]) else None


def _exp(x):
    if not isinstance(x, dict):
        return None
    e = {
        "company_name": _s(x.get("company_name") or x.get("company")),
        "role": _s(x.get("role") or x.get("title") or x.get("designation")),
        "joining_date": _date(x.get("joining_date")),
        "last_working_date": _date(x.get("last_working_date")),
        "is_current_company": _bool(x.get("is_current_company")),
        "responsibilities": _s(x.get("responsibilities")),
    }
    return e if e["company_name"] else None


def _proj(x):
    if not isinstance(x, dict):
        return None
    p = {
        "project_name": _s(x.get("project_name") or x.get("title") or x.get("name")),
        "description": _s(x.get("description")),
        "technologies_used": _csv(x.get("technologies_used") or x.get("technologies")),
        "duration": _s(x.get("duration")),
        "role": _s(x.get("role")),
    }
    return p if p["project_name"] else None


def _ref(x):
    if not isinstance(x, dict):
        return None
    email, phone = _s(x.get("email")), _s(x.get("phone"))
    contact = _s(x.get("contact"))
    if contact and not email and _EMAIL_RE.match(contact):
        email = contact
    elif contact and not phone:
        phone = contact
    r = {
        "name": _s(x.get("name")), "company": _s(x.get("company")),
        "designation": _s(x.get("designation")), "email": email, "phone": phone,
        "relationship": _s(x.get("relationship")),
    }
    return r if r["name"] else None


def _normalize(raw: dict) -> dict:
    """Coerce raw LLM output into the canonical shape, dropping unreliable values."""
    out = dict(_FLAT_DEFAULTS)
    for k, default in _FLAT_DEFAULTS.items():
        if default == "":
            out[k] = _s(raw.get(k))
    out["skills"] = _csv(raw.get("skills"))
    out["languages"] = _csv(raw.get("languages"))
    out["date_of_birth"] = _date(raw.get("date_of_birth")) or None
    out["passing_year"] = _year(raw.get("passing_year"))
    out["total_experience"] = _num(raw.get("total_experience"))
    notice = _num(raw.get("notice_period"))
    out["notice_period"] = int(notice) if notice is not None else None

    # Sanity: an invalid email or a short phone is worse than an empty field.
    if out["email"] and not _EMAIL_RE.match(out["email"]):
        out["email"] = ""
    for pk in ("phone_number", "alternate_phone_number"):
        if out[pk] and len(re.sub(r"\D", "", out[pk])) < 10:
            out[pk] = ""

    out["educations"] = [e for e in (_edu(x) for x in _as_list(raw.get("educations"))) if e]
    out["experiences"] = [e for e in (_exp(x) for x in _as_list(raw.get("experiences"))) if e]
    out["projects"] = [p for p in (_proj(x) for x in _as_list(raw.get("projects"))) if p]
    out["references"] = [r for r in (_ref(x) for x in _as_list(raw.get("references"))) if r]

    out["fresher"] = _bool(raw.get("fresher"), default=not out["experiences"])
    if out["experiences"]:
        out["fresher"] = False
    return out


def _extract_json(raw: str):
    """Pull the first JSON object out of an LLM response (handles code fences)."""
    if not raw:
        return None
    s = raw.strip()
    s = re.sub(r"^```(?:json)?", "", s).strip()
    s = re.sub(r"```$", "", s).strip()
    start = s.find("{")
    end = s.rfind("}")
    if start == -1 or end == -1 or end <= start:
        return None
    try:
        return json.loads(s[start:end + 1])
    except json.JSONDecodeError:
        return None


def llm_parse_resume(text: str, user=None) -> dict | None:
    """Parse résumé text via the configured LLM. Returns the canonical parsed
    dict, or None on ANY failure (no provider, timeout, malformed or unusable
    output) so the caller can fall back to the package parser."""
    if not text or not text.strip():
        return None
    try:
        from apps.llm.service import call_llm
    except Exception:
        logger.warning("LLM service unavailable — cannot LLM-parse résumé")
        return None

    snippet = text[:12000]  # cap so we stay within token limits
    try:
        res = call_llm(_PROMPT.format(text=snippet), purpose="resume_parse", user=user, timeout=45)
    except Exception as e:  # noqa: BLE001
        logger.warning("LLM résumé parse call failed: %s", e)
        return None

    if not res.get("ok"):
        logger.warning("LLM résumé parse not ok: %s", res.get("error"))
        return None

    data = _extract_json(res.get("text", ""))
    if not isinstance(data, dict):
        logger.warning("LLM résumé parse returned non-JSON output (provider=%s)", res.get("provider"))
        return None

    parsed = _normalize(data)
    if not (parsed["first_name"] or parsed["email"] or parsed["phone_number"]):
        logger.warning("LLM résumé parse produced no identifying fields — treating as invalid")
        return None
    return parsed


def parse_resume(file_path: str, user=None) -> tuple[dict, str]:
    """Two-stage résumé parse. Returns (parsed_data, source) where source is
    'llm', 'regex' (package-parser fallback) or 'none' (both stages failed)."""
    from .parser import ResumeParser

    name = os.path.basename(file_path)
    text = ""
    try:
        text = ResumeParser.extract_text(file_path) or ""
    except Exception:
        logger.exception("Résumé text extraction failed for %s", name)

    if text.strip():
        parsed = llm_parse_resume(text, user=user)
        if parsed is not None:
            logger.info("Résumé %s parsed via LLM", name)
            return parsed, "llm"
        logger.warning("LLM parse failed for %s — falling back to package parser", name)
    else:
        logger.warning("No text extracted from %s — trying package parser directly", name)

    try:
        parsed = ResumeParser.parse(file_path)
        logger.info("Résumé %s parsed via package parser (fallback)", name)
        return parsed, "regex"
    except Exception:
        logger.exception("Package résumé parser failed for %s", name)
        return {}, "none"
