#!/usr/bin/env python3
"""Build data/{exam}/questions.json from questions-review.json."""
from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = Path(__file__).resolve().parent
if str(SCRIPTS) not in sys.path:
    sys.path.insert(0, str(SCRIPTS))

from exam_config import ExamProfile, get_profile

PREFACE_ID_BASE = 90000
PLAYABLE_TYPES = frozenset({"single_choice", "multi_select", "yes_no"})
DEFERRED_TYPES = frozenset({"hotspot", "dropdown", "drag_drop"})


def bilingual(text: str | None) -> dict:
    from clean_text import clean_extracted_text

    t = clean_extracted_text(text)
    return {"en": t, "vi": None}


def resolve_id(item: dict, used: set[int], profile: ExamProfile) -> int:
    """id matches PDF Question:N (pdfId in review)."""
    from document_ids import PREFACE_ID_BASE, is_junk_id

    doc_id = item.get("pdfId")
    seq = int(item.get("seq") or 0)
    if doc_id is None:
        doc_id = PREFACE_ID_BASE + seq if seq <= profile.preface_seq_max else seq
    qid = int(doc_id)
    if is_junk_id(qid):
        qid = seq if seq > profile.preface_seq_max else PREFACE_ID_BASE + seq
    while qid in used:
        qid += 1
    used.add(qid)
    return qid


def load_pdf_pages_by_seq(path: Path) -> dict[int, int]:
    if not path.exists():
        return {}
    try:
        old = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return {
        int(q["meta"]["reviewSeq"]): q["source"]["pdfPage"]
        for q in old
        if (q.get("meta") or {}).get("reviewSeq") is not None
        and (q.get("source") or {}).get("pdfPage")
    }


def load_old_by_review_seq(path: Path) -> dict[int, dict]:
    if not path.exists():
        return {}
    try:
        old = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return {
        int(q["meta"]["reviewSeq"]): q
        for q in old
        if (q.get("meta") or {}).get("reviewSeq") is not None
    }


def copy_bilingual(dst: dict | None, src: dict | None) -> None:
    if not dst or not src or not src.get("vi"):
        return
    dst["vi"] = src["vi"]


def merge_translations(new_q: dict, old_q: dict) -> None:
    copy_bilingual(new_q.get("stem"), old_q.get("stem"))
    copy_bilingual(new_q.get("explanation"), old_q.get("explanation"))
    copy_bilingual((new_q.get("answer") or {}).get("text"), (old_q.get("answer") or {}).get("text"))
    copy_bilingual((new_q.get("meta") or {}).get("scenario"), (old_q.get("meta") or {}).get("scenario"))

    for opt_n, opt_o in zip(new_q.get("options") or [], old_q.get("options") or []):
        if opt_n.get("key") == opt_o.get("key"):
            copy_bilingual(opt_n.get("text"), opt_o.get("text"))

    for box_n, box_o in zip(new_q.get("boxes") or [], old_q.get("boxes") or []):
        if box_n.get("id") == box_o.get("id"):
            copy_bilingual(box_n.get("answer"), box_o.get("answer"))

    old_meta = old_q.get("meta") or {}
    if old_meta.get("translatedAt"):
        new_q.setdefault("meta", {})["translatedAt"] = old_meta["translatedAt"]


def extract_yes_no_scenario(question_text: str) -> str | None:
    from extract_pdf import extract_yes_no_parts

    scenario, _, _ = extract_yes_no_parts(question_text)
    return scenario


def build_boxes(qtype: str, explanation: str | None) -> list[dict]:
    from enrich_questions import infer_slots_from_explanation, parse_boxes

    exp = explanation or ""
    boxes: list[dict] = []
    for b in parse_boxes(exp):
        boxes.append(
            {
                "id": b["id"],
                "label": b["label"],
                "answer": bilingual(b["text"]),
            }
        )
    if boxes:
        return boxes
    if qtype in ("drag_drop", "hotspot", "dropdown", "unknown"):
        slots = infer_slots_from_explanation(exp, "")
        if slots:
            return [
                {
                    "id": s["id"],
                    "label": s.get("label") or f"Slot {s['id']}",
                    "answer": bilingual(s["text"]),
                }
                for s in slots
            ]
    return []


def media_required(qtype: str, stem: str, question_text: str) -> bool:
    if qtype in DEFERRED_TYPES:
        return True
    bl = f"{stem} {question_text}".lower()
    return any(
        x in bl
        for x in (
            "graphic",
            "image",
            "hot area",
            "diagram",
            "exhibit",
            "following exhibit",
            "select and place",
        )
    )


