#!/usr/bin/env python3
"""Add interaction metadata, fix boxes/answers, strip blank images."""
from __future__ import annotations

import json
import re
import sys
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 image_utils import filter_image_paths
from extract_pdf import (
    build_question_body_map,
    detect_type,
    extract_stem,
    parse_answer_keys,
    parse_boxes,
    parse_options,
)
from extract_pdf import _body_quality

QUESTIONS_PATH = ROOT / "data" / "questions.json"
PDF_PATH = ROOT / "AZ-104 (634Qs).pdf"

CONTAINER_SERVICE_CHOICES = [
    "Azure Container Instances only",
    "Azure Container Apps only",
    "Azure Container Instances and App Services only",
    "Azure Container Apps and App Services only",
    "Azure Container Instances, Azure Container Apps, and App Services",
]

ROLE_FROM_URL = re.compile(r"built-in-roles#([\w-]+)", re.I)
TAGS_ASSIGNED = re.compile(
    r"(\d+)\s*\)?\s*Tags assigned to (\w+):\s*\"([^\"]+)\"",
    re.I | re.M,
)
COMMAND_TOKEN = re.compile(
    r"\b(az\s+[\w][\w.-]*|az\.[\w][\w.-]*|docker\s+[\w][\w.-]*|kubectl\s+[\w][\w.-]*)\b",
    re.I,
)
LABELED_SLOT = re.compile(
    r"^([A-Z][^:\n]{6,80}?):\s*\n?\s*([a-z][\w.\s-]{2,60}?)\.",
    re.M,
)
STEP_SLOT = re.compile(
    r"(?:^|\n)\s*(?:Step\s*)?(\d+)\s*[:\).-]\s*(.+?)(?=(?:\n\s*(?:Step\s*)?\d+\s*[:\).-])|\Z)",
    re.I | re.S,
)


def parse_tags_assigned_slots(explanation: str) -> list[dict]:
    slots = []
    for m in TAGS_ASSIGNED.finditer(explanation or ""):
        ans = re.sub(r"\s+", " ", m.group(3).strip())
        if len(ans) >= 2 and ans[0] == '"' and ans[-1] == '"':
            ans = ans[1:-1]
        slots.append(
            {
                "id": int(m.group(1)),
                "label": f"Tags assigned to {m.group(2)}",
                "text": ans,
            }
        )
    return slots


def parse_labeled_command_slots(explanation: str) -> list[dict]:
    slots = []
    for m in LABELED_SLOT.finditer(explanation or ""):
        label = re.sub(r"\s+", " ", m.group(1).strip())
        ans = re.sub(r"\s+", " ", m.group(2).strip())
        low = label.lower()
        if any(x in low for x in ("correct", "reference", "scenario", "note")):
            continue
        if ans.lower().startswith("this is"):
            continue
        slots.append({"id": len(slots) + 1, "label": label, "text": ans})
    return slots


def parse_numbered_slots(explanation: str) -> list[dict]:
    slots = []
    for m in re.finditer(
        r"^(\d+)\s*[\).]\s*\n?\s*(.+?)(?=^\d+\s*[\).]|^Tags assigned|Therefore,|Reference:|Box\s*\d|$)",
        explanation or "",
        re.M | re.S,
    ):
        ans = re.sub(r"\s+", " ", m.group(2).strip())
        if len(ans) > 3:
            slots.append({"id": int(m.group(1)), "label": f"Slot {m.group(1)}", "text": ans})
    return slots


def clean_slot_text(text: str) -> str:
    t = re.sub(r"\s+", " ", (text or "")).strip()
    t = t.strip(" -:;,.")
    return t


def parse_step_slots(explanation: str) -> list[dict]:
    """Drag/drop style steps: Step 1: ..., 2) ..., etc."""
    slots = []
    for m in STEP_SLOT.finditer(explanation or ""):
        idx = int(m.group(1))
        ans = clean_slot_text(m.group(2))
        ans = re.split(r"\.\s+[A-Z]", ans, maxsplit=1)[0]
        if len(ans) > 180:
            ans = ans[:180].rsplit(" ", 1)[0] + "..."
        if len(ans) < 4:
            continue
        slots.append({"id": idx, "label": f"Step {idx}", "text": ans})
    slots.sort(key=lambda x: x["id"])
    return slots


def parse_box_yesno_slots(explanation: str) -> list[dict]:
    slots = []
    for m in re.finditer(r"Box\s*(\d+)\s*[-:]\s*(YES|NO)\b", explanation or "", re.I):
        ans = "Yes" if m.group(2).upper() == "YES" else "No"
        slots.append({"id": int(m.group(1)), "label": f"Box {m.group(1)}", "text": ans})
    return slots


