"""AnswerWindow — AI-powered small popup window with correct answer.

Workflow:
  1. Press hotkey (default: Ctrl+F3)
  2. Screenshot taken of the current screen
  3. Vision AI identifies the correct answer (A/B/C/D)
  4. Answer letter displayed in a small popup window (bottom-right corner)

The window floats above ALL windows including CSCA/SEB.
Same AI pipeline as AnswerDisplay (Ctrl+F9) but uses a visible mini-window
instead of a transparent overlay.

Platform support:
  - Windows: PyQt6 + ctypes (HWND_TOPMOST, above CSCA)
  - macOS:   PyQt6 + NSWindowLevel 1100 (above SEB kiosk)
"""

from __future__ import annotations

import logging
import sys
import threading
import time
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..config import Config

from .vision_pipeline import (
    ANSWER_PROMPT,
    analyze_screenshot_verified,
    analyze_two_stage,
    beep_result,
    beep_start,
    capture_screenshot,
    extract_answer,
    ocr_read_screenshot,
)

log = logging.getLogger(__name__)

# Double-press detection threshold (seconds)
_DOUBLE_PRESS_THRESHOLD = 0.5


_EXTRACT_PROMPT = """\
Look at this exam screenshot. Extract ONLY the question(s) and answer options.

Format:
Question: [question text]
A) [option A]
B) [option B]
C) [option C]
D) [option D]

If there are multiple questions, number them:
1. [question]
A) ... B) ... C) ... D) ...
2. [question]
A) ... B) ... C) ... D) ...

If it's a matching question, list all items and options.
If there are no A/B/C/D options, just extract the question text.
Do NOT answer or solve. Just extract what you see.\
"""

_SOLVE_VISION_PROMPT = """\
Look at this exam screenshot and solve the question(s) directly.

MULTIPLE CHOICE (A/B/C/D): Reply with ONLY the correct letter.
MULTIPLE QUESTIONS: Answer ALL on separate lines: 1. B  2. A  3. D
TRUE/FALSE (判断题): Reply ✓ or ✗
FILL-IN-THE-BLANK (填空题): Give the exact answer.
OPEN-ENDED (简答题): Give a clear answer (3-5 sentences).
MATH/PHYSICS: Show the final answer with units.

Be concise and accurate. Answer in the same language as the question.\
"""


def _extract_question(config: Config, img_b64: str) -> str | None:
    """Extract question + answer options from screenshot via vision AI.

    Priority: online providers (fast, 2-5s) → ollama → llamacpp (slow, 40-90s).
    Returns extracted text or None on failure.
    """
    from ..ai.vision_adapter import VisionAdapter

    # Build fallback chain: online first (fast), local last (slow)
    fallback_chain = [config.vision_provider]
    for p in ["proxy", "aliyun", "88api"]:
        if p not in fallback_chain:
            fallback_chain.append(p)
    fallback_chain.append("ollama")

    # Try online/ollama providers first (fast: 2-5 seconds)
    # If all fail, retry once after 3s (NetworkProtector may need time to establish routes)
    for attempt in range(2):
        if attempt == 1:
            log.info("[answer-win] All online providers failed — waiting 3s for NetworkProtector retry")
            time.sleep(3)
        for provider_name in fallback_chain:
            try:
                adapter = VisionAdapter(config)
                original_provider = config.vision_provider
                config._data.setdefault("vision", {})["provider"] = provider_name
                try:
                    raw = ""
                    for chunk in adapter.analyze(
                        img_b64, _EXTRACT_PROMPT,
                        stream=False,
                        temperature=0.0,
                        max_tokens=2048,
                    ):
                        raw += chunk
                    if raw and "[Vision error:" not in raw:
                        log.info("[answer-win] Question extracted via %s (attempt %d): %d chars", provider_name, attempt + 1, len(raw))
                        return raw
                    elif "[Vision error:" in raw:
                        log.warning("[answer-win] %s returned error text, skipping: %s", provider_name, raw[:80])
                finally:
                    config._data.setdefault("vision", {})["provider"] = original_provider
            except Exception as exc:
                log.debug("[answer-win] %s vision extract failed (attempt %d): %s", provider_name, attempt + 1, exc)

    # Last resort: llamacpp (slow but works offline, 40-90s)
    try:
        from ..ai.provider_llamacpp import get_llamacpp_provider

        provider = get_llamacpp_provider()
        if provider and provider.vision_ready:
            result = provider.generate(
                _EXTRACT_PROMPT,
                image_base64=img_b64,
                system_prompt="You are an OCR assistant. Extract questions and answer options from exam screenshots.",
                max_tokens=2048,
                temperature=0.0,
            )
            if result:
                log.info("[answer-win] Question extracted via llamacpp vision: %d chars", len(result))
                return result
    except Exception as exc:
        log.debug("[answer-win] llamacpp vision extract failed: %s", exc)

    return None


def _solve_via_vision(config: Config, img_b64: str) -> str | None:
    """Solve exam question directly from screenshot in one vision call.

    Fallback when extract+solve two-step pipeline fails (e.g. aliyun down).
    """
    from ..ai.vision_adapter import VisionAdapter

    fallback_chain = [config.vision_provider]
    for p in ["proxy", "aliyun", "88api"]:
        if p not in fallback_chain:
            fallback_chain.append(p)
    fallback_chain.append("ollama")

    for provider_name in fallback_chain:
        try:
            adapter = VisionAdapter(config)
            original_provider = config.vision_provider
            config._data.setdefault("vision", {})["provider"] = provider_name
            try:
                raw = ""
                for chunk in adapter.analyze(
                    img_b64, _SOLVE_VISION_PROMPT,
                    stream=False,
                    temperature=0.0,
                    max_tokens=2048,
                ):
                    raw += chunk
                if raw and "[Vision error:" not in raw:
                    log.info("[answer-win] Solved directly via vision/%s: %d chars", provider_name, len(raw))
                    return raw
            finally:
                config._data.setdefault("vision", {})["provider"] = original_provider
        except Exception as exc:
            log.debug("[answer-win] %s vision solve failed: %s", provider_name, exc)

    return None