def has_hotspot_label(item: dict) -> bool:
    text = item.get("questionText") or ""
    return bool(re.search(r"HOTSPOT\s*-", text, re.I))


def is_deferred(item: dict) -> bool:
    qtype = item.get("type") or "unknown"
    if qtype in DEFERRED_TYPES:
        return True
    if has_hotspot_label(item):
        return True
    if "hot area:" in (item.get("questionText") or "").lower():
        return True
    if "each correct selection is worth one point" in (
        item.get("questionText") or ""
    ).lower():
        return True
    return False


def is_playable(item: dict) -> bool:
    if is_deferred(item):
        return False
    qtype = item.get("type") or "unknown"
    if qtype not in PLAYABLE_TYPES:
        return False
    keys = item.get("answer", {}).get("keys") or []
    opts = item.get("options") or []
    stem = (item.get("stem") or "").strip()
    if not keys or not opts or len(stem) < 15:
        return False
    return True


def review_to_question(
    item: dict,
    profile: ExamProfile,
    page_by_seq: dict[int, int],
    used_ids: set[int],
    old_by_seq: dict[int, dict],
    *,
    parse_status: str,
) -> dict:
    from enrich_questions import build_interaction

    qtype = item.get("type") or "unknown"
    stem_en = (item.get("stem") or "").strip() or (item.get("questionText") or "").strip()
    explanation_en = item.get("explanation")
    keys = list(item.get("answer", {}).get("keys") or [])
    answer_raw = item.get("answer", {}).get("raw")

    boxes = build_boxes(qtype, explanation_en) if parse_status == "complete" else []
    if parse_status == "complete" and not keys and boxes:
        keys = [(b["answer"]["en"] or "").strip() for b in boxes if b["answer"]["en"]]

    qid = resolve_id(item, used_ids, profile)
    certyiq = f"{profile.certyiq_base}/{qid}"

    scenario = None
    if qtype == "yes_no" and parse_status == "complete":
        scenario = extract_yes_no_scenario(item.get("questionText") or "")

    ans_text = None
    if answer_raw and len(str(answer_raw)) < 80:
        ans_text = str(answer_raw)

    seq = int(item.get("seq") or 0)
    deferred_reason = None
    if parse_status == "deferred":
        if qtype in DEFERRED_TYPES:
            deferred_reason = f"type_{qtype}"
        elif has_hotspot_label(item):
            deferred_reason = "hotspot_label"
        else:
            deferred_reason = "hotspot_ui_pending"
    elif parse_status == "pending_review":
        deferred_reason = "unknown_or_incomplete_parse"

    q = {
        "id": qid,
        "exam": item.get("exam") or profile.code,
        "type": qtype,
        "source": {
            "pdfPage": page_by_seq.get(seq),
            "certyiqUrl": certyiq,
        },
        "stem": bilingual(stem_en if parse_status == "complete" else stem_en[:4000]),
        "options": [
            {"key": o["key"], "text": bilingual(o.get("text"))}
            for o in (item.get("options") or [])
        ]
        if parse_status == "complete"
        else [],
        "answer": {
            "keys": keys if parse_status == "complete" else [],
            "text": bilingual(ans_text) if parse_status == "complete" else bilingual(None),
        },
        "explanation": bilingual(explanation_en),
        "references": list(item.get("references") or []),
        "boxes": boxes,
        "media": {
            "required": media_required(qtype, stem_en, item.get("questionText") or ""),
            "images": [],
            "pageRender": None,
        },
        "tags": [],
        "meta": {
            "note": (item.get("note") or "").strip() or None,
            "scenario": bilingual(scenario) if scenario else None,
            "translatedAt": None,
            "reviewSeq": seq,
            "parseStatus": parse_status,
            "deferredReason": deferred_reason,
        },
    }

    if parse_status == "complete":
        interaction = build_interaction(q)
        if interaction:
            q["interaction"] = interaction

    old_q = old_by_seq.get(seq)
    if old_q:
        merge_translations(q, old_q)

    return q