def parse_numbered_yesno_slots(explanation: str) -> list[dict]:
    slots = []
    for m in re.finditer(r"^(\d+)\.\s*(Yes|No)\b", explanation or "", re.M | re.I):
        slots.append(
            {
                "id": int(m.group(1)),
                "label": f"Statement {m.group(1)}",
                "text": m.group(2).capitalize(),
            }
        )
    return slots


def parse_labeled_kv_slots(explanation: str) -> list[dict]:
    """Pricing Plan: Standard, Record Type: TXT, Image1: ..."""
    slots = []
    seen: set[str] = set()
    for m in re.finditer(
        r"^([A-Za-z][A-Za-z0-9 /()-]{2,45}?):\s*([^\n.]{1,80})",
        explanation or "",
        re.M,
    ):
        label = clean_slot_text(m.group(1))
        val = clean_slot_text(m.group(2))
        if not label or not val or label.lower() in seen:
            continue
        if any(x in label.lower() for x in ("http", "reference", "incorrect")):
            continue
        if len(val) > 80:
            continue
        seen.add(label.lower())
        slots.append({"id": len(slots) + 1, "label": label, "text": val})
    return slots


def parse_container_image_slots(explanation: str) -> list[dict]:
    exp = (explanation or "").lower()
    has_aci = "azure container instances only" in exp
    has_aca = "azure container apps only" in exp
    if not has_aci and not has_aca:
        return []
    # Expected UI has 2 zones (Image1/Image2); keep both even when OCR truncates.
    return [
        {"id": 1, "label": "Image1", "text": "Azure Container Instances only"},
        {"id": 2, "label": "Image2", "text": "Azure Container Apps only"},
    ]


def parse_assertion_slots(explanation: str) -> list[dict]:
    """
    Hotspot/dropdown style assertions:
    - Statement: Yes/No
    - Statement = Yes/No
    - Label: 3
    """
    slots = []
    seen_labels: set[str] = set()
    for m in re.finditer(
        r"([A-Z][^:\n=]{6,150}?)\s*(?::|=)\s*(?:\n\s*)?([^\n]{1,80})",
        explanation or "",
        re.M,
    ):
        label = clean_slot_text(m.group(1))
        value_raw = clean_slot_text(m.group(2))
        value = re.split(r"\s{2,}|(?<=\.)\s+[A-Z]", value_raw, maxsplit=1)[0].strip()
        if not label or len(label) < 6:
            continue
        if label.lower() in seen_labels:
            continue
        if any(x in label.lower() for x in ("incorrect answer", "reference", "therefore")):
            continue
        if len(value) < 1 or len(value) > 60:
            continue
        # Keep concise hotspot/dropdown outputs, not full explanatory text.
        if re.search(r"\b(is|are|was|were|because|therefore)\b", value, re.I) and len(value) > 20:
            continue
        seen_labels.add(label.lower())
        slots.append({"id": len(slots) + 1, "label": label, "text": value})
    return slots


def parse_boxes(explanation: str) -> list[dict]:
    boxes = []
    for m in re.finditer(
        r"Box\s*(\d+)\s*:\s*(.+?)(?=Box\s*\d+\s*:|Reference:|$)",
        explanation or "",
        re.I | re.S,
    ):
        ans = re.sub(r"\s+", " ", m.group(2).strip()).rstrip(" -")
        boxes.append({"id": int(m.group(1)), "label": f"Box {m.group(1)}", "text": ans})
    return boxes


def extract_commands(explanation: str) -> list[str]:
    seen: set[str] = set()
    cmds = []
    for m in COMMAND_TOKEN.finditer(explanation or ""):
        c = re.sub(r"\s+", " ", m.group(1).strip())
        if c.lower() not in seen:
            seen.add(c.lower())
            cmds.append(c)
    return cmds


def normalize_exp(explanation: str) -> str:
    return re.sub(r"[\u200b\ufeff\u00ad]", "", explanation or "")


def parse_storage_auth_slots(explanation: str) -> list[dict]:
    """AzCopy / storage hotspot: Blob Storage + File Storage selections."""
    slots = []
    patterns = [
        (r"Blob Storage Selection:.*?\"([^\"]+)\"", "Blob Storage"),
        (r"File Storage Selection:.*?\"([^\"]+)\"", "File Storage"),
    ]
    for pat, label in patterns:
        m = re.search(pat, explanation or "", re.I | re.S)
        if m:
            slots.append({"id": len(slots) + 1, "label": label, "text": m.group(1).strip()})
    return slots