_SOLVER_SYSTEM = """\
You are an expert exam solver for Chinese university exams (math, physics, Chinese terms).
Follow these rules strictly:

MULTIPLE CHOICE (A/B/C/D):
- Reply with ONLY the correct letter: A, B, C, or D.

MULTIPLE QUESTIONS in one image:
- Answer ALL on separate lines: 1. B  2. A  3. D

MATCHING questions (5 questions matched to 5 answers):
- Reply: 1-C  2-E  3-A  4-B  5-D

TRUE/FALSE (判断题):
- Reply: ✓ or ✗ (with brief reason if needed)

FILL-IN-THE-BLANK (填空题):
- Give the exact answer to fill in.

OPEN-ENDED / SHORT ANSWER (简答题, no A/B/C/D options):
- Give a clear, complete answer (3-5 sentences).
- Include key formulas, definitions, or reasoning steps.
- Write in the same language as the question.

MATH/PHYSICS:
- Show the final answer with units.
- For complex problems, show key steps briefly.

CHINESE TERMS:
- Give the correct answer with brief explanation.

CRITICAL: Detect question type first, then answer accordingly.\
"""


import math
import re

# ------------------------------------------------------------------ #
# Math compute engine — solves expressions directly via Python
# ------------------------------------------------------------------ #

def _normalize_math_expr(expr: str) -> str:
    """Convert OCR math symbols to Python-evaluable expression."""
    s = expr.strip()
    # Unicode superscripts → **N
    s = s.replace("⁰", "**0").replace("¹", "**1").replace("²", "**2")
    s = s.replace("³", "**3").replace("⁴", "**4").replace("⁵", "**5")
    s = s.replace("⁶", "**6").replace("⁷", "**7").replace("⁸", "**8").replace("⁹", "**9")
    # Roots: √(x) or √x → math.sqrt(x)
    s = re.sub(r"√\(([^)]+)\)", r"math.sqrt(\1)", s)
    s = re.sub(r"√(\d+\.?\d*)", r"math.sqrt(\1)", s)
    s = re.sub(r"∛\(([^)]+)\)", r"(\1)**(1/3)", s)
    s = re.sub(r"∛(\d+\.?\d*)", r"(\1)**(1/3)", s)
    # Arithmetic symbols
    s = s.replace("×", "*").replace("÷", "/").replace("−", "-")
    # Fractions
    s = s.replace("½", "(1/2)").replace("⅓", "(1/3)").replace("¼", "(1/4)")
    s = s.replace("⅕", "(1/5)").replace("¾", "(3/4)").replace("⅔", "(2/3)")
    # Constants
    s = s.replace("π", "math.pi")
    # Trig functions (sin30°, cos60°, tan45°)
    s = re.sub(r"sin\s*(\d+\.?\d*)\s*°", r"math.sin(math.radians(\1))", s)
    s = re.sub(r"cos\s*(\d+\.?\d*)\s*°", r"math.cos(math.radians(\1))", s)
    s = re.sub(r"tan\s*(\d+\.?\d*)\s*°", r"math.tan(math.radians(\1))", s)
    s = re.sub(r"(?<![\w.])sin\s*\(([^)]+)\)", r"math.sin(\1)", s)
    s = re.sub(r"(?<![\w.])cos\s*\(([^)]+)\)", r"math.cos(\1)", s)
    s = re.sub(r"(?<![\w.])tan\s*\(([^)]+)\)", r"math.tan(\1)", s)
    # |x| → abs(x)
    s = re.sub(r"\|([^|]+)\|", r"abs(\1)", s)
    # Implicit multiplication: 2(3) → 2*(3), 3π → 3*math.pi
    # But NOT after function calls like math.sqrt(, math.log10(
    s = re.sub(r"(?<!\w)(\d+)\(", r"\1*(", s)
    s = re.sub(r"(\d)(math\.)", r"\1*\2", s)
    # log/ln — AFTER implicit multiplication to avoid conflicts
    # Process log FIRST (so ln doesn't get caught by log), use lookbehind
    s = re.sub(r"(?<![\w.])log\s*\*?\(([^)]+)\)", r"math.log10(\1)", s)
    s = re.sub(r"(?<![\w.])ln\s*\*?\(([^)]+)\)", r"math.log(\1)", s)
    # Remove degree symbol if standalone
    s = s.replace("°", "")
    return s


def _safe_eval(expr: str) -> float | None:
    """Safely evaluate a math expression. Returns None if not evaluable."""
    allowed = {
        "math": math, "abs": abs, "round": round,
        "int": int, "float": float,
    }
    try:
        result = eval(expr, {"__builtins__": {}}, allowed)  # noqa: S307
        if isinstance(result, (int, float)) and not (math.isinf(result) or math.isnan(result)):
            return result
    except Exception:
        pass
    return None


def _format_number(val: float) -> str:
    """Format number for display: integers as int, floats rounded."""
    if val == int(val) and abs(val) < 1e15:
        return str(int(val))
    # Round to avoid floating point noise (e.g. 0.30000000000000004)
    rounded = round(val, 6)
    if rounded == int(rounded):
        return str(int(rounded))
    return str(rounded)


