"""Resume builder CRUD, preview, PDF export, and PDF import endpoints."""
import functools as _functools
import io
import json
import logging
import re
import uuid as _uuid
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import HTMLResponse, Response, JSONResponse
from sqlalchemy.orm import Session

from backend.models.db import get_db, Resume, TracerLink, TracerClickEvent, Setting, Job, SessionLocal, utcnow, Persona
from backend.job_monitor import launch_background, JobAlreadyRunningError


# ── Persona experience merge helpers ─────────────────────────────────────────
# Used by _tailor_impl when merging Persona.resume_content's experience entries
# into a base Resume's experience. Two-stage:
#   1. Match entries by normalized company name (case-insensitive, suffix-stripped) +
#      coarse title-root (Project|Product|Program Manager → "manager")
#   2. For matched entries, merge bullets — drop persona bullets that duplicate base
#      bullets via combined Jaccard + numeric-anchor heuristic.
# Thresholds tuned against real-world data (see analyze_bullet_dupes.py): J >= 0.40
# with shared numeric anchor catches all 27 true dups in the corpus zero false
# positives; J >= 0.50 lexical-only catches the rare paraphrase pair.

_COMPANY_SUFFIX_RE = re.compile(r'\b(inc|corp|corporation|ltd|llc|gmbh|ag|sa|plc|co)\.?$', re.IGNORECASE)
_NUMERIC_RE = re.compile(r'\$?\d+(?:[.,]\d+)?[KMB%+]*')
_WORD_RE = re.compile(r'[a-zA-Z]+')

_BULLET_STOPWORDS = {
    "a", "an", "the", "and", "or", "of", "to", "in", "on", "at",
    "by", "for", "with", "from", "as", "is", "was", "were", "be",
    "been", "being", "have", "has", "had", "do", "does", "did",
    "this", "that", "these", "those", "it", "its", "i", "we", "our",
    "you", "your", "into", "via", "across", "per",
}

_TITLE_ROOTS = (
    "manager", "engineer", "analyst", "developer", "designer",
    "lead", "director", "vp", "chief", "intern", "consultant",
    "scientist", "researcher", "specialist", "architect", "owner",
)


def _normalize_company(s: str) -> str:
    """Lowercase + strip common suffixes (Inc., Corp., LLC, GmbH, AG, ...)."""
    if not s:
        return ""
    s = s.strip().lower().rstrip(",.")
    return _COMPANY_SUFFIX_RE.sub("", s).strip().rstrip(",.")


def _normalize_title_root(s: str) -> str:
    """Collapse role variants to a single root, e.g.
       'Senior Project Manager' / 'Senior Product Manager' / 'Senior Program Manager' → 'manager'.
       Falls back to the lowercased title if no known root matches."""
    s = (s or "").strip().lower()
    if not s:
        return ""
    for root in _TITLE_ROOTS:
        if root in s:
            return root
    return s


def _bullet_stem(w: str) -> str:
    for suf in ("ings", "ing", "edly", "ed", "ly", "es", "s"):
        if len(w) > len(suf) + 2 and w.endswith(suf):
            return w[: -len(suf)]
    return w


def _bullet_tokens(s: str) -> set:
    return {_bullet_stem(w.lower()) for w in _WORD_RE.findall(s or "") if w.lower() not in _BULLET_STOPWORDS}


def _bullet_jaccard(a: str, b: str) -> float:
    ta, tb = _bullet_tokens(a), _bullet_tokens(b)
    if not ta or not tb:
        return 0.0
    return len(ta & tb) / len(ta | tb)


def _numeric_anchors(s: str) -> set:
    return set(_NUMERIC_RE.findall(s or ""))


def _is_duplicate_bullet(a: str, b: str) -> bool:
    """Two bullets are duplicates if:
       - they share a numeric anchor AND have Jaccard ≥ 0.40, OR
       - they have Jaccard ≥ 0.50 (no shared numeric anchor required)."""
    if _numeric_anchors(a) & _numeric_anchors(b):
        return _bullet_jaccard(a, b) >= 0.40
    return _bullet_jaccard(a, b) >= 0.50


def _merge_persona_experience(base_exp: list, persona_exp: list) -> list:
    """Merge persona experience entries into base experience, returning a new list.

    For each persona entry: find the base entry with the same normalized company AND
    matching title-root; if found, append persona bullets that don't duplicate any
    base bullet (via Jaccard + numeric-anchor). If no entry matches, append the
    persona entry wholesale (it's a role the base resume doesn't list)."""
    if not persona_exp:
        return [dict(e) for e in (base_exp or [])]
    if not base_exp:
        return [dict(e) for e in persona_exp]

    # Deep-clone base entries so we don't mutate the caller's data
    merged = [{**e, "bullets": list(e.get("bullets", []) or [])} for e in base_exp]

    # Index merged entries by normalized company → list of indices
    by_company = {}
    for i, e in enumerate(merged):
        cn = _normalize_company(e.get("company", ""))
        if cn:
            by_company.setdefault(cn, []).append(i)

    for p_exp in persona_exp:
        p_company = _normalize_company(p_exp.get("company", ""))
        candidates = by_company.get(p_company, [])
        chosen = None
        if candidates:
            p_root = _normalize_title_root(p_exp.get("title", ""))
            for i in candidates:
                if _normalize_title_root(merged[i].get("title", "")) == p_root:
                    chosen = i
                    break
            if chosen is None:
                chosen = candidates[0]  # fallback — same company, different role flavor

        if chosen is not None:
            existing = merged[chosen]["bullets"]
            for p_bullet in (p_exp.get("bullets") or []):
                if not any(_is_duplicate_bullet(p_bullet, eb) for eb in existing):
                    existing.append(p_bullet)
        else:
            new_entry = {**p_exp, "bullets": list(p_exp.get("bullets", []) or [])}
            merged.append(new_entry)
            cn = _normalize_company(p_exp.get("company", ""))
            if cn:
                by_company.setdefault(cn, []).append(len(merged) - 1)

    return merged