def storage_auth_choices() -> list[str]:
    return [
        "Azure AD and shared access signatures (SAS)",
        "Shared access signatures (SAS) only",
        "Azure AD only",
        "Storage account key only",
    ]


def infer_slots_from_explanation(explanation: str, stem: str = "") -> list[dict]:
    explanation = normalize_exp(explanation)
    stem_l = (stem or "").lower()
    if "container images" in stem_l:
        slots = parse_container_image_slots(explanation)
        if slots:
            return slots
    for parser in (
        parse_storage_auth_slots,
        parse_container_image_slots,
        parse_box_yesno_slots,
        parse_numbered_yesno_slots,
        parse_labeled_kv_slots,
        parse_assertion_slots,
        parse_tags_assigned_slots,
        parse_labeled_command_slots,
        parse_step_slots,
        parse_numbered_slots,
    ):
        slots = parser(explanation)
        if len(slots) >= 1:
            return slots
    return []


def apply_parsed_boxes(q: dict, slots: list[dict]) -> None:
    if not slots:
        return
    q["boxes"] = [
        {
            "id": s["id"],
            "label": s["label"],
            "answer": {"en": s["text"], "vi": None},
        }
        for s in slots
    ]
    q.setdefault("answer", {"keys": [], "text": {"en": None, "vi": None}})
    q["answer"]["keys"] = [s["text"] for s in slots]


def collect_choice_pool(slots: list[dict], explanation: str) -> list[str]:
    pool: list[str] = []
    seen: set[str] = set()
    for s in slots:
        t = s["text"]
        if t and t.lower() not in seen:
            seen.add(t.lower())
            pool.append(t)
    for c in extract_commands(explanation):
        if c.lower() not in seen:
            seen.add(c.lower())
            pool.append(c)
    for m in re.finditer(r"'(\w+)':\s*'([^']+)'", explanation or ""):
        phrase = f"'{m.group(1)}': '{m.group(2)}'"
        if phrase.lower() not in seen:
            seen.add(phrase.lower())
            pool.append(phrase)
    return pool


def choices_from_explanation(explanation: str, slots: list[dict]) -> list[str]:
    choices: list[str] = []
    seen: set[str] = set()
    for t in collect_choice_pool(slots, explanation):
        if t.lower() not in seen:
            seen.add(t.lower())
            choices.append(t)
    for m in ROLE_FROM_URL.finditer(explanation or ""):
        name = m.group(1).replace("-", " ").title()
        if name.lower() not in seen:
            seen.add(name.lower())
            choices.append(name)
    for box in parse_boxes(explanation):
        if box["text"] and box["text"].lower() not in seen:
            seen.add(box["text"].lower())
            choices.append(box["text"])
    return choices


def dropdown_choice_pool(q: dict, boxes: list[dict], exp: str, options: list) -> list[str]:
    stem = ((q.get("stem") or {}).get("en") or "").lower()
    slot_texts = [
        {"id": b["id"], "label": b.get("label"), "text": (b.get("answer") or {}).get("en") or ""}
        for b in boxes
    ]
    answers = [(b.get("answer") or {}).get("en") or "" for b in boxes]
    if "container images" in stem:
        return list(CONTAINER_SERVICE_CHOICES)
    if answers and all(a.strip().lower() in ("yes", "no") for a in answers if a):
        return ["Yes", "No"]
    choices = choices_from_explanation(exp, slot_texts)
    if "blob storage selection" in (exp or "").lower():
        for c in storage_auth_choices():
            if c.lower() not in {x.lower() for x in choices}:
                choices.append(c)
    for o in options:
        t = (o.get("text") or {}).get("en") or ""
        if t and t.lower() not in {c.lower() for c in choices}:
            choices.append(t)
    return choices


def build_dropdown_interaction(q: dict, boxes: list[dict], exp: str, options: list) -> dict | None:
    choices = dropdown_choice_pool(q, boxes, exp, options)
    zones = []
    for b in boxes:
        ans = (b.get("answer") or {}).get("en") or ""
        zone_choices = list(choices)
        if ans and ans.lower() not in {c.lower() for c in zone_choices}:
            zone_choices.append(ans)
        if len(zone_choices) < 2:
            continue
        zones.append(
            {
                "id": b.get("id"),
                "label": b.get("label", f"Box {b.get('id')}"),
                "choices": sorted(set(zone_choices), key=len)[:20],
            }
        )
    if zones:
        return {"kind": "dropdown", "zones": zones}
    return None