def _try_compute_expression(text: str) -> str | None:
    """Try to compute a pure math expression directly via Python.

    Handles: 2+2, √9, 3²×5, sin30°, log(100), |−5|, π×r², etc.
    Returns computed answer string, or None if not a pure math expression.
    """
    # Must look like a math expression (has digits and operators)
    if not re.search(r"\d", text):
        return None
    # Skip if it has A) B) C) D) options — multiple choice, not pure math
    if re.search(r"[A-D]\)", text):
        return None
    # Skip if multiple lines (likely a full question, not a single expression)
    lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
    if len(lines) > 2:
        return None

    # Use last non-empty line or full text
    expr = lines[-1] if lines else text
    # Strip common Chinese/English prefixes
    expr = re.sub(r"^(?:计算|求|求解|Calculate|Compute|Evaluate)\s*[:：]?\s*", "", expr)
    # Strip after ":" or "="
    for sep in (":", "："):
        if sep in expr:
            expr = expr.split(sep, 1)[-1].strip()

    # Remove trailing "=", "= ?", "=?"
    expr = re.sub(r"[\s=?？]+$", "", expr).strip()
    if not expr:
        return None

    normalized = _normalize_math_expr(expr)
    result = _safe_eval(normalized)
    if result is not None:
        formatted = _format_number(result)
        log.info("[answer-win] Computed directly: %s = %s", expr, formatted)
        return formatted
    return None


def _try_compute_mcq(text: str) -> str | None:
    """Try to solve multiple-choice by computing each option.

    If question has "= ?" and options A) B) C) D) with numeric values,
    compute the expression and match to correct option.
    """
    lines = text.strip().splitlines()
    if len(lines) < 3:
        return None

    # Find the expression (line with = or =?)
    expr_line = None
    for line in lines:
        if re.search(r"=\s*[?？]", line) or line.strip().endswith("="):
            expr_line = line
            break
    if not expr_line:
        return None

    # Extract expression before "="
    expr = re.split(r"=\s*[?？]?", expr_line)[0].strip()
    # Remove question number prefix like "1." or "Question:"
    expr = re.sub(r"^(?:\d+[.)]\s*|Question:\s*)", "", expr).strip()

    normalized = _normalize_math_expr(expr)
    target = _safe_eval(normalized)
    if target is None:
        return None

    # Parse options: A) value  B) value  C) value  D) value
    options: dict[str, float | None] = {}
    option_pattern = re.compile(r"([A-F])\)\s*([^A-F)]+)")
    full_text = "\n".join(lines)
    for m in option_pattern.finditer(full_text):
        letter = m.group(1)
        val_str = m.group(2).strip().rstrip(",;")
        opt_norm = _normalize_math_expr(val_str)
        options[letter] = _safe_eval(opt_norm)

    if not options:
        return None

    # Find matching option
    for letter, val in options.items():
        if val is not None and abs(val - target) < 1e-6:
            log.info("[answer-win] MCQ computed: %s = %s → %s", expr, _format_number(target), letter)
            return letter

    return None


# Math symbol → text description mapping (helps small LLMs understand formulas)
_MATH_SYMBOL_MAP = [
    # Roots
    ("√", " square root of "),
    ("∛", " cube root of "),
    # Powers / superscripts
    ("²", " squared"),
    ("³", " cubed"),
    ("⁴", " to the power of 4"),
    ("⁵", " to the power of 5"),
    ("⁰", " to the power of 0"),
    ("¹", " to the power of 1"),
    # Fractions
    ("½", " one half"),
    ("⅓", " one third"),
    ("¼", " one quarter"),
    ("⅕", " one fifth"),
    ("¾", " three quarters"),
    ("⅔", " two thirds"),
    # Arithmetic
    ("×", " multiplied by "),
    ("÷", " divided by "),
    ("±", " plus or minus "),
    ("∓", " minus or plus "),
    # Comparison
    ("≥", " greater than or equal to "),
    ("≤", " less than or equal to "),
    ("≠", " not equal to "),
    ("≈", " approximately equal to "),
    ("≡", " identical to "),
    ("∝", " proportional to "),
    # Greek letters (common in math/physics)
    ("α", " alpha "),
    ("β", " beta "),
    ("γ", " gamma "),
    ("δ", " delta "),
    ("Δ", " Delta "),
    ("ε", " epsilon "),
    ("θ", " theta "),
    ("λ", " lambda "),
    ("μ", " mu "),
    ("π", " pi "),
    ("σ", " sigma "),
    ("Σ", " Sigma (sum) "),
    ("τ", " tau "),
    ("φ", " phi "),
    ("ω", " omega "),
    ("Ω", " Omega "),
    # Calculus / set theory
    ("∫", " integral of "),
    ("∂", " partial derivative of "),
    ("∞", " infinity "),
    ("∈", " belongs to "),
    ("∉", " does not belong to "),
    ("⊂", " subset of "),
    ("⊃", " superset of "),
    ("∪", " union "),
    ("∩", " intersection "),
    ("∅", " empty set "),
    # Arrows
    ("→", " implies "),
    ("←", " from "),
    ("↔", " if and only if "),
    ("⇒", " implies "),
    ("⇔", " if and only if "),
    # Logic
    ("∀", " for all "),
    ("∃", " there exists "),
    ("¬", " not "),
    ("∧", " and "),
    ("∨", " or "),
    # Misc
    ("°", " degrees "),
    ("‰", " per mille "),
    ("∠", " angle "),
    ("⊥", " perpendicular to "),
    ("∥", " parallel to "),
    ("∴", " therefore "),
    ("∵", " because "),
]

# Regex for digit sequences — expand to words for small models
_DIGIT_WORDS = {
    "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
    "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine",
}