def build_placeholder_stub(qid: int, profile: ExamProfile) -> dict:
    return {
        "id": qid,
        "exam": profile.code,
        "type": "unknown",
        "source": {
            "pdfPage": None,
            "certyiqUrl": f"{profile.certyiq_base}/{qid}",
        },
        "stem": {"en": None, "vi": None},
        "options": [],
        "answer": {"keys": [], "text": {"en": None, "vi": None}},
        "explanation": {"en": None, "vi": None},
        "references": [],
        "boxes": [],
        "media": {"required": False, "images": [], "pageRender": None},
        "tags": [],
        "meta": {
            "note": None,
            "scenario": None,
            "translatedAt": None,
            "reviewSeq": None,
            "parseStatus": "missing",
            "deferredReason": "not_extracted_from_pdf",
        },
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Apply questions-review to questions.json")
    parser.add_argument(
        "--exam",
        default="az-104",
        choices=sorted(__import__("exam_config").EXAMS.keys()),
    )
    parser.add_argument(
        "--max-id",
        type=int,
        default=0,
        help="Only import review items with pdfId <= this value (0 = all)",
    )
    parser.add_argument(
        "--min-id",
        type=int,
        default=1,
        help="Only import review items with pdfId >= this value (default: 1)",
    )
    args = parser.parse_args()
    profile = get_profile(args.exam)

    review_path = profile.questions_review_json
    if not review_path.exists():
        print(f"Missing {review_path}", file=sys.stderr)
        return 1

    review = json.loads(review_path.read_text(encoding="utf-8"))
    out_json = profile.questions_json
    page_by_seq = load_pdf_pages_by_seq(out_json)
    old_by_seq = load_old_by_review_seq(out_json)

    existing_by_id: dict[int, dict] = {}
    if out_json.exists() and args.min_id > 1:
        try:
            for q in json.loads(out_json.read_text(encoding="utf-8")):
                qid = int(q["id"])
                if qid < args.min_id:
                    existing_by_id[qid] = q
        except Exception:
            existing_by_id = {}

    used_ids: set[int] = set(existing_by_id.keys())
    questions: list[dict] = []

    for item in review:
        pid = item.get("pdfId")
        if pid is not None:
            pid_int = int(pid)
            if args.min_id and pid_int < args.min_id:
                continue
            if args.max_id and pid_int > args.max_id:
                continue
        if is_playable(item):
            status = "complete"
        elif is_deferred(item):
            status = "deferred"
        else:
            status = "pending_review"
        questions.append(
            review_to_question(
                item, profile, page_by_seq, used_ids, old_by_seq, parse_status=status
            )
        )

    # Ensure every PDF question number in batch range exists
    by_id = {**existing_by_id, **{q["id"]: q for q in questions}}
    min_qid = args.min_id if args.min_id > 1 else 1
    max_qid = args.max_id if args.max_id else profile.expected_questions
    for qid in range(min_qid, max_qid + 1):
        if qid not in by_id:
            stub = build_placeholder_stub(qid, profile)
            used_ids.add(qid)
            by_id[qid] = stub

    questions = [by_id[k] for k in sorted(by_id)]

    type_counts: dict[str, int] = {}
    status_counts: dict[str, int] = {}
    deferred_ids: list[int] = []
    for q in questions:
        type_counts[q["type"]] = type_counts.get(q["type"], 0) + 1
        st = (q.get("meta") or {}).get("parseStatus") or "unknown"
        status_counts[st] = status_counts.get(st, 0) + 1
        if st in ("deferred", "pending_review", "missing"):
            deferred_ids.append(q["id"])

    playable = sum(1 for q in questions if (q.get("meta") or {}).get("parseStatus") == "complete")

    profile.data_dir.mkdir(parents=True, exist_ok=True)
    out_json.write_text(
        json.dumps(questions, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    manifest = {
        "exam": profile.code,
        "builtAt": datetime.now(timezone.utc).isoformat(),
        "sourceReview": review_path.name,
        "sourcePdf": profile.pdf_name,
        "totalQuestions": len(questions),
        "playableCount": playable,
        "deferredCount": status_counts.get("deferred", 0),
        "pendingReviewCount": status_counts.get("pending_review", 0)
        + status_counts.get("missing", 0),
        "parseStatusCounts": status_counts,
        "typeCounts": type_counts,
        "unknownCount": type_counts.get("unknown", 0),
        "deferredIds": deferred_ids[:120],
        "idMatchesPdfQuestionNumber": True,
    }
    if profile.preface_seq_max:
        manifest["prefaceIdsFrom"] = PREFACE_ID_BASE

    profile.manifest_json.write_text(json.dumps(manifest, indent=2), encoding="utf-8")

    print(f"Wrote {out_json} ({len(questions)} questions)")
    print(f"  Playable: {playable}, deferred: {manifest['deferredCount']}, pending: {manifest['pendingReviewCount']}")
    print(f"  Types: {type_counts}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