def build_interaction(q: dict) -> dict | None:
    qtype = q.get("type") or "unknown"
    exp = normalize_exp((q.get("explanation") or {}).get("en") or "")
    stem = (q.get("stem") or {}).get("en") or ""
    boxes = q.get("boxes") or []
    options = q.get("options") or []
    if "container images" in stem.lower():
        wanted = parse_container_image_slots(exp)
        if len(wanted) >= 2:
            by_label = {(b.get("label") or "").lower(): b for b in boxes}
            merged = []
            for w in wanted:
                existing = by_label.get(w["label"].lower())
                if existing:
                    merged.append(existing)
                else:
                    merged.append(
                        {
                            "id": w["id"],
                            "label": w["label"],
                            "answer": {"en": w["text"], "vi": None},
                        }
                    )
            q["boxes"] = merged
            q.setdefault("answer", {"keys": [], "text": {"en": None, "vi": None}})
            q["answer"]["keys"] = [w["text"] for w in wanted]
            q["type"] = "dropdown"
            boxes = q.get("boxes") or []

    needs_slots = qtype in ("unknown", "hotspot", "dropdown", "drag_drop") or (
        "answer area" in stem.lower() or "for each requirement" in stem.lower()
    )

    stub_boxes = (
        len(boxes) == 1
        and ((boxes[0].get("answer") or {}).get("en") or "").rstrip().endswith(":")
        and len(exp) > 80
    )
    if needs_slots and (not boxes or stub_boxes):
        inferred = infer_slots_from_explanation(exp, stem)
        if inferred:
            apply_parsed_boxes(q, inferred)
            boxes = q.get("boxes") or []
            if qtype == "unknown":
                q["type"] = "dropdown"
            elif qtype == "hotspot" and all(
                (s["text"] or "").lower() in ("yes", "no") for s in inferred
            ):
                pass
            elif "container images" in stem.lower():
                q["type"] = "dropdown"

    if qtype == "drag_drop":
        if not boxes:
            inferred_steps = parse_step_slots(exp)
            if inferred_steps:
                apply_parsed_boxes(q, inferred_steps)
                boxes = q.get("boxes") or []
        pool = []
        seen: set[str] = set()
        for b in boxes:
            t = re.sub(r"\s+", " ", ((b.get("answer") or {}).get("en") or "")).strip().rstrip(":")
            if t and t.lower() not in seen:
                seen.add(t.lower())
                pool.append(t)
        if not pool:
            for s in parse_numbered_slots(exp):
                t = re.sub(r"\s+", " ", s["text"]).strip().rstrip(":")
                if t and t.lower() not in seen:
                    seen.add(t.lower())
                    pool.append(t)
        zones = [
            {"id": b.get("id", i + 1), "label": b.get("label", f"Slot {i + 1}")}
            for i, b in enumerate(boxes)
        ]
        if pool and zones:
            return {"kind": "drag_drop", "pool": pool, "zones": zones}

    if qtype in ("dropdown", "hotspot", "unknown") and boxes:
        return build_dropdown_interaction(q, boxes, exp, options)

    return None


def clear_media_when_interactive(q: dict) -> bool:
    ix = q.get("interaction") or {}
    if ix.get("kind") == "drag_drop" and ix.get("pool") and ix.get("zones"):
        media = q.setdefault("media", {"required": False, "images": [], "pageRender": None})
        media["pageRender"] = None
        media["images"] = []
        media["required"] = False
        return True
    if ix.get("kind") == "dropdown":
        zones = ix.get("zones") or []
        if zones and all(len(z.get("choices") or []) >= 2 for z in zones):
            media = q.setdefault("media", {"required": False, "images": [], "pageRender": None})
            media["pageRender"] = None
            media["images"] = []
            media["required"] = False
            return True
    return False


def detect_type_from_stem(stem: str) -> str | None:
    bl = (stem or "").lower()
    if "use the drop-down menus" in bl or "select the appropriate options in the answer area" in bl:
        return "dropdown"
    if "select the options in the answer area" in bl and "container images" in bl:
        return "dropdown"
    if re.search(r"hotspot\s*-", stem, re.I):
        return "hotspot"
    if "for each of the following statements, select yes" in bl:
        return "hotspot"
    return None


def clear_incompatible_choice_options(q: dict) -> bool:
    """Remove stale A/B Yes-No options accidentally attached to non-choice types."""
    qtype = q.get("type") or ""
    if qtype not in ("dropdown", "hotspot", "drag_drop"):
        return False
    opts = q.get("options") or []
    if len(opts) != 2:
        return False
    texts = [((o.get("text") or {}).get("en") or "").strip().lower() for o in opts]
    if sorted(texts) != ["no", "yes"]:
        return False
    q["options"] = []
    ans = q.setdefault("answer", {"keys": [], "text": {"en": None, "vi": None}})
    keys = ans.get("keys") or []
    if keys and all(str(k).strip().upper() in ("A", "B", "YES", "NO") for k in keys):
        ans["keys"] = []
    return True