def _math_to_text(text: str) -> str:
    """Convert math symbols and expressions to text descriptions.

    Helps small LLMs (Qwen 7B) understand math formulas better.
    Example: '√(4) + 3² × 5' → ' square root of (4) + 3 squared  multiplied by  5'
    """
    result = text
    for symbol, description in _MATH_SYMBOL_MAP:
        result = result.replace(symbol, description)
    # Clean up extra spaces
    result = re.sub(r"  +", " ", result)
    return result


def _solve_question(config: Config, text: str) -> str:
    """Solve exam question(s) extracted by vision OCR.

    Try 0: Direct Python computation (instant, 100% accurate for math)
    Try 1: DeepSeek API (best quality, needs network)
    Try 2: local llamacpp Gemma 12B/Qwen 7B (offline, works under CSCA)
    Fallback: return original extracted text
    """
    if not text or len(text.strip()) < 5:
        return text

    # Try 0: Direct computation — instant and always correct
    computed = _try_compute_mcq(text)
    if computed:
        return computed
    computed = _try_compute_expression(text)
    if computed:
        return computed

    # Convert math symbols to text for better LLM understanding
    text_expanded = _math_to_text(text)
    if text_expanded != text:
        log.info("[answer-win] Math symbols expanded: %d → %d chars", len(text), len(text_expanded))

    prompt = f"Solve the following exam question(s):\n\n{text_expanded}"

    # Try 1: ChinaAdapter with full proxy + failover (DeepSeek → Aliyun → 88API)
    try:
        from ..ai.base import AIRequest
        from ..ai.china_adapter import ChinaAdapter

        req = AIRequest(
            prompt=prompt, system_prompt=_SOLVER_SYSTEM,
            temperature=0.0, max_tokens=1024,
        )
        resp = ChinaAdapter(config).ask(req)
        if resp.ok and resp.text:
            log.info("[answer-win] Solved via %s: %d chars", resp.provider, len(resp.text))
            return resp.text
    except Exception as exc:
        log.debug("[answer-win] AI solver failed: %s", exc)

    # Try 2: local llamacpp (offline, works under CSCA)
    try:
        from ..ai.provider_llamacpp import get_llamacpp_provider

        provider = get_llamacpp_provider()
        if provider and provider.text_ready:
            result = provider.generate(
                prompt, system_prompt=_SOLVER_SYSTEM,
                max_tokens=256, temperature=0.0,
            )
            if result:
                log.info("[answer-win] Solved via llamacpp: %d chars", len(result))
                return result
    except Exception as exc:
        log.debug("[answer-win] llamacpp solver failed: %s", exc)

    return text