# ─────────────────────────────────────────────────────────────────────────────

logger = logging.getLogger("jobnavigator.resumes")

import asyncio as _asyncio

_tailoring_semaphore: _asyncio.Semaphore | None = None


def _get_tailoring_semaphore() -> _asyncio.Semaphore:
    """Lazy-init tailoring semaphore from DB setting. Created on first use."""
    global _tailoring_semaphore
    if _tailoring_semaphore is None:
        db = SessionLocal()
        try:
            row = db.query(Setting).filter(Setting.key == "tailoring_max_concurrent").first()
            try:
                limit = max(1, int(row.value)) if row and row.value else 2
            except (ValueError, TypeError):
                limit = 2
        finally:
            db.close()
        _tailoring_semaphore = _asyncio.Semaphore(limit)
        logger.info(f"Tailoring semaphore initialized: max {limit} concurrent jobs")
    return _tailoring_semaphore


def reset_tailoring_semaphore():
    """Reset semaphore so next call re-reads the limit from DB."""
    global _tailoring_semaphore
    _tailoring_semaphore = None


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

TEMPLATES_DIR = Path(__file__).parent.parent / "resume_templates"

# Warm Playwright browser singleton for fast PDF generation (~3-13ms warm vs ~500ms cold)
_pw_instance = None
_pw_browser = None

async def _get_browser():
    """Get or create a warm Playwright browser instance."""
    global _pw_instance, _pw_browser
    if _pw_browser and _pw_browser.is_connected():
        return _pw_browser
    from playwright.async_api import async_playwright
    _pw_instance = await async_playwright().start()
    _pw_browser = await _pw_instance.chromium.launch(headless=True, args=['--font-render-hinting=none'])
    logger.info("Warm Playwright browser started for PDF generation")
    return _pw_browser

def _default_template_id() -> str:
    """Return the first available template ID, or 'garamond' as last resort."""
    templates = _discover_templates()
    return templates[0]["id"] if templates else "garamond"


def _discover_templates() -> list[dict]:
    """Scan resume_templates/ for folders containing template.html.j2.
    Each folder can optionally include meta.json with 'name' and 'description'."""
    templates = []
    if not TEMPLATES_DIR.exists():
        return templates
    for d in sorted(TEMPLATES_DIR.iterdir()):
        if d.is_dir() and (d / "template.html.j2").exists():
            meta = {"id": d.name, "name": d.name.replace("_", " ").title(), "description": ""}
            meta_file = d / "meta.json"
            if meta_file.exists():
                try:
                    with open(meta_file) as f:
                        meta.update(json.load(f))
                        meta["id"] = d.name  # folder name is always the ID
                except Exception:
                    pass
            templates.append(meta)
    return templates


# ── Helpers ─────────────────────────────────────────────────────────────────

@_functools.lru_cache(maxsize=32)
def _load_template_fonts(fonts_dir_str: str) -> dict:
    """Read + base64-encode a template's fonts once per process.

    Fonts never change at runtime (~0.75 MB raw per serif template), but this was
    being re-read and re-encoded on every PDF preview/render. Keyed by directory
    path string so the resume and cover-letter template trees share the cache.
    """
    import base64
    from pathlib import Path as _Path
    fonts = {}
    fonts_dir = _Path(fonts_dir_str)
    if fonts_dir.exists():
        for pattern in ("*.TTF", "*.ttf"):
            for font_file in fonts_dir.glob(pattern):
                with open(font_file, "rb") as f:
                    fonts[font_file.name] = "data:font/truetype;base64," + base64.b64encode(f.read()).decode()
    return fonts