def append_missing_box_explanation_from_next_page(doc, q: dict) -> bool:
    """
    Some hotspot/dropdown items have Box 1 on page N and Box 2 on page N+1.
    Pull Box 2+ lines so interaction can be built fully.
    """
    qtype = q.get("type") or ""
    if qtype not in ("dropdown", "hotspot"):
        return False
    page_num = (q.get("source") or {}).get("pdfPage")
    if not page_num or page_num >= doc.page_count:
        return False

    exp = ((q.get("explanation") or {}).get("en") or "").strip()
    if not re.search(r"Box\s*1\s*:", exp, re.I) or re.search(r"Box\s*2\s*:", exp, re.I):
        return False

    nxt = doc[page_num].get_text()
    m = re.search(r"(Box\s*2\s*:.+?)(?=Reference:|Question:\s*\d+|You have |\Z)", nxt, re.I | re.S)
    if not m:
        return False
    extra = m.group(1).strip()
    if not extra:
        return False
    q.setdefault("explanation", {})["en"] = f"{exp}\n{extra}".strip()
    if isinstance(q.get("explanation"), dict):
        q["explanation"]["vi"] = None
    return True


def extract_box_entries(text: str) -> list[tuple[int, str]]:
    entries = []
    for m in re.finditer(
        r"Box\s*(\d+)\s*:\s*(.+?)(?=Box\s*\d+\s*:|Reference:|Question:\s*\d+|You have |\Z)",
        text or "",
        re.I | re.S,
    ):
        idx = int(m.group(1))
        ans = re.sub(r"\s+", " ", m.group(2)).strip().rstrip(" -")
        if ans:
            entries.append((idx, ans))
    return entries


def rebuild_explanation_from_page_boxes(doc, q: dict) -> bool:
    qtype = q.get("type") or ""
    if qtype not in ("dropdown", "hotspot"):
        return False
    page_num = (q.get("source") or {}).get("pdfPage")
    qid = q.get("id")
    if not page_num or not qid:
        return False

    page = doc[page_num - 1].get_text()
    qmark = re.search(rf"Question:\s*{qid}\b", page, re.I)
    main = page[: qmark.start()] if qmark else page
    entries = extract_box_entries(main)
    if page_num < doc.page_count:
        nxt = doc[page_num].get_text()
        entries.extend(extract_box_entries(nxt))
    if not entries:
        return False

    dedup: dict[int, str] = {}
    for idx, text in entries:
        dedup[idx] = text
    ordered = [f"Box {i}: {dedup[i]}" for i in sorted(dedup)]
    if len(ordered) < 2:
        return False

    q.setdefault("explanation", {})["en"] = "\n".join(ordered)
    if isinstance(q.get("explanation"), dict):
        q["explanation"]["vi"] = None
    return True


def normalize_hot_area_stem_and_explanation(q: dict) -> bool:
    """Split noisy 'Hot Area: Answer: Explanation: Box 1 ...' out of stem into explanation."""
    stem_obj = q.get("stem") or {}
    exp_obj = q.get("explanation") or {}
    if not isinstance(stem_obj, dict) or not isinstance(exp_obj, dict):
        return False
    stem_en = stem_obj.get("en") or ""
    if "hot area:" not in stem_en.lower():
        return False

    changed = False
    m = re.search(r"^(.*?)(?:\s*Hot Area:\s*Answer:\s*Explanation:\s*)(Box\s*1\s*:.+)$", stem_en, re.I | re.S)
    if m:
        clean_stem = re.sub(r"\s+", " ", m.group(1)).strip()
        box_part = re.sub(r"\s+", " ", m.group(2)).strip()
        if clean_stem and clean_stem != stem_en.strip():
            stem_obj["en"] = clean_stem
            stem_obj["vi"] = None
            changed = True
        exp_en = exp_obj.get("en") or ""
        if "box 1:" not in exp_en.lower():
            exp_obj["en"] = box_part
            exp_obj["vi"] = None
            changed = True
    return changed