class AnswerWindow:
    """Screenshot -> Vision AI -> small popup window with correct answer."""

    def __init__(self, config: Config) -> None:
        self._config = config
        self._running = False
        self._lock = threading.Lock()
        self._widget: AnswerWindowWidget | None = None

        # Double-press detection state
        self._last_press_time: float = 0.0
        self._last_answer: str | None = None
        self._last_answer_time: float = 0.0
        self._last_ocr_text: str | None = None

    def set_widget(self, widget: AnswerWindowWidget) -> None:
        """Set the window widget (must be created in the main/Qt thread)."""
        self._widget = widget

    def show_text(self, text: str, timeout: int = 20) -> None:
        """Show full text in the answer window (thread-safe)."""
        if self._widget:
            self._widget.show_text_signal.emit(text, timeout)

    def hide(self) -> None:
        """Hide the answer window (thread-safe)."""
        if self._widget:
            self._widget.hide_signal.emit()

    # ------------------------------------------------------------------ #
    # Public entry point
    # ------------------------------------------------------------------ #

    def on_hotkey(self) -> None:
        """Called when hotkey is pressed.

        Double-press (within 0.5s): show last cached answer instantly.
        Single press: run full AI pipeline (screenshot -> vision -> answer).
        """
        now = time.monotonic()

        # If window is visible — just hide it
        if self._widget and self._widget.is_visible():
            self._widget.hide_signal.emit()
            log.info("[answer-win] Hiding window")
            self._last_press_time = now
            return

        # Double-press detection: if pressed within threshold AND we have a cached answer
        elapsed_since_last = now - self._last_press_time
        self._last_press_time = now

        if (
            elapsed_since_last <= _DOUBLE_PRESS_THRESHOLD
            and self._last_ocr_text is not None
        ):
            log.info(
                "[answer-win] Double-press detected — showing cached OCR text (%d chars)",
                len(self._last_ocr_text),
            )
            timeout = max(int(self._config.get("answer_window.timeout_sec", 8)), 30)
            if self._widget:
                self._widget.show_text_signal.emit(self._last_ocr_text, timeout)
            return

        # Single press — run OCR text extraction (default behavior)
        with self._lock:
            if self._running:
                log.info("[answer-win] AI request in progress — please wait")
                return
            self._running = True
        log.info("[answer-win] Starting OCR text extraction...")
        threading.Thread(target=self._run_ocr, daemon=True, name="answer-window-ocr").start()

    def on_ocr_hotkey(self) -> None:
        """Called when OCR reader hotkey is pressed (Ctrl+F7).

        Takes screenshot -> extracts all text -> shows in answer window.
        """
        # If window is visible — just hide it
        if self._widget and self._widget.is_visible():
            self._widget.hide_signal.emit()
            log.info("[answer-win] Hiding window (OCR)")
            return

        with self._lock:
            if self._running:
                log.info("[answer-win] AI request in progress — please wait")
                return
            self._running = True
        log.info("[answer-win] Starting OCR reader...")
        threading.Thread(target=self._run_ocr, daemon=True, name="ocr-reader").start()

    def _run_ocr(self) -> None:
        """OCR reader pipeline: screenshot -> vision OCR -> show text."""
        t_start = time.monotonic()
        try:
            # Step 0: wait for NetworkProtector to finish starting
            try:
                from ..utils.network_protect import wait_for_network
                if not wait_for_network(timeout=0):
                    log.info("[answer-win] Network protection still starting, waiting...")
                    if self._widget:
                        self._widget.show_text_signal.emit("⏳", 30)
                    if not wait_for_network(timeout=30):
                        log.warning("[answer-win] Network protection timeout — proceeding anyway")
            except ImportError:
                pass

            # Step 1: show loading indicator
            if self._widget:
                self._widget.loading_signal.emit()

            # Step 2: screenshot
            b64, orig_w, orig_h, res_w, res_h = capture_screenshot(
                self._config, "answer_window"
            )
            if not b64:
                log.error("[answer-win] OCR screenshot failed — aborting")
                if self._widget:
                    self._widget.show_signal.emit("!", 3, 0.0)
                return

            # Step 3: Vision — extract question + options
            text = _extract_question(self._config, b64)
            if not text:
                # Fallback to generic OCR
                text = ocr_read_screenshot(
                    self._config, b64,
                    config_prefix="answer_window",
                    max_tokens=2048,
                )
            if not text:
                # Step 3b: All extraction failed — try solving directly via vision
                log.info("[answer-win] Extract failed — trying direct vision solve...")
                answer = _solve_via_vision(self._config, b64)
                if answer:
                    elapsed_total = time.monotonic() - t_start
                    log.info("[answer-win] Direct vision answer: %d chars (%.1fs total)", len(answer), elapsed_total)
                    timeout = int(self._config.get("answer_window.timeout_sec", 8))
                    if self._widget:
                        self._widget.show_text_signal.emit(answer, timeout)
                    return
                log.error("[answer-win] Failed to extract question")
                if self._widget:
                    self._widget.show_signal.emit("!", 3, 0.0)
                return

            elapsed_ocr = time.monotonic() - t_start
            log.info("[answer-win] Question extracted: %d chars (%.1fs)", len(text), elapsed_ocr)

            # Cache extracted text for double-press recall
            self._last_ocr_text = text

            # Step 4: Solve the extracted question (retry once after 2s if empty)
            log.info("[answer-win] Solving extracted question...")
            answer = _solve_question(self._config, text)
            if not answer or answer == text:
                log.info("[answer-win] Solve returned empty/unchanged — retrying after 2s")
                time.sleep(2)
                answer = _solve_question(self._config, text)
            elapsed_total = time.monotonic() - t_start
            log.info("[answer-win] Answer: %d chars (%.1fs total)", len(answer), elapsed_total)

            # Step 5: show answer — auto-hide after timeout
            timeout = int(self._config.get("answer_window.timeout_sec", 8))
            if self._widget:
                self._widget.show_text_signal.emit(answer, timeout)

        except Exception as exc:
            log.error("[answer-win] OCR error: %s", exc, exc_info=True)
            if self._widget:
                self._widget.show_signal.emit("!", 3, 0.0)
        finally:
            with self._lock:
                self._running = False

    # ------------------------------------------------------------------ #
    # Main pipeline
    # ------------------------------------------------------------------ #

    def _run(self) -> None:
        t_start = time.monotonic()
        try:
            # Step 1: show loading indicator (no beep)
            if self._widget:
                self._widget.loading_signal.emit()

            # Step 2: screenshot
            b64, orig_w, orig_h, res_w, res_h = capture_screenshot(
                self._config, "answer_window"
            )
            if not b64:
                log.error("[answer-win] Screenshot failed — aborting")
                if self._widget:
                    self._widget.show_signal.emit("!", 3, 0.0)
                return

            # Step 3: Two-stage pipeline (PRIMARY — most accurate)
            # Stage 1: vision model reads text from screenshot (OCR)
            # Stage 2: text model (qwen2.5:7b) solves the question
            # This is more accurate than asking vision model to solve directly.
            log.info("[answer-win] Using two-stage pipeline (OCR → text solve)")
            result = analyze_two_stage(
                self._config, b64,
                config_prefix="answer_window",
                max_tokens=512,
            )
            if result is None:
                # Fallback: direct vision answer (less accurate but faster)
                log.info("[answer-win] Two-stage failed — trying direct vision")
                result = analyze_screenshot_verified(
                    self._config,
                    b64,
                    ANSWER_PROMPT,
                    config_prefix="answer_window",
                    max_tokens=256,
                    retries=1,
                    required_key="answer",
                    verify=False,
                )
            if result is None:
                log.error("[answer-win] Both strategies failed — aborting")
                if self._widget:
                    self._widget.show_signal.emit("!", 3, 0.0)
                return

            answer = extract_answer(result)

            if not answer:
                log.info("[answer-win] No question found on screen")
                if self._widget:
                    self._widget.show_signal.emit("!", 3, 0.0)
                return

            # Calculate elapsed time
            elapsed = time.monotonic() - t_start
            log.info("[answer-win] Answer: %s (%.1fs)", answer, elapsed)

            # Cache the answer for double-press recall
            self._last_answer = answer
            self._last_answer_time = time.monotonic()

            # Step 4: show window via signal (thread-safe, no beep)
            timeout = int(self._config.get("answer_window.timeout_sec", 8))
            if self._widget:
                self._widget.show_signal.emit(answer, timeout, elapsed)
            else:
                log.error("[answer-win] No widget — cannot show answer")

        except Exception as exc:
            log.error("[answer-win] Unexpected error: %s", exc, exc_info=True)
            if self._widget:
                self._widget.show_signal.emit("!", 3, 0.0)
        finally:
            with self._lock:
                self._running = False