def _render_html(json_data: dict, template_name: str, page_format: str) -> str:
    """Render a resume to HTML using its Jinja2 template."""
    from jinja2 import Environment, FileSystemLoader

    template_dir = TEMPLATES_DIR / template_name
    if not template_dir.exists():
        raise HTTPException(status_code=400, detail=f"Template '{template_name}' not found")

    import re as _re
    env = Environment(loader=FileSystemLoader(str(template_dir)))
    from markupsafe import Markup
    env.filters['bold'] = lambda text: Markup(_re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', _re.sub(r'[<>&]', lambda m: {'<':'&lt;','>':'&gt;','&':'&amp;'}[m.group()], text or '')))
    template = env.get_template("template.html.j2")

    # Embed fonts as base64 data URIs (file:// blocked by Chromium in set_content)
    fonts = _load_template_fonts(str(template_dir / "fonts"))

    html = template.render(
        **json_data,
        page_format=page_format,
        fonts_base="",
        fonts=fonts,
    )
    return html


def _rewrite_urls_with_tracers(json_data: dict, resume_id: str, db,
                               cover_letter_id: str = None, job_id=None) -> dict:
    """Replace header contact URLs with tracer redirect URLs. Returns modified copy.

    Owner is exactly one of a resume (resume_id) or a cover letter
    (cover_letter_id). For job_id token styles, the owning job's short_id is
    resolved from the Resume's job_id, or from the passed `job_id` for cover
    letters.
    """
    import string, random, json as _json

    enabled_row = db.query(Setting).filter(Setting.key == "tracer_links_enabled").first()
    if not enabled_row or enabled_row.value != "true":
        return json_data

    base_url_row = db.query(Setting).filter(Setting.key == "tracer_links_base_url").first()
    base_url = (base_url_row.value if base_url_row else "").rstrip("/")
    if not base_url:
        return json_data

    style_row = db.query(Setting).filter(Setting.key == "tracer_links_url_style").first()
    url_style = style_row.value if style_row else "path"

    owner_is_cl = cover_letter_id is not None
    owner_filter = (TracerLink.cover_letter_id == cover_letter_id) if owner_is_cl \
        else (TracerLink.resume_id == resume_id)

    def _new_link(token, dest_url, label):
        kwargs = {"token": token, "destination_url": dest_url, "source_label": label}
        if owner_is_cl:
            kwargs["cover_letter_id"] = cover_letter_id
        else:
            kwargs["resume_id"] = resume_id
        return TracerLink(**kwargs)

    def _repoint(link, dest_url, label):
        # Reassign this token to the current owner, clearing the other owner so a
        # link never ends up owned by both (a resume + a cover letter for the same
        # job derive the same {short_id}{stub} token and would otherwise collide).
        if owner_is_cl:
            link.cover_letter_id = cover_letter_id
            link.resume_id = None
        else:
            link.resume_id = resume_id
            link.cover_letter_id = None
        link.destination_url = dest_url
        link.source_label = label

    # Resolve the owning job's short_id once (for *_jobid token styles).
    job_short_id = None
    if url_style in ("path_jobid", "param_jobid"):
        resolved_job_id = job_id
        if not resolved_job_id and not owner_is_cl:
            resume_obj = db.query(Resume).filter(Resume.id == resume_id).first()
            resolved_job_id = resume_obj.job_id if resume_obj else None
        if resolved_job_id:
            job_obj = db.query(Job).filter(Job.id == resolved_job_id).first()
            job_short_id = job_obj.short_id if job_obj else None

    data = _json.loads(_json.dumps(json_data))  # deep copy
    header = data.get("header", {})

    items = header.get("contact_items", [])
    for i, item in enumerate(items):
        url = item.get("url")
        if not url or not url.strip() or url.startswith("mailto:"):
            continue

        label = item.get("text", f"Link {i+1}")
        dest_url = url if url.startswith("http") else f"https://{url}"
        # Suffix for per-link distinction in job_id modes (user stub or first 3 chars)
        label_suffix = item.get("stub") or label.lower()[:3]

        token = f"{job_short_id}{label_suffix}" if job_short_id else None

        # Find or create tracer link for this owner + destination
        existing = db.query(TracerLink).filter(
            owner_filter, TracerLink.destination_url == dest_url,
        ).first()

        if existing:
            if token and existing.token != token:
                existing.token = token
                db.commit()
            if not token:
                token = existing.token
        else:
            if token:
                existing_by_token = db.query(TracerLink).filter(TracerLink.token == token).first()
                if existing_by_token:
                    _repoint(existing_by_token, dest_url, label)
                    db.commit()
                else:
                    db.add(_new_link(token, dest_url, label))
                    db.commit()
            else:
                chars = string.ascii_lowercase + string.digits
                for _ in range(100):
                    token = ''.join(random.choices(chars, k=6))
                    if not db.query(TracerLink).filter(TracerLink.token == token).first():
                        break
                db.add(_new_link(token, dest_url, label))
                db.commit()

        if url_style in ("param", "param_jobid"):
            tracer_url = f"{base_url}?cv={token}"
        else:
            tracer_url = f"{base_url}/cv/{token}"

        items[i]["url"] = tracer_url

    data["header"] = header
    return data


def _resume_to_dict(r: Resume, include_json_data: bool = False) -> dict:
    """Serialize a Resume row to a dict."""
    d = {
        "id": str(r.id),
        "name": r.name,
        "is_base": r.is_base,
        "parent_id": str(r.parent_id) if r.parent_id else None,
        "job_id": str(r.job_id) if r.job_id else None,
        "template": r.template,
        "page_format": r.page_format,
        "created_at": r.created_at.isoformat() if r.created_at else None,
        "updated_at": r.updated_at.isoformat() if r.updated_at else None,
    }
    if include_json_data:
        d["json_data"] = r.json_data or {}
    return d


# ── Template listing ────────────────────────────────────────────────────────

@router.get("/templates")
def list_templates():
    """Return available resume templates (auto-discovered from filesystem)."""
    return _discover_templates()


# ── CRUD ────────────────────────────────────────────────────────────────────

@router.get("")
def list_resumes(is_base: Optional[bool] = None, db: Session = Depends(get_db)):
    """List all resumes. Optional filter: is_base=true for base resumes only."""
    q = db.query(Resume).order_by(Resume.updated_at.desc())
    if is_base is not None:
        q = q.filter(Resume.is_base == is_base)
    resumes = q.all()
    return [_resume_to_dict(r) for r in resumes]


@router.post("", status_code=201)
def create_resume(body: dict, db: Session = Depends(get_db)):
    """Create a new resume.

    Body: {name, is_base?, parent_id?, job_id?, template?, page_format?, json_data?}
    """
    name = body.get("name", "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="name is required")

    resume = Resume(
        name=name,
        is_base=body.get("is_base", True),
        parent_id=body.get("parent_id"),
        job_id=body.get("job_id"),
        template=body.get("template", _default_template_id()),
        page_format=body.get("page_format", "letter"),
        json_data=body.get("json_data", {}),
    )
    db.add(resume)
    db.commit()
    db.refresh(resume)
    return _resume_to_dict(resume, include_json_data=True)


@router.post("/copy")
def copy_resume_for_job(body: dict, db: Session = Depends(get_db)):
    """Copy a base resume for a job — no LLM, just exact copy with tracer links."""
    import json as _json
    base_resume_id = body.get("base_resume_id")
    job_id = body.get("job_id")
    if not base_resume_id or not job_id:
        raise HTTPException(400, "base_resume_id and job_id are required")

    base = db.query(Resume).filter(Resume.id == base_resume_id).first()
    if not base:
        raise HTTPException(404, "Base resume not found")


    job = db.query(Job).filter(Job.id == job_id).first()
    if not job:
        raise HTTPException(404, "Job not found")

    job_name = f"{job.company} \u2014 {job.title}" if job.company else job.title or ""
    copy = Resume(
        name=f"{base.name} \u2192 {job_name}",
        is_base=False,
        parent_id=base.id,
        job_id=job_id,
        template=base.template,
        page_format=base.page_format,
        json_data=_json.loads(_json.dumps(base.json_data or {})),
    )
    db.add(copy)
    db.commit()
    db.refresh(copy)
    return _resume_to_dict(copy, include_json_data=True)


@router.post("/tailor", status_code=202)
async def tailor_resume(body: dict, db: Session = Depends(get_db)):
    """Tailor a base resume for a specific job in the background.

    Returns immediately with a run_id. Progress is trackable via
    GET /api/monitor/in-flight?job_ids=<job_id>. The resulting Resume
    row appears when the job finishes (fetch via list_resumes).
    """
    base_resume_id = body.get("base_resume_id")
    job_id = body.get("job_id")
    job_description = body.get("job_description")

    if not base_resume_id:
        raise HTTPException(400, "base_resume_id is required")
    if not job_id and not job_description:
        raise HTTPException(400, "Either job_id or job_description is required")

    # Fast-fail: base resume must exist. Reserved id 'persona' resolves to the
    # singleton Persona's resume_content — must be non-empty.
    if base_resume_id == "persona":
        persona = db.query(Persona).filter(Persona.id == 1).first()
        if not persona or not (persona.resume_content or {}):
            raise HTTPException(400, "Persona has no resume_content — fill it in /persona first")
    else:
        base = db.query(Resume).filter(Resume.id == base_resume_id).first()
        if not base:
            raise HTTPException(404, "Base resume not found")

    # Fast-fail: job must exist (if job_id given) and have description
    if job_id:
        job = db.query(Job).filter(Job.id == job_id).first()
        if not job:
            raise HTTPException(404, "Job not found")
        if not (job.description or "").strip():
            raise HTTPException(400, "Job has no description")

    # Fast-fail: the prompt template must exist
    prompt_row = db.query(Setting).filter(Setting.key == "cv_tailor_prompt").first()
    if not prompt_row or not (prompt_row.value or "").strip():
        raise HTTPException(500, "cv_tailor_prompt setting is empty — configure it in Settings")

    target_uuid = _uuid.UUID(job_id) if job_id else None
    scope = f"{base_resume_id}:{job_id or 'freeform'}"

    try:
        run_id = launch_background(
            "tailor_resume",
            _tailor_impl,
            trigger="manual",
            scope_key=scope,
            target_job_id=target_uuid,
            func_kwargs={
                "base_resume_id": base_resume_id,
                "job_id": job_id,
                "job_description_override": job_description,
            },
        )
        return {"run_id": run_id, "status": "running"}
    except JobAlreadyRunningError as e:
        return JSONResponse(
            status_code=409,
            content={"detail": f"{e.job_type} is already running for this pair"},
        )


async def _tailor_impl(base_resume_id: str, job_id: str | None, job_description_override: str | None):
    """Background worker: does the actual LLM tailoring work.

    Opens its own DB session (no request-scoped session available outside an HTTP
    handler). Semaphore-guarded so concurrent tailors don't exceed
    tailoring_max_concurrent.
    """
    import re as _re
    import json as _json

    async with _get_tailoring_semaphore():
        db = SessionLocal()
        try:
            # Reserved id 'persona' uses the singleton Persona's resume_content as
            # the base. The output Resume has parent_id=None since Persona isn't a
            # Resume row.
            persona_as_base = (base_resume_id == "persona")
            if persona_as_base:
                persona_row = db.query(Persona).filter(Persona.id == 1).first()
                if not persona_row or not (persona_row.resume_content or {}):
                    logger.error("Tailor: persona has no resume_content at execution time")
                    raise RuntimeError("Tailor: persona has no resume_content at execution time")
                base = None
                base_data = persona_row.resume_content or {}
                base_name = "Persona"
                base_template = None
                base_page_format = None
                base_id_for_parent = None
            else:
                base = db.query(Resume).filter(Resume.id == base_resume_id).first()
                if not base:
                    logger.error(f"Tailor: base resume {base_resume_id} missing at execution time")
                    raise RuntimeError(f"Tailor: base resume {base_resume_id} missing at execution time")
                base_data = base.json_data or {}
                base_name = base.name
                base_template = base.template
                base_page_format = base.page_format
                base_id_for_parent = base.id

            jd_text = job_description_override or ""
            job_name = ""
            if job_id:
                job = db.query(Job).filter(Job.id == job_id).first()
                if not job:
                    logger.error(f"Tailor: job {job_id} missing at execution time")
                    raise RuntimeError(f"Tailor: job {job_id} missing at execution time")
                jd_text = job.description or ""
                job_name = f"{job.company} \u2014 {job.title}" if job.company else (job.title or "")
                if not jd_text:
                    logger.error(f"Tailor: job {job_id} has no description")
                    raise RuntimeError(f"Tailor: job {job_id} has no description")

            # Persona-as-base uses a constrained prompt (select 3-5 bullets per role
            # from the rich pool); falls back to the standard cv_tailor_prompt if the
            # persona-specific one isn't configured.
            prompt_template = None
            if persona_as_base:
                p_row = db.query(Setting).filter(Setting.key == "persona_tailor_prompt").first()
                if p_row and (p_row.value or "").strip():
                    prompt_template = p_row.value
            if not prompt_template:
                prompt_row = db.query(Setting).filter(Setting.key == "cv_tailor_prompt").first()
                if not prompt_row or not prompt_row.value:
                    logger.error("Tailor: cv_tailor_prompt setting is empty")
                    raise RuntimeError("Tailor: cv_tailor_prompt setting is empty")
                prompt_template = prompt_row.value

            resume_sections = {
                "summary": base_data.get("summary", ""),
                "experience": list(base_data.get("experience", []) or []),
                "skills": dict(base_data.get("skills", {}) or {}),
            }

            # Note: Persona is NOT auto-merged into Resume-as-base tailoring. Two clean
            # modes — Resume-as-base uses ONLY the base resume's bullets (predictable
            # output length); Persona-as-base uses persona's full pool with the
            # constrained persona_tailor_prompt that selects 3-5 best bullets per role.

            prompt = prompt_template.replace("{resume_json}", _json.dumps(resume_sections, indent=2))
            prompt = prompt.replace("{job_description}", jd_text[:6000])

            system = (
                "You are an expert resume tailor. Rewrite the resume to align with the "
                "job description using the JD's exact vocabulary. Do NOT invent experience, "
                "skills, or facts not present in the original resume. Only reformulate, "
                "reframe, and reorder existing content. If something is missing, map to "
                "the closest truthful concept."
            )

            from backend.analyzer.llm_client import call_cv_tailor_llm
            from backend.analyzer.llm_logger import track_llm_call

            _m = db.query(Setting).filter(Setting.key == "cv_tailor_llm_model").first()
            _model = _m.value if _m and _m.value else None
            if not _model:
                _m2 = db.query(Setting).filter(Setting.key == "llm_model").first()
                _model = _m2.value if _m2 and _m2.value else "claude-sonnet-4-6"
            _p = db.query(Setting).filter(Setting.key == "cv_tailor_llm_provider").first()
            _provider = _p.value if _p and _p.value else None
            if not _provider:
                _p2 = db.query(Setting).filter(Setting.key == "llm_provider").first()
                _provider = _p2.value if _p2 and _p2.value else "claude_api"

            try:
                async with track_llm_call("tailor", _provider, _model, job_id=job_id) as _tracker:
                    _resp = await call_cv_tailor_llm(prompt, system, max_tokens=3000)
                    _tracker.usage = _resp.get("usage", _tracker.usage)
                    raw = _resp["text"]
            except Exception as e:
                logger.error(f"Tailor LLM failed for base={base_resume_id} job={job_id}: {e}")
                raise

            try:
                text = raw.strip()
                match = _re.search(r'\{[\s\S]*\}', text)
                if match:
                    text = match.group(0)
                llm_result = _json.loads(text)
            except _json.JSONDecodeError as e:
                logger.error(f"Tailor JSON parse failed: {e}. Raw: {raw[:500]}")
                raise

            tailored_data = _json.loads(_json.dumps(base_data))
            if "summary" in llm_result:
                tailored_data["summary"] = llm_result["summary"]
            if "experience" in llm_result:
                llm_exp = llm_result["experience"]
                base_exp = tailored_data.get("experience", [])
                for i, llm_job in enumerate(llm_exp):
                    if i < len(base_exp):
                        base_exp[i]["bullets"] = llm_job.get("bullets", base_exp[i].get("bullets", []))
                        if llm_job.get("suggested_bullets"):
                            base_exp[i]["suggested_bullets"] = llm_job["suggested_bullets"]
                        if llm_job.get("description") is not None:
                            base_exp[i]["description"] = llm_job["description"]
                tailored_data["experience"] = base_exp
            if "skills" in llm_result:
                tailored_data["skills"] = llm_result["skills"]

            name = f"{base_name} \u2192 {job_name}" if job_name else f"{base_name} (tailored)"
            tailored = Resume(
                name=name,
                is_base=False,
                parent_id=base_id_for_parent,
                job_id=job_id,
                template=base_template,
                page_format=base_page_format,
                json_data=tailored_data,
            )
            db.add(tailored)
            db.commit()
            db.refresh(tailored)
            # Optional: chain a score against the newly tailored CV.
            # Setting `tailor_auto_quick_score` accepts:
            #   'off' / 'false'           → no chain
            #   'light' / 'true' / ''     → light chain (default)
            #   'full'                    → full chain (richer report, slower/costlier)
            chain_row = db.query(Setting).filter(Setting.key == "tailor_auto_quick_score").first()
            raw_chain = (chain_row.value if chain_row else "light").strip().lower()
            depth_map = {
                "off": None, "false": None, "no": None, "0": None,
                "true": "light", "light": "light", "yes": "light", "1": "light", "": "light",
                "full": "full",
            }
            chain_depth = depth_map.get(raw_chain, "light")
            if chain_depth and job_id:
                try:
                    from backend.analyzer.cv_scorer import score_single_job
                    launch_background(
                        "analyze_job",
                        score_single_job,
                        trigger="manual",
                        scope_key=f"{job_id}:tailored:{tailored.id}",
                        target_job_id=_uuid.UUID(job_id) if isinstance(job_id, str) else job_id,
                        func_kwargs={
                            "job_id": job_id,
                            "cv_ids": [str(tailored.id)],
                            "depth": chain_depth,
                        },
                    )
                except Exception as _e:
                    # Non-fatal — tailor succeeded, chain is a nice-to-have
                    logger.warning(f"Tailor chain score failed to launch: {_e}")
            logger.info(f"Tailor: created resume {tailored.id} for job {job_id}")
        finally:
            db.close()


@router.get("/{resume_id}")
def get_resume(resume_id: str, db: Session = Depends(get_db)):
    """Get a single resume with its full json_data."""
    resume = db.query(Resume).filter(Resume.id == resume_id).first()
    if not resume:
        raise HTTPException(status_code=404, detail="Resume not found")
    return _resume_to_dict(resume, include_json_data=True)


@router.patch("/{resume_id}")
def update_resume(resume_id: str, body: dict, db: Session = Depends(get_db)):
    """Update resume fields. Supports partial updates.

    Body: any subset of {name, is_base, parent_id, job_id, template, page_format, json_data}
    """
    resume = db.query(Resume).filter(Resume.id == resume_id).first()
    if not resume:
        raise HTTPException(status_code=404, detail="Resume not found")

    allowed = {"name", "is_base", "parent_id", "job_id", "template", "page_format", "json_data"}
    for key, value in body.items():
        if key in allowed:
            setattr(resume, key, value)

    resume.updated_at = utcnow()
    db.commit()
    db.refresh(resume)
    return _resume_to_dict(resume, include_json_data=True)


@router.delete("/{resume_id}")
def delete_resume(resume_id: str, db: Session = Depends(get_db)):
    """Delete a resume and cascade-delete its tailored children."""
    resume = db.query(Resume).filter(Resume.id == resume_id).first()
    if not resume:
        raise HTTPException(status_code=404, detail="Resume not found")

    # Delete tracer links + tailored children first
    children = db.query(Resume).filter(Resume.parent_id == resume_id).all()
    child_ids = [c.id for c in children]
    all_ids = [resume.id] + child_ids
    # Delete tracer links for this resume and all children
    db.query(TracerLink).filter(TracerLink.resume_id.in_(all_ids)).delete(synchronize_session=False)
    for child in children:
        db.delete(child)

    db.delete(resume)
    db.commit()
    return {"deleted": True, "id": resume_id, "children_deleted": len(children)}


# ── Preview & PDF ───────────────────────────────────────────────────────────

@router.get("/{resume_id}/preview")
def preview_resume(resume_id: str, db: Session = Depends(get_db)):
    """Render resume as HTML and return it for preview."""
    resume = db.query(Resume).filter(Resume.id == resume_id).first()
    if not resume:
        raise HTTPException(status_code=404, detail="Resume not found")

    json_data = resume.json_data or {}
    html = _render_html(json_data, resume.template, resume.page_format)
    return HTMLResponse(content=html)


@router.get("/{resume_id}/pdf")
async def export_pdf(resume_id: str, db: Session = Depends(get_db)):
    """Render resume as PDF via Playwright and return the bytes."""
    resume = db.query(Resume).filter(Resume.id == resume_id).first()
    if not resume:
        raise HTTPException(status_code=404, detail="Resume not found")

    json_data = resume.json_data or {}
    # Rewrite URLs with tracer links if enabled
    pdf_data = _rewrite_urls_with_tracers(json_data, str(resume.id), db)
    html = _render_html(pdf_data, resume.template, resume.page_format)

    # Determine paper format
    fmt = resume.page_format or "letter"
    paper_format = "A4" if fmt.lower() == "a4" else "Letter"

    try:
        browser = await _get_browser()
        page = await browser.new_page()
        await page.set_content(html, wait_until="networkidle")
        pdf_bytes = await page.pdf(
            format=paper_format,
            print_background=True,
            margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
        )
        # Count pages (rough estimate from PDF byte boundaries)
        page_count = pdf_bytes.count(b"/Type /Page") - pdf_bytes.count(b"/Type /Pages")
        if page_count < 1:
            page_count = 1
        await page.close()
    except Exception as e:
        logger.error(f"PDF generation failed: {e}")
        raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}")

    # Build filename: {Name}_{Type}_Resume_{number}.pdf
    # Name = candidate header name; Type = base resume name (PM/PjM/\u2026);
    # number = linked job short_id (omitted for base resumes with no job).
    header_name = (resume.json_data or {}).get("header", {}).get("name", "Resume").replace(" ", "")
    base_name = (resume.name.split(" \u2192 ")[0] if " \u2192 " in (resume.name or "") else resume.name) or "Resume"
    base_name = base_name.replace(" ", "")
    number = ""
    if resume.job_id:
        job_for_name = db.query(Job).filter(Job.id == resume.job_id).first()
        if job_for_name and job_for_name.short_id:
            number = f"_{job_for_name.short_id}"
    filename = f"{header_name}_{base_name}_Resume{number}".encode("ascii", "replace").decode()

    headers = {
        "Content-Disposition": f'attachment; filename="{filename}.pdf"',
        "X-Page-Count": str(page_count),
    }
    if page_count > 1:
        headers["X-Warning"] = f"Resume is {page_count} pages - consider trimming to 1 page"

    return Response(
        content=pdf_bytes,
        media_type="application/pdf",
        headers=headers,
    )


# ── PDF Import ──────────────────────────────────────────────────────────────

@router.post("/import-pdf", status_code=201)
async def import_pdf(file: UploadFile = File(...), db: Session = Depends(get_db)):
    """Upload a PDF resume, extract text with pdfplumber, use LLM to parse into structured json_data.

    Returns the created Resume with extracted json_data.
    """
    if not file.filename.lower().endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Only PDF files are accepted")

    pdf_bytes = await file.read()
    if len(pdf_bytes) > 10 * 1024 * 1024:
        raise HTTPException(status_code=400, detail="PDF too large (max 10 MB)")

    # Extract text via pdfplumber
    extracted_text = ""
    try:
        import pdfplumber
        with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
            for page in pdf.pages:
                text = page.extract_text()
                if text:
                    extracted_text += text + "\n"
    except Exception as e:
        raise HTTPException(status_code=422, detail=f"Failed to process PDF: {str(e)}")

    if len(extracted_text.strip()) < 50:
        raise HTTPException(status_code=422, detail="Could not extract enough text from PDF. It may be image-based.")

    # Use LLM to parse extracted text into structured resume JSON
    schema_example = '{"header":{"name":"","contact_items":[{"text":"location"},{"text":"email","url":"mailto:email"},{"text":"LinkedIn","url":"linkedin.com/in/..."},{"text":"phone"}]},"summary":"","experience":[{"company":"","title":"","location":"","date":"","description":"","bullets":[]}],"skills":{},"education":[{"school":"","location":"","degree":""}],"projects":[],"publications":[]}'

    system_prompt = "You are a resume parser. Extract structured data from resume text. Return ONLY valid JSON, no markdown fences."
    user_prompt = (
        f"Parse this resume text into the following JSON structure. "
        f"Fill in all fields you can find. Use empty strings for missing fields, empty arrays for missing lists.\n\n"
        f"Target schema:\n{schema_example}\n\n"
        f"Resume text:\n{extracted_text}"
    )

    try:
        from backend.analyzer.llm_client import call_llm
        from backend.analyzer.llm_logger import track_llm_call
        # Determine model for logging
        _m = db.query(Setting).filter(Setting.key == "llm_model").first()
        _model = _m.value if _m and _m.value else "claude-sonnet-4-6"
        _p = db.query(Setting).filter(Setting.key == "llm_provider").first()
        _provider = _p.value if _p and _p.value else "claude_api"
        async with track_llm_call("pdf", _provider, _model) as _tracker:
            _resp = await call_llm(prompt=user_prompt, system=system_prompt, max_tokens=2000)
            _tracker.usage = _resp.get("usage", _tracker.usage)
            raw_response = _resp["text"]

        # Strip markdown fences if present
        cleaned = raw_response.strip()
        if cleaned.startswith("```"):
            # Remove opening fence (with optional language tag)
            first_newline = cleaned.index("\n")
            cleaned = cleaned[first_newline + 1:]
        if cleaned.endswith("```"):
            cleaned = cleaned[:-3]
        cleaned = cleaned.strip()

        json_data = json.loads(cleaned)
    except json.JSONDecodeError as e:
        logger.error(f"LLM returned invalid JSON for PDF import: {e}\nRaw: {raw_response[:500]}")
        raise HTTPException(status_code=422, detail="LLM returned invalid JSON. Try again or enter data manually.")
    except Exception as e:
        logger.error(f"LLM call failed during PDF import: {e}")
        raise HTTPException(status_code=500, detail=f"LLM extraction failed: {str(e)}")

    # Create resume with extracted data
    name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename
    resume = Resume(
        name=name,
        is_base=True,
        template=_default_template_id(),
        page_format="letter",
        json_data=json_data,
    )
    db.add(resume)
    db.commit()
    db.refresh(resume)

    return _resume_to_dict(resume, include_json_data=True)


# ── Score Check ────────────────────────────────────────────────────────────

def _resume_to_score_text(json_data: dict) -> str:
    """Flatten a Resume.json_data into the plaintext form passed to the scorer.

    Thin wrapper around analyzer.cv_scorer._flatten_resume so the score-check pre-check
    and the LLM payload always agree on the canonical flatten (skills is a dict, not a list).
    """
    from backend.analyzer.cv_scorer import _flatten_resume
    return _flatten_resume(json_data or {})


async def _score_resume_impl(resume_id: str, depth: str):
    """Background worker: score a tailored resume against its linked job.

    Runs under launch_background → progress visible via /monitor/active so the
    spinner survives navigation away from /resumes.
    """
    from backend.analyzer.cv_scorer import score_job_sync

    db = SessionLocal()
    try:
        resume = db.query(Resume).filter(Resume.id == resume_id).first()
        if not resume:
            logger.error(f"Score: resume {resume_id} missing at execution time")
            return
        if not resume.job_id:
            logger.error(f"Score: resume {resume_id} has no linked job")
            return

        job = db.query(Job).filter(Job.id == resume.job_id).first()
        if not job:
            logger.error(f"Score: linked job {resume.job_id} not found")
            return

        resume_text = _resume_to_score_text(resume.json_data or {})
        if len(resume_text) < 50:
            logger.warning(f"Score: resume {resume_id} has insufficient text ({len(resume_text)} chars)")
            return

        cv_texts = {"Tailored": resume_text}
        result = await score_job_sync(job, cv_texts, db=db, depth=depth)
        if not result:
            logger.error(f"Score: scoring failed for resume {resume_id}")
            return

        tailored_score = None
        scores = result.get("scores", result)
        if isinstance(scores, dict):
            tailored_score = scores.get("Tailored")

        updated_scores = dict(job.cv_scores or {})
        if tailored_score is not None:
            updated_scores["Tailored"] = tailored_score
            job.cv_scores = updated_scores
            numeric = {k: v for k, v in updated_scores.items() if isinstance(v, (int, float))}
            if numeric:
                job.best_cv = max(numeric, key=numeric.get)
                try:
                    job.best_cv_score = float(max(numeric.values()))
                except (ValueError, TypeError):
                    job.best_cv_score = None

        if depth == "full" and result.get("_scoring_report"):
            report = result["_scoring_report"]
            report["scored_with"] = "Tailored"
            existing = dict(job.scoring_report or {})
            if existing and "summary" in existing:
                old_cv = existing.pop("scored_with", job.best_cv or "Unknown")
                existing = {old_cv: existing}
            existing["Tailored"] = report
            job.scoring_report = existing

        db.commit()
        logger.info(f"Score: resume {resume_id} → job {resume.job_id} = {tailored_score} (depth={depth})")
    finally:
        db.close()


@router.post("/{resume_id}/score-check", status_code=202)
async def score_check(resume_id: str, request_body: dict = None, db: Session = Depends(get_db)):
    """Score a tailored resume against its linked job in the background.

    Returns 202 + run_id immediately. Progress trackable via GET /api/monitor/active
    (job_type=score_resume). Result lands in the job's cv_scores under 'Tailored'.
    """
    import uuid as _uuid

    resume = db.query(Resume).filter(Resume.id == resume_id).first()
    if not resume:
        raise HTTPException(status_code=404, detail="Resume not found")
    if not resume.job_id:
        raise HTTPException(status_code=400, detail="Resume has no linked job")

    job = db.query(Job).filter(Job.id == resume.job_id).first()
    if not job:
        raise HTTPException(status_code=404, detail="Linked job not found")
    # Pre-check matches the worker's text-resolution: cv_scorer._get_job_text() falls
    # back to cached_page_text (and even live page-fetch) when description is empty.
    # Don't 400 jobs that scored fine via cache alone.
    if not (job.description or "").strip() and not (job.cached_page_text or "").strip():
        raise HTTPException(status_code=400, detail="Linked job has no description or cached page text")

    if len(_resume_to_score_text(resume.json_data or {})) < 50:
        raise HTTPException(status_code=400, detail="Resume has insufficient text for scoring")

    depth = (request_body or {}).get("depth", "light")
    if depth not in ("light", "full"):
        depth = "light"

    scope = f"{resume.job_id}:resume:{resume_id}"
    try:
        run_id = launch_background(
            "score_resume",
            _score_resume_impl,
            trigger="manual",
            scope_key=scope,
            target_job_id=_uuid.UUID(str(resume.job_id)),
            func_kwargs={"resume_id": resume_id, "depth": depth},
        )
        return {"run_id": run_id, "status": "running", "depth": depth, "resume_id": resume_id}
    except JobAlreadyRunningError as e:
        return JSONResponse(
            status_code=409,
            content={"detail": f"{e.job_type} is already running for this resume"},
        )


# ── Tracer Stats ───────────────────────────────────────────────────────────

@router.get("/{resume_id}/tracer-stats")
def get_tracer_stats(resume_id: str, db: Session = Depends(get_db)):
    """Get click stats per tracer link for a resume."""
    from sqlalchemy import func
    links = db.query(TracerLink).filter(TracerLink.resume_id == resume_id).all()
    result = []
    for link in links:
        total_clicks = db.query(func.count(TracerClickEvent.id)).filter(
            TracerClickEvent.tracer_link_id == link.id,
            TracerClickEvent.is_likely_bot == False,
        ).scalar()
        last_click = db.query(func.max(TracerClickEvent.clicked_at)).filter(
            TracerClickEvent.tracer_link_id == link.id,
            TracerClickEvent.is_likely_bot == False,
        ).scalar()
        result.append({
            "token": link.token,
            "source_label": link.source_label,
            "destination_url": link.destination_url,
            "clicks": total_clicks or 0,
            "last_clicked": last_click.isoformat() if last_click else None,
            "is_active": link.is_active,
        })
    return result