def is_question_misaligned(q: dict) -> bool:
    stem = ((q.get("stem") or {}).get("en") or "").strip()
    exp = ((q.get("explanation") or {}).get("en") or "").strip()
    if not stem:
        return True
    if len(re.findall(r"\bAnswer:\s*", stem, re.I)) >= 2:
        return True

    opts = q.get("options") or []
    keys = (q.get("answer") or {}).get("keys") or []
    if not opts or not keys:
        return False

    opt_map = {o["key"]: ((o.get("text") or {}).get("en") or "") for o in opts}
    chosen = opt_map.get(keys[0], "")
    if not chosen:
        return False

    stem_words = set(re.findall(r"[a-z]{4,}", stem.lower()))
    opt_words = set(re.findall(r"[a-z]{4,}", chosen.lower()))
    exp_words = set(re.findall(r"[a-z]{4,}", exp.lower()))
    overlap = (stem_words & opt_words) | (opt_words & exp_words)
    significant = {w for w in opt_words if len(w) >= 5}
    if significant and len(overlap & significant) == 0:
        return True
    return False


def apply_parsed_fields(q: dict, body: str) -> bool:
    qtype = detect_type(body)
    ans_m = re.search(r"Answer:\s*(.*?)\s*Explanation:", body, re.DOTALL | re.I)
    exp_m = re.search(
        r"Explanation:\s*(.*?)(?=Reference:|Note:|\Z)", body, re.DOTALL | re.I
    )
    answer_raw = ans_m.group(1).strip() if ans_m else ""
    explanation = exp_m.group(1).strip() if exp_m else ""
    options = parse_options(body)
    keys = parse_answer_keys(answer_raw, qtype)
    boxes = parse_boxes(explanation)
    stem = extract_stem(body, qtype)

    if not stem or len(stem) < 20:
        return False
    if qtype in ("single_choice", "multi_select") and not options:
        return False
    if qtype in ("single_choice", "multi_select", "yes_no") and not keys and not boxes:
        return False

    q["type"] = qtype
    q.setdefault("stem", {})["en"] = stem
    q["stem"]["vi"] = None
    q["options"] = options
    for o in q["options"]:
        o.setdefault("text", {})["vi"] = None
    q["answer"] = {
        "keys": keys,
        "text": {"en": answer_raw if len(answer_raw) < 80 else None, "vi": None},
    }
    q.setdefault("explanation", {})["en"] = explanation
    q["explanation"]["vi"] = None
    q["boxes"] = boxes
    return True


def question_quality(q: dict) -> int:
    stem = ((q.get("stem") or {}).get("en") or "").strip()
    score = len(stem)
    if re.search(r"\bAnswer:\s*", stem, re.I):
        score -= 400
    keys = (q.get("answer") or {}).get("keys") or []
    opts = q.get("options") or []
    if keys:
        score += 200
    if opts:
        score += 100
    if is_question_misaligned(q):
        score -= 500
    return score


def repair_mapping_from_pdf(questions: list[dict], full_text: str) -> int:
    bodies = build_question_body_map(full_text)
    fixed = 0
    for q in questions:
        qid = q.get("id")
        if not qid or q.get("type") in ("hotspot", "drag_drop", "dropdown"):
            continue
        body = bodies.get(qid)
        if not body:
            continue
        new_score = _body_quality(body)
        if new_score <= question_quality(q) and not is_question_misaligned(q):
            continue
        if apply_parsed_fields(q, body):
            fixed += 1
    return fixed