# ===================================================================== #
# Window widget — MUST be created in main/Qt thread
# ===================================================================== #


from PyQt6.QtWidgets import QWidget as _QWidgetBase


class _DraggableWidget(_QWidgetBase):
    """QWidget subclass with custom painting — no QLabel, no background artifacts."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._drag_pos = None
        self._display_text = ""
        self._text_font = None
        self._text_color = None
        self._text_align = None
        self._text_wrap = False
        self._text_margins = (0, 0, 0, 0)
        self.setAutoFillBackground(False)

    def paintEvent(self, event):
        """Draw text with outline — visible on any background, no rectangle."""
        from PyQt6.QtCore import QRectF, Qt
        from PyQt6.QtGui import QColor, QPainter, QPainterPath, QPen, QBrush

        if not self._display_text:
            return
        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)

        if self._text_font:
            painter.setFont(self._text_font)

        ml, mt, mr, mb = self._text_margins
        rect = QRectF(ml, mt, self.width() - ml - mr, self.height() - mt - mb)

        flags = int(Qt.AlignmentFlag.AlignCenter)
        if self._text_align:
            flags = int(self._text_align)
        if self._text_wrap:
            flags |= int(Qt.TextFlag.TextWordWrap)

        # For HTML content (answer with elapsed time)
        if "<" in self._display_text and ">" in self._display_text:
            from PyQt6.QtGui import QTextDocument
            doc = QTextDocument()
            doc.setDefaultFont(painter.font())
            doc.setHtml(
                f'<div style="color:#ffffff; -webkit-text-stroke: 2px black;">'
                f'{self._display_text}</div>'
            )
            doc.setTextWidth(rect.width())
            # Draw black outline
            painter.save()
            painter.translate(rect.topLeft())
            for dx, dy in [(-1,0),(1,0),(0,-1),(0,1)]:
                painter.save()
                painter.translate(dx, dy)
                doc.setHtml(f'<div style="color:rgba(30,30,30,0.55)">{self._display_text}</div>')
                doc.drawContents(painter)
                painter.restore()
            # Draw gray text on top
            doc.setHtml(f'<div style="color:#666666">{self._display_text}</div>')
            doc.drawContents(painter)
            painter.restore()
        else:
            # Plain text — draw outline + fill for visibility
            # Thin dark outline for readability (1px offset, 4 directions)
            painter.setPen(QPen(QColor(30, 30, 30, 140), 1))
            for dx, dy in [(-1,0),(1,0),(0,-1),(0,1)]:
                offset_rect = rect.translated(dx, dy)
                painter.drawText(offset_rect, flags, self._display_text)
            # Gray text on top
            painter.setPen(QPen(QColor(102, 102, 102)))
            painter.drawText(rect, flags, self._display_text)

        painter.end()

    def mousePressEvent(self, event):
        from PyQt6.QtCore import Qt
        if event.button() == Qt.MouseButton.LeftButton:
            self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
            event.accept()

    def mouseMoveEvent(self, event):
        from PyQt6.QtCore import Qt
        if self._drag_pos is not None and event.buttons() & Qt.MouseButton.LeftButton:
            self.move(event.globalPosition().toPoint() - self._drag_pos)
            event.accept()

    def mouseReleaseEvent(self, event):
        from PyQt6.QtCore import Qt
        if event.button() == Qt.MouseButton.LeftButton:
            self._drag_pos = None
            event.accept()


class AnswerWindowWidget:
    """Small popup window showing the answer letter.

    - Dark rounded mini-window in bottom-right corner
    - White bold letter on dark background
    - On top of ALL windows including CSCA/SEB
    - Thread-safe: show/hide via Qt signals
    """

    def __init__(self) -> None:
        from PyQt6.QtCore import QObject, QTimer, Qt, pyqtSignal
        from PyQt6.QtGui import QColor, QFont

        # Signals for thread-safe communication
        class _Signals(QObject):
            show = pyqtSignal(str, int, float)  # answer_letter, timeout_sec, elapsed_secs
            hide = pyqtSignal()
            loading = pyqtSignal()
            show_text = pyqtSignal(str, int)  # full_text, timeout_sec

        self._signals = _Signals()
        self.show_signal = self._signals.show
        self.hide_signal = self._signals.hide
        self.loading_signal = self._signals.loading
        self.show_text_signal = self._signals.show_text

        # Create widget — fully transparent, no layout, no children, just QPainter
        self._widget = _DraggableWidget()
        self._widget.setWindowFlags(
            Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.WindowStaysOnTopHint
            | Qt.WindowType.Tool
            | Qt.WindowType.WindowDoesNotAcceptFocus
        )
        self._widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
        self._widget.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
        self._widget.setWindowTitle("")
        import random
        _sz = random.randint(116, 128)
        self._initial_sz = _sz
        self._widget.setFixedSize(_sz, _sz)

        # Windows: ensure no DWM shadow/frame
        if sys.platform == "win32":
            try:
                import ctypes
                hwnd = int(self._widget.winId())
                # Disable DWM non-client rendering (removes any shadow/border)
                DWMWA_NCRENDERING_POLICY = 2
                val = ctypes.c_int(1)  # DWMNCRP_DISABLED
                ctypes.windll.dwmapi.DwmSetWindowAttribute(
                    hwnd, DWMWA_NCRENDERING_POLICY, ctypes.byref(val), ctypes.sizeof(val)
                )
                # Exclude from screen capture (CSCA stealth)
                WDA_EXCLUDEFROMCAPTURE = 0x11
                val2 = ctypes.c_int(WDA_EXCLUDEFROMCAPTURE)
                ctypes.windll.user32.SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE)
            except Exception:
                pass

        # Set default font and color for answer display (painted directly in paintEvent)
        self._widget._text_font = QFont("Arial", 56, QFont.Weight.Bold)
        self._widget._text_color = QColor(102, 102, 102)

        # Auto-hide timer
        self._hide_timer = QTimer(self._widget)
        self._hide_timer.setSingleShot(True)
        self._hide_timer.timeout.connect(self._do_hide)

        # Z-order maintenance timer (every 50ms — matches NormalizeAllTopMosts speed)
        self._topmost_timer = QTimer(self._widget)
        self._topmost_timer.setInterval(50)
        self._topmost_timer.timeout.connect(self._assert_topmost)

        # Foreground hook state (Win32 — instant z-order re-assertion)
        self._foreground_hook_installed = False
        self._foreground_callback = None  # prevent GC of ctypes callback

        # Connect signals to slots
        self.show_signal.connect(self._do_show)
        self.hide_signal.connect(self._do_hide)
        self.loading_signal.connect(self._do_loading)
        self.show_text_signal.connect(self._do_show_text)

    def is_visible(self) -> bool:
        """Check if window is currently visible (thread-safe read)."""
        return self._widget.isVisible()

    # Win32 constants
    _HWND_TOPMOST = -1
    _SWP_NOMOVE = 0x0002
    _SWP_NOSIZE = 0x0001
    _SWP_NOACTIVATE = 0x0010
    _SWP_SHOWWINDOW = 0x0040
    _GWL_EXSTYLE = -20
    _WS_EX_TOOLWINDOW = 0x00000080
    _WS_EX_NOACTIVATE = 0x08000000
    _WS_EX_TOPMOST = 0x00000008
    _WS_EX_LAYERED = 0x00080000
    _WDA_EXCLUDEFROMCAPTURE = 0x00000011

    @staticmethod
    def _apply_shadow(widget) -> None:
        """Apply a dark drop-shadow effect for text readability."""
        from PyQt6.QtCore import QPointF
        from PyQt6.QtGui import QColor
        from PyQt6.QtWidgets import QGraphicsDropShadowEffect

        shadow = QGraphicsDropShadowEffect(widget)
        shadow.setColor(QColor(0, 0, 0, 220))
        shadow.setBlurRadius(20)
        shadow.setOffset(QPointF(2, 2))
        widget.setGraphicsEffect(shadow)

    def _do_loading(self) -> None:
        """Loading indicator — don't show window, just log. Window appears only with answer."""
        log.info("[answer-win] AI thinking (no loading window)")

    def _do_show(self, answer: str, timeout_sec: int, elapsed_secs: float) -> None:
        """Show the answer window with answer letter and optional elapsed time."""
        from PyQt6.QtGui import QFont
        # Restore small size (may have been resized by _do_show_text)
        _sz = self._initial_sz
        self._widget.setFixedSize(_sz, _sz)
        self._widget._text_font = QFont("Arial", 56, QFont.Weight.Bold)
        self._widget._text_wrap = False
        self._widget._text_align = None
        self._widget._text_margins = (0, 0, 0, 0)

        # Use HTML to show answer in large font and elapsed time below in small font
        if elapsed_secs > 0:
            html = (
                '<div style="font-size:56px;font-weight:bold">{}</div>'
                '<div style="font-size:14px">{:.1f}s</div>'
            ).format(answer, elapsed_secs)
        else:
            html = (
                '<div style="font-size:56px;font-weight:bold">{}</div>'
            ).format(answer)
        self._widget._display_text = html
        self._widget.update()

        # Position: bottom-right corner
        self._position_bottom_left()

        self._widget.show()
        self._widget.raise_()

        # Assert topmost immediately
        self._assert_topmost()

        # Start timers
        self._topmost_timer.start()
        if timeout_sec > 0:
            self._hide_timer.start(timeout_sec * 1000)

        # Install foreground hook for instant z-order re-assertion
        self._install_foreground_hook()
        self._register_cbt_hook()

        log.info("[answer-win] Window shown: %s (%.1fs)", answer, elapsed_secs)

    def _do_show_text(self, text: str, timeout_sec: int) -> None:
        """Show full text (multi-line) in the answer window."""
        from PyQt6.QtCore import Qt
        from PyQt6.QtGui import QFont
        # Auto-size based on text length
        text_len = len(text)
        if text_len < 100:
            max_w, max_h = 350, 220
        elif text_len <= 300:
            max_w, max_h = 450, 300
        elif text_len <= 600:
            max_w, max_h = 550, 400
        else:
            max_w, max_h = 600, 500
        self._widget.setFixedSize(max_w, max_h)
        self._widget._text_font = QFont("Arial", 13, QFont.Weight.Normal)
        self._widget._text_align = Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft
        self._widget._text_wrap = True
        self._widget._text_margins = (10, 10, 10, 10)
        self._widget._display_text = text
        self._widget.update()
        self._position_bottom_left()
        self._widget.show()
        self._widget.raise_()
        self._assert_topmost()
        self._topmost_timer.start()
        if timeout_sec > 0:
            self._hide_timer.start(timeout_sec * 1000)
        self._install_foreground_hook()
        self._register_cbt_hook()
        log.info("[answer-win] Full text shown (%d chars)", len(text))

    def _do_hide(self) -> None:
        """Hide the window."""
        self._hide_timer.stop()
        self._topmost_timer.stop()
        self._widget.hide()
        log.info("[answer-win] Window hidden")

    def _position_bottom_left(self) -> None:
        """Place widget in bottom-left corner — over taskbar."""
        from PyQt6.QtWidgets import QApplication

        screen = QApplication.primaryScreen()
        if not screen:
            return
        geom = screen.geometry()
        # margin must be >= shadow blur radius (8) + offset (2) to avoid
        # UpdateLayeredWindowIndirect errors from negative dirty-rect coords
        margin = 15
        x = geom.left() + margin
        y = geom.bottom() - self._widget.height() - margin
        # Clamp so the widget (plus shadow padding) never goes off-screen
        x = max(x, geom.left() + 10)
        y = max(y, geom.top() + 10)
        self._widget.move(x, y)

    def _register_cbt_hook(self) -> None:
        """Register this window with the CBT hook for CSCA Z-order protection."""
        if sys.platform != "win32":
            return
        try:
            from ..seb.window_cloak import register_protected_hwnd

            hwnd = int(self._widget.winId())
            register_protected_hwnd(hwnd)
        except Exception:
            pass

    def _install_foreground_hook(self) -> None:
        """Install EVENT_SYSTEM_FOREGROUND hook for instant z-order re-assertion."""
        if self._foreground_hook_installed or sys.platform != "win32":
            return
        try:
            import ctypes
            from ctypes import wintypes

            # WinEventProc callback type
            WINEVENTPROC = ctypes.WINFUNCTYPE(
                None,             # return void
                wintypes.HANDLE,  # hWinEventHook
                wintypes.DWORD,   # event
                wintypes.HWND,    # hwnd
                ctypes.c_long,    # idObject
                ctypes.c_long,    # idChild
                wintypes.DWORD,   # idEventThread
                wintypes.DWORD,   # dwmsEventTime
            )

            widget_ref = self._widget  # prevent GC

            def _on_foreground(
                hWinEventHook, event, hwnd, idObject, idChild,
                idEventThread, dwmsEventTime,
            ):
                if widget_ref.isVisible():
                    try:
                        my_hwnd = int(widget_ref.winId())
                        ctypes.windll.user32.SetWindowPos(
                            my_hwnd, -1, 0, 0, 0, 0,
                            0x0002 | 0x0001 | 0x0010,  # NOMOVE|NOSIZE|NOACTIVATE
                        )
                    except Exception:
                        pass

            self._foreground_callback = WINEVENTPROC(_on_foreground)

            EVENT_SYSTEM_FOREGROUND = 0x0003
            WINEVENT_OUTOFCONTEXT = 0x0000
            WINEVENT_SKIPOWNPROCESS = 0x0002

            hook = ctypes.windll.user32.SetWinEventHook(
                EVENT_SYSTEM_FOREGROUND,  # eventMin
                EVENT_SYSTEM_FOREGROUND,  # eventMax
                None,                      # hmodWinEventProc
                self._foreground_callback,
                0,                         # idProcess (all processes)
                0,                         # idThread (all threads)
                WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS,
            )
            if hook:
                self._foreground_hook_installed = True
                log.debug("[answer-win] Foreground hook installed")
        except Exception:
            pass

    def _assert_topmost(self) -> None:
        """Re-assert topmost z-order — above CSCA and all windows."""
        if not self._widget.isVisible():
            return
        if sys.platform == "win32":
            self._assert_topmost_win32()
        elif sys.platform == "darwin":
            self._assert_topmost_macos()

    def _assert_topmost_win32(self) -> None:
        """Windows: SetWindowPos HWND_TOPMOST + extended styles."""
        try:
            import ctypes
            hwnd = int(self._widget.winId())
            user32 = ctypes.windll.user32  # type: ignore[attr-defined]
            # Force topmost z-order
            user32.SetWindowPos(
                hwnd, self._HWND_TOPMOST, 0, 0, 0, 0,
                self._SWP_NOMOVE | self._SWP_NOSIZE
                | self._SWP_NOACTIVATE | self._SWP_SHOWWINDOW,
            )
            # Exclude from CSCA screen capture (WDA_EXCLUDEFROMCAPTURE)
            user32.SetWindowDisplayAffinity(hwnd, self._WDA_EXCLUDEFROMCAPTURE)
            # Do NOT set WS_EX_LAYERED here — it conflicts with WA_TranslucentBackground
            # Qt manages the layered window state internally via UpdateLayeredWindow
            # Clear window title — harder to find via FindWindow/EnumWindows
            user32.SetWindowTextW(hwnd, "")
        except (OSError, TypeError, ValueError, AttributeError):
            pass

    def _assert_topmost_macos(self) -> None:
        """macOS: NSWindowLevel 1100 — above SEB kiosk (~1000)."""
        try:
            import ctypes
            import ctypes.util

            lib_path = ctypes.util.find_library("objc")
            if not lib_path:
                return
            objc = ctypes.cdll.LoadLibrary(lib_path)
            objc.objc_getClass.restype = ctypes.c_void_p
            objc.sel_registerName.restype = ctypes.c_void_p
            objc.objc_msgSend.restype = ctypes.c_void_p
            objc.objc_msgSend.argtypes = [ctypes.c_void_p, ctypes.c_void_p]

            win_id = int(self._widget.winId())
            nsview = ctypes.c_void_p(win_id)
            sel_window = objc.sel_registerName(b"window")
            nswindow = objc.objc_msgSend(nsview, sel_window)
            if nswindow:
                sel_set_level = objc.sel_registerName(b"setLevel:")
                objc.objc_msgSend.argtypes = [
                    ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long,
                ]
                objc.objc_msgSend(nswindow, sel_set_level, 1100)
                objc.objc_msgSend.argtypes = [
                    ctypes.c_void_p, ctypes.c_void_p,
                ]
        except Exception:
            pass