def repair_stem_explanation_from_pdf(doc, q: dict) -> bool:
    """Re-align stem/explanation when PDF page text diverges from extracted chunk."""
    if q.get("type") in ("single_choice", "multi_select"):
        return False
    qid = q.get("id")
    page_num = (q.get("source") or {}).get("pdfPage")
    if not qid or not page_num or not doc:
        return False

    page_text = doc[page_num - 1].get_text()
    qmark = re.search(rf"Question:\s*{qid}\b", page_text, re.I)
    if not qmark:
        return False

    before = page_text[: qmark.start()]
    body = None
    for pat in (
        r"HOTSPOT\s*-?\s*\n(.+)",
        r"DRAG\s*DROP\s*-\s*\n(.+)",
    ):
        m = re.search(pat, before, re.I | re.S)
        if m:
            body = re.sub(r"\s+", " ", m.group(1)).strip()
            break
    if not body:
        openers = list(
            re.finditer(
                r"(?:^|\n)((?:You have|You need|You plan|For each of the following).+)",
                before,
                re.I | re.S,
            )
        )
        if openers:
            body = re.sub(r"\s+", " ", openers[-1].group(1)).strip()

    if not body or len(body) < 40:
        return False

    exp = ""
    if page_num < doc.page_count:
        nxt = doc[page_num].get_text()
        em = re.search(
            r"(?:Answer:\s*)?Explanation:\s*(.+?)(?=(?:You have |You need |HOTSPOT|DRAG\s*DROP|Question:))",
            nxt,
            re.I | re.S,
        )
        if em:
            exp = em.group(1).strip()

    changed = False
    if normalize_hot_area_stem_and_explanation(q):
        changed = True
    if rebuild_explanation_from_page_boxes(doc, q):
        changed = True
    cur_stem = ((q.get("stem") or {}).get("en") or "").strip()
    if body[:50] not in cur_stem and cur_stem[:50] not in body:
        q.setdefault("stem", {})["en"] = body
        if isinstance(q.get("stem"), dict):
            q["stem"]["vi"] = None
        changed = True
        new_type = detect_type_from_stem(body)
        if new_type:
            q["type"] = new_type

    cur_exp = ((q.get("explanation") or {}).get("en") or "").strip()
    is_box_item = (q.get("type") in ("dropdown", "hotspot")) and (
        "box 1:" in cur_exp.lower() or "box 1:" in cur_stem.lower()
    )
    if exp and exp[:40] not in cur_exp and not is_box_item:
        wrong_topics = (
            "compute gallery" in cur_stem.lower()
            and "container instances only" in exp.lower()
        )
        if wrong_topics or not cur_exp or cur_exp[:40] not in exp:
            q.setdefault("explanation", {})["en"] = exp
            if isinstance(q.get("explanation"), dict):
                q["explanation"]["vi"] = None
            changed = True

    if append_missing_box_explanation_from_next_page(doc, q):
        changed = True

    return changed


def sanitize_media(q: dict) -> None:
    if clear_media_when_interactive(q):
        return

    media = q.get("media") or {}
    qid = q.get("id")
    qdir = ROOT / "assets" / "questions" / str(qid) if qid else None
    page_rel = f"assets/questions/{qid}/page.png" if qid else None
    page_path = (ROOT / page_rel) if page_rel else None

    images = filter_image_paths(media.get("images") or [], ROOT)
    if images and qdir and qdir.is_dir():
        images.sort(
            key=lambda p: (ROOT / p).stat().st_size if (ROOT / p).exists() else 0,
            reverse=True,
        )
        sizes = [(ROOT / p).stat().st_size for p in images if (ROOT / p).exists()]
        if sizes and all(sz < 5000 for sz in sizes) and page_path and page_path.exists():
            images = []

    media["images"] = images
    needs = q.get("type") in ("hotspot", "dropdown") or media.get("required")
    if page_path and page_path.exists() and needs and not images:
        media["pageRender"] = page_rel
        media["required"] = True
    else:
        media["pageRender"] = None
        if not images:
            media["required"] = False
    q["media"] = media


YES_NO_MARKERS = (
    "does this meet the goal",
    "does the solution meet the goal",
    "does that meet the goal",
)


def is_yes_no_stem(stem: str) -> bool:
    low = (stem or "").lower()
    return any(m in low for m in YES_NO_MARKERS)


def fix_yes_no_options(q: dict) -> bool:
    """PDF often keeps only A. Yes — ensure both Yes and No choices exist."""
    stem = (q.get("stem") or {}).get("en") or ""
    if not is_yes_no_stem(stem):
        return False

    opts = q.get("options") or []
    keys = [str(k).strip().upper() for k in (q.get("answer") or {}).get("keys") or []]
    correct = keys[0] if keys else "B"
    if correct in ("YES", "A"):
        correct = "A"
    elif correct in ("NO", "B"):
        correct = "B"
    elif correct not in ("A", "B"):
        correct = "B"

    existing = {}
    for o in opts:
        t = ((o.get("text") or {}).get("en") or "").strip().lower()
        if t in ("yes", "no"):
            existing[t] = o

    q["type"] = "yes_no"
    q["options"] = [
        {
            "key": "A",
            "text": {
                "en": "Yes",
                "vi": (existing.get("yes", {}).get("text") or {}).get("vi") or "Có",
            },
        },
        {
            "key": "B",
            "text": {
                "en": "No",
                "vi": (existing.get("no", {}).get("text") or {}).get("vi") or "Không",
            },
        },
    ]
    q.setdefault("answer", {"keys": [], "text": {"en": None, "vi": None}})
    q["answer"]["keys"] = [correct]
    return len(opts) != 2


def fix_table_media(q: dict) -> bool:
    stem = ((q.get("stem") or {}).get("en") or "").lower()
    if not any(
        x in stem
        for x in ("following table", "following exhibit", "shown in the following", "following graphic")
    ):
        return False
    qid = q.get("id")
    if not qid:
        return False
    page = ROOT / "assets" / "questions" / str(qid) / "page.png"
    if not page.exists():
        return False
    media = q.setdefault("media", {"required": False, "images": [], "pageRender": None})
    if media.get("pageRender") or media.get("images"):
        return False
    media["required"] = True
    media["pageRender"] = f"assets/questions/{qid}/page.png"
    return True


def repair_azcopy_hotspots(questions: list[dict]) -> int:
    """Copy full explanation from the best AzCopy auth hotspot when PDF parse left stubs."""
    template = next(
        (
            q
            for q in questions
            if "blob storage selection" in ((q.get("explanation") or {}).get("en") or "").lower()
        ),
        None,
    )
    if not template:
        return 0
    marker = "which authentication method should you use"
    fixed = 0
    for q in questions:
        stem = ((q.get("stem") or {}).get("en") or "").lower()
        if marker not in stem or q["id"] == template["id"]:
            continue
        exp = (q.get("explanation") or {}).get("en") or ""
        if len(exp) > 120 and "selection:" in exp.lower():
            continue
        q["explanation"] = json.loads(json.dumps(template.get("explanation") or {}))
        fixed += 1
    return fixed


def clear_obviously_misaligned_vi(q: dict) -> bool:
    changed = False
    stem = q.get("stem") or {}
    exp = q.get("explanation") or {}
    en_stem = (stem.get("en") or "").lower() if isinstance(stem, dict) else ""
    vi_stem = (stem.get("vi") or "").lower() if isinstance(stem, dict) else ""
    en_exp = (exp.get("en") or "").lower() if isinstance(exp, dict) else ""
    vi_exp = (exp.get("vi") or "").lower() if isinstance(exp, dict) else ""

    if "container images" in en_stem and "thư viện điện toán azure" in vi_stem:
        stem["vi"] = None
        changed = True
    if "azure container instances only" in en_exp and "vm gen 2" in vi_exp:
        exp["vi"] = None
        changed = True
    return changed


def main() -> int:
    questions = json.loads(QUESTIONS_PATH.read_text(encoding="utf-8"))
    repaired = repair_azcopy_hotspots(questions)
    pdf_fixed = 0
    mapping_fixed = 0
    doc = None
    full_text = ""
    if PDF_PATH.exists():
        try:
            import fitz
            from pypdf import PdfReader

            doc = fitz.open(str(PDF_PATH))
            full_text = "\n".join(
                (p.extract_text() or "") for p in PdfReader(str(PDF_PATH)).pages
            )
        except Exception as exc:
            print(f"  PDF repair skipped: {exc}", file=sys.stderr)

    stats = {
        "interaction": 0,
        "boxes": 0,
        "unknown_fixed": 0,
        "yes_no_fixed": 0,
        "table_media": 0,
        "pdf_realigned": 0,
        "images_before": 0,
        "images_after": 0,
    }
    for q in questions:
        clear_incompatible_choice_options(q)
        clear_obviously_misaligned_vi(q)
        if doc and repair_stem_explanation_from_pdf(doc, q):
            stats["pdf_realigned"] += 1
            pdf_fixed += 1
        if fix_yes_no_options(q):
            stats["yes_no_fixed"] += 1
        if fix_table_media(q):
            stats["table_media"] += 1
        stats["images_before"] += len((q.get("media") or {}).get("images") or [])
        sanitize_media(q)
        stats["images_after"] += len((q.get("media") or {}).get("images") or [])
        was_unknown = q.get("type") == "unknown"
        interaction = build_interaction(q)
        if interaction:
            q["interaction"] = interaction
            stats["interaction"] += 1
        elif q.get("interaction"):
            del q["interaction"]
        if q.get("boxes"):
            stats["boxes"] += 1
        if was_unknown and q.get("type") != "unknown":
            stats["unknown_fixed"] += 1
        clear_media_when_interactive(q)

    if full_text:
        mapping_fixed = repair_mapping_from_pdf(questions, full_text)

    if doc:
        doc.close()

    QUESTIONS_PATH.write_text(
        json.dumps(questions, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(
        f"Enriched {len(questions)} questions\n"
        f"  azcopy repaired: {repaired}\n"
        f"  mapping realigned: {mapping_fixed}\n"
        f"  pdf realigned: {stats['pdf_realigned']}\n"
        f"  yes/no options fixed: {stats['yes_no_fixed']}\n"
        f"  table page media: {stats['table_media']}\n"
        f"  interaction: {stats['interaction']}\n"
        f"  with boxes: {stats['boxes']}\n"
        f"  unknown retyped: {stats['unknown_fixed']}\n"
        f"  images: {stats['images_before']} -> {stats['images_after']}"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
