"""MathSolver — screenshot → Qwen2.5-VL (CPU) → Qwen 7B → answer.

100% offline, 0 network. Persistent worker process keeps models loaded.

Pipeline:
  1. Screenshot → crop 15% → resize 640px
  2. Qwen2.5-VL-3B reads screenshot → question + choices
  3. Qwen2.5-7B solves with comprehensive prompt
  4. Verification: Qwen 7B double-checks answer
  5. Answer window shows final answer
"""

from __future__ import annotations

import base64
import io
import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import threading
import traceback
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..config import Config

log = logging.getLogger(__name__)

_VISION_PROMPT = """\
Read the exam question from this screenshot. Be extremely precise with:
- Mathematical symbols: fractions (÷, /), powers (², ³, ⁿ), subscripts (₂, ₃)
- Chemical formulas: H₂O, Na₂O₂, Fe₂O₃, CₙH₂ₙ₊₂
- Greek letters: α, β, θ, Δ
- Signs: ≥, ≤, ≠, ±, →, ⇌
- Numbers and units exactly as shown

If there is an illustration/diagram/graph, describe it:
DIAGRAM: [what is shown, axes labels, key values, objects]

Format:
QUESTION: [exact question text with correct symbols]
A) [choice A]
B) [choice B]
C) [choice C]
D) [choice D]

If no question found: NOMATH: [all text on screen]
"""

_SOLVE_PROMPT = """\
Solve this exam problem. Show brief reasoning, then ANSWER: X (A/B/C/D).

PROBLEM: {question}

{choices_text}

KEY STRATEGIES:
- Sets: list elements explicitly. B={{x|0<x<5,x∈N}}={{1,2,3,4}}. Subset count = 2^n
- "At least one": Total - None. Example: C(9,2)-C(5,2) = 36-10 = 26
- Counting odd sum: 1odd+3even OR 3odd+1even. Example: C(5,1)C(4,3)+C(5,3)C(4,1) = 60
- Trig: cos2α = 2cos²α-1. If cosα=-1/4: 2(1/16)-1 = -7/8
- Parallel resistors: 1/R = 1/R₁+1/R₂
- Substitute known values as numbers first. If x=1,y=2: plug in directly
- Logic negation: ∀↔∃, <↔≥
- Physics: F=ma, F=kx, V=IR, W=Fs, Ek=½mv²
- Conceptual: analyze each choice carefully

EXAMPLES:
Q: A={{2,3}}, B={{1,2,3,4}}, count C where A⊆C⊆B → free={{1,4}}, count=2²=4 → C
Q: cosα=-1/4, cos2α? → 2(1/16)-1=-7/8 → A
Q: R=30Ω, R₁=60Ω, R₂? → 1/30-1/60=1/60, R₂=60 → C
Q: 5white+4red, pick 2, ≥1red → 36-10=26 → B
Q: mx+4y=7, 3x-my=n, sol=(1,2), m-n? → m=-1,n=5, m-n=-6 → D

BEFORE answering, double-check: signs (+/-), fractions, calculation steps. If unsure — recheck.
Solve step by step, then write ANSWER: X
"""

_PHYSICS_PROMPT = """\
Solve this physics exam problem. Show brief reasoning, then ANSWER: X (A/B/C/D).

PROBLEM: {question}

{choices_text}

=== MECHANICS ===
Kinematics:
- v = v₀ + at, s = v₀t + ½at², v² = v₀² + 2as
- Free fall: a = g = 9.8 m/s² (or 10), v₀ = 0
- Displacement-time graph: slope = velocity. Straight line = constant v. Curve = acceleration.
- Velocity-time graph: slope = acceleration. Area under curve = displacement.
- If graph goes up then down: object moves forward then backward (来回运动)

Newton's Laws:
- F = ma. Weight: W = mg. Normal force: N = mg on flat surface.
- F_net = 0 → constant velocity or at rest (balanced forces)
- Action-reaction: equal magnitude, opposite direction, on DIFFERENT objects
- Force needs TWO objects (施力体 and 受力体). No isolated force.
- Same force magnitude can have different effects (direction, contact point matter)

Momentum & Impulse:
- p = mv. Impulse: I = Ft = Δp
- Conservation: m₁v₁ + m₂v₂ = m₁v₁' + m₂v₂' (when no external force)
- Elastic collision: both momentum and KE conserved
- Inelastic: only momentum conserved

Work & Energy:
- W = Fs·cosθ. If θ=90°: W=0. If θ>90°: negative work.
- Ek = ½mv². Ep = mgh. W_net = ΔEk
- Conservation: Ek₁+Ep₁ = Ek₂+Ep₂ (only conservative forces)
- Power: P = W/t = Fv

Circular Motion:
- F = mv²/r = mω²r. T = 2π/ω = 2πr/v
- Gravitation: F = GMm/r². Orbital: GMm/r² = mv²/r

=== ELECTROMAGNETISM ===
Electrostatics:
- Coulomb: F = kq₁q₂/r² (k=9×10⁹). Like charges repel, opposite attract.
- Electric field: E = F/q = kQ/r². Field lines: from + to -
- Electric potential: V = kQ/r. Work: W = qΔV = q(V₁-V₂)
- Capacitor: C = Q/V = εA/d. Energy: E = ½CV² = ½QV

DC Circuits:
- Ohm: V = IR. P = IV = I²R = V²/R
- Series: R_total = R₁+R₂. Same current, voltage divides.
- Parallel: 1/R = 1/R₁+1/R₂. Same voltage, current divides.
- Kirchhoff: sum of currents at node = 0, sum of voltages in loop = 0

Magnetic Field:
- F on wire: F = BIL (when B⊥I). Direction: left-hand rule (force on current)
- Lorentz: F = qvB (when B⊥v). Direction: left-hand rule. Circular motion.
- Radius: r = mv/(qB). Period: T = 2πm/(qB)

Electromagnetic Induction:
- Faraday: EMF = -NΔΦ/Δt = BLv (for moving wire)
- Lenz: induced current opposes the CHANGE causing it
- If flux increases → induced current creates opposing flux
- If flux decreases → induced current creates supporting flux

=== THERMODYNAMICS ===
- PV = nRT. P₁V₁/T₁ = P₂V₂/T₂
- First law: ΔU = Q - W. Q>0: heat absorbed. W>0: work done by gas.
- Isothermal: ΔU=0, Q=W. Isobaric: W=PΔV. Isochoric: W=0, ΔU=Q.
- Internal energy depends on temperature only (ideal gas)

=== OPTICS ===
- Reflection: angle of incidence = angle of reflection
- Refraction: n₁sinθ₁ = n₂sinθ₂ (Snell's law)
- Total internal reflection: from dense to less dense, θ > θ_c, sinθ_c = n₂/n₁
- Interference: path difference = nλ (bright), (n+½)λ (dark)

=== MODERN PHYSICS ===
- Photoelectric: Ek = hf - W₀. Threshold: f₀ = W₀/h. No electron if f < f₀.
- E = hf = hc/λ. Photon momentum: p = h/λ
- Nuclear: mass defect Δm → energy E = Δmc²
- α decay: Z-2, A-4. β decay: Z+1, A same. γ: no change.
- Half-life: N = N₀(½)^(t/T)

CONCEPTUAL TIPS:
- Read ALL four choices. Eliminate obviously wrong ones first.
- "Which is correct" → usually only ONE is right
- "Which is wrong" → three are right, find the wrong one
- Graph questions: describe what the graph shows, then match to physics

EXAMPLES:
Q: F=kx, k=200N/m, x=0.2m, F=? → 200×0.2=40N → C
Q: R=30Ω parallel R₁=60Ω, R₂=? → 1/30-1/60=1/60, R₂=60Ω → C
Q: v-t graph shows straight line going up then down → object reverses direction → C
Q: Force on object, does motion state definitely change? → No, balanced forces possible → D
Q: Photoelectric: increase frequency → increase Ek, not current → B

BEFORE answering, double-check: units, signs, formulas, direction of vectors. If unsure — recheck.
Solve step by step, then write ANSWER: X
"""

_READ_TEXT_PROMPT = """\
Read ALL text visible on this screenshot. Output every word, number, sentence exactly as shown. Preserve the original language. Do not summarize, do not interpret, do not add anything. Just output the raw text.
"""

_CHEMISTRY_PROMPT = """\
You are a chemistry professor solving an exam problem. Analyze carefully, then ANSWER: X (A/B/C/D).

PROBLEM: {question}

{choices_text}

STEP-BY-STEP METHOD:
1. What topic is this? (classification, calculation, reaction, equilibrium, organic, redox, ionic, lab, periodic table)
2. What exactly is asked? (correct/incorrect statement, calculate value, identify substance, predict product)
3. For EACH choice A/B/C/D: is it true or false? Why?
4. Pick the answer that matches what is asked

=== TOPIC 1: CLASSIFICATION & STATE CHANGES ===
Pure substance = element (Fe, O₂, C) or compound (H₂O, NaCl, CO₂)
Mixture = homogeneous (solution, alloy) or heterogeneous (suspension, colloid)
Colloid: Tyndall effect. Solution: no Tyndall.
Allotropes: O₂/O₃, diamond/graphite/C₆₀ — same element, different structure

=== TOPIC 2: CHEMICAL NOTATION & EQUATIONS ===
Balance: atoms equal both sides. Include state symbols: (s)(l)(g)(aq)
Structural formula: H-O-H. Electron formula: show shared/lone pairs
Empirical vs molecular formula: CH₂O vs C₆H₁₂O₆

=== TOPIC 3: CALCULATIONS ===
n=m/M, c=n/V, PV=nRT. STP: 22.4L/mol, Nₐ=6.022×10²³
Dilution: c₁V₁=c₂V₂. pH=-lg[H⁺]. 0.01mol/L HCl → pH=2
Mass fraction: w=m(solute)/m(solution)×100%

=== TOPIC 4: INORGANIC PROPERTIES ===
Na: Na+H₂O→NaOH+H₂↑. Na₂O₂+H₂O→NaOH+O₂↑ (NOT H₂!)
Fe: Fe²⁺=green, Fe³⁺=yellow-brown. Fe+2HCl→FeCl₂+H₂↑. 2Fe+3Cl₂→2FeCl₃
Al: AMPHOTERIC. Al+NaOH(aq)→NaAlO₂+H₂↑. Al+HCl→AlCl₃+H₂↑. Al₂O₃ also amphoteric
Cu: Cu²⁺=blue. CuSO₄·5H₂O=blue crystal. Cu+2H₂SO₄(conc,hot)→CuSO₄+SO₂↑+2H₂O
Strong acids: HCl, H₂SO₄, HNO₃. Strong bases: NaOH, KOH, Ca(OH)₂
Weak acids: CH₃COOH, H₂CO₃, H₂S. Weak bases: NH₃·H₂O
Conc. H₂SO₄: oxidizing+dehydrating. Conc. HNO₃: oxidizing (→NO₂). Dilute HNO₃→NO

=== TOPIC 5: ORGANIC CHEMISTRY ===
Alkane CₙH₂ₙ₊₂: single bonds only. Substitution. CH₄+Cl₂→(light)CH₃Cl+HCl
Alkene CₙH₂ₙ: C=C double bond. Addition. C₂H₄+Br₂→C₂H₄Br₂. Polymerization.
Alkyne CₙH₂ₙ₋₂: C≡C triple bond. Addition.
Alcohol -OH: C₂H₅OH. Combustion, catalytic oxidation→CH₃CHO, dehydration→C₂H₄
Carboxylic acid -COOH: CH₃COOH. Esterification with alcohol.
Ester -COO-: hydrolysis in acid or base (saponification with NaOH)
Benzene C₆H₆: substitution (NOT addition easily). +Br₂(FeBr₃ catalyst)→bromobenzene
Isomers: same formula, different structure. C₄H₁₀ has 2 isomers.

=== TOPIC 6: REDOX ===
Oxidation = lose e⁻, oxidation number ↑. The substance is REDUCING AGENT.
Reduction = gain e⁻, oxidation number ↓. The substance is OXIDIZING AGENT.
Oxidation numbers: H=+1, O=-2, free element=0, sum=charge
Common oxidizers: KMnO₄, Cl₂, HNO₃, conc. H₂SO₄, O₂
Common reducers: metals (Fe, Zn, Al), H₂, C, CO, H₂S

=== TOPIC 7: IONIC REACTIONS ===
Net ionic equation: remove spectator ions. Keep: ↓precipitate, ↑gas, weak electrolyte, H₂O
Precipitates: AgCl↓(white), BaSO₄↓(white), CaCO₃↓(white), Fe(OH)₃↓(red-brown), Cu(OH)₂↓(blue)
Tests: Cl⁻+Ag⁺→AgCl↓. SO₄²⁻+Ba²⁺→BaSO₄↓. CO₃²⁻+H⁺→CO₂↑. Fe³⁺+KSCN→blood red.

=== TOPIC 8: ATOMIC STRUCTURE & PERIODIC LAW ===
Config: 1s²2s²2p⁶3s²3p⁶... Period=shells. Group=valence e⁻.
Left→right: radius↓, electronegativity↑, metallic↓, nonmetallic↑
Top→bottom: radius↑, metallic↑. Ionization energy: generally ↑ across period.

=== TOPIC 9: CHEMICAL BONDS ===
Ionic: metal+nonmetal (NaCl). Electron transfer.
Covalent: nonmetal+nonmetal. Polar(H-Cl) or nonpolar(H-H).
Metallic: metal atoms+delocalized e⁻.
Hydrogen bonding: H-F, H-O, H-N (stronger than van der Waals)

=== TOPIC 10: RATE & EQUILIBRIUM ===
Rate ↑: ↑concentration, ↑temperature, catalyst, ↑surface area
Equilibrium: forward rate=reverse rate. K changes ONLY with temperature.
Le Chatelier:
- Add reactant → forward. Remove product → forward.
- ↑T → endothermic direction. ↓T → exothermic direction.
- ↑P → fewer gas moles. ↓P → more gas moles.
- Catalyst: NO SHIFT. Just reaches equilibrium faster.

=== TOPIC 11: ELECTROLYTE SOLUTIONS ===
Strong: fully dissociate (HCl, H₂SO₄, NaOH, NaCl). Weak: partially (CH₃COOH, NH₃·H₂O)
Hydrolysis: weak acid salt→basic pH (Na₂CO₃, CH₃COONa). Weak base salt→acidic pH (NH₄Cl, FeCl₃)
NaCl, KNO₃: neutral (strong acid + strong base salt)

=== TOPIC 12: LAB & EXPERIMENTS ===
Diluting H₂SO₄: acid INTO water (never reverse). Smelling: waft, don't inhale.
H₂: Zn+dilute H₂SO₄. O₂: 2KClO₃→(MnO₂)2KCl+3O₂↑ or 2H₂O₂→2H₂O+O₂↑
CO₂: CaCO₃+2HCl→CaCl₂+H₂O+CO₂↑. Test: Ca(OH)₂→milky
Cl₂: MnO₂+4HCl(conc)→MnCl₂+Cl₂↑+2H₂O
Separation: filtration(solid/liquid), distillation(bp difference), extraction(immiscible solvent)

=== TOPIC 13: INDUSTRIAL PROCESSES ===
Haber: N₂+3H₂⇌2NH₃ (high P, 400-500°C, Fe catalyst)
Contact: 2SO₂+O₂⇌2SO₃ (V₂O₅ catalyst) → SO₃+H₂O→H₂SO₄
Electrolysis of brine: 2NaCl+2H₂O→2NaOH+Cl₂↑+H₂↑

EXAMPLES (follow this reasoning style):
Q: Na₂O₂ + H₂O →? → 2Na₂O₂+2H₂O→4NaOH+O₂↑. Products: NaOH and O₂ → B
Q: Which is strong electrolyte? NaCl/CH₃COOH/NH₃·H₂O/C₂H₅OH → NaCl fully ionizes → A
Q: pH of 0.01mol/L HCl? → strong acid, [H⁺]=0.01=10⁻², pH=2 → C
Q: Add N₂ to N₂+3H₂⇌2NH₃ at equilibrium → more reactant, forward shift → B
Q: Test Fe³⁺? → KSCN → blood red color → D
Q: C₂H₄+Br₂? → C=C double bond, addition reaction → A
Q: Catalyst effect on equilibrium? → faster but NO shift, K unchanged → A
Q: Which salt solution is acidic? NH₄Cl/Na₂CO₃/NaCl/KNO₃ → NH₄⁺ hydrolysis → acidic → A
Q: Reducing agent in 2Fe+3Cl₂→2FeCl₃? → Fe loses e⁻ (0→+3), Fe is reducing agent → A
Q: C₄H₁₀ isomers? → n-butane + isobutane = 2 → C
Q: Amphoteric oxide? Al₂O₃/CaO/SO₃/Fe₂O₃ → Al₂O₃ reacts with acid AND base → A
Q: Strongest acid in HF/HCl/HBr/HI? → down group: acid strength ↑ → HI → D
Q: Bond in NaCl? → metal Na + nonmetal Cl → ionic bond → B
Q: Dilute H₂SO₄ + what produces H₂? → Active metal (Zn, Fe, Mg). NOT Cu, Ag, Au → B
Q: Haber process catalyst? → Fe (iron) → C

BEFORE answering, double-check: chemical formulas, oxidation numbers, reaction products, balance. If unsure — recheck.
Solve step by step, then write ANSWER: X
"""

_CHINESE_VISION_PROMPT = """\
Read this Chinese exam screenshot. Copy ALL Chinese text exactly.

Possible formats:
1. Standard: question + A/B/C/D
2. Matching: terms + definitions to match
3. Fill blanks: text with (1)(2)(3) gaps + word choices
4. Passage + questions: long text then questions
5. Character recognition: identify Chinese characters
6. Sentence forming: arrange words into sentence
7. Sentence completion: finish incomplete sentence
8. Word/phrase distinction: choose correct word usage

Output ALL text you see on screen. Keep Chinese characters exact. Mark question numbers.
If multiple questions on one screen, output ALL of them.
Do not translate. Do not solve. Just read and output the text.
"""

# Legacy aliases for backwards compatibility
_STEM_VISION_PROMPT = _CHINESE_VISION_PROMPT
_HUMANITIES_VISION_PROMPT = _CHINESE_VISION_PROMPT

_CHINESE_PROMPT = """\
You are an expert on Chinese language exams (科技汉语 STEM Chinese + 人文汉语 Humanities Chinese). This tests understanding of Chinese language in both scientific AND humanities contexts — NOT calculations.

Topics covered: science, technology, literature, history, philosophy, economics, sociology, law, grammar.

{question}

{choices_text}

=== QUESTION FORMATS & HOW TO ANSWER ===

FORMAT 1 — Standard (one question + A/B/C/D):
→ Pick correct answer. Reply: ANSWER: X

FORMAT 2 — Matching (terms + definitions):
→ Match each term to its definition. One option may be extra, don't use it.
→ Reply: MATCHING: 1→X 2→X 3→X 4→X

FORMAT 3 — Fill in blanks (text with gaps + word choices):
→ Read context. Pick word that fits grammatically AND in meaning.
→ Reply: BLANKS: (1) X (2) X (3) X

FORMAT 4 — Passage + 2 or more questions:
→ Read ENTIRE passage carefully first. Answer based on passage content, not outside knowledge.
→ Reply: Q1: X Q2: X

FORMAT 5 — Character recognition (汉字辨认):
→ Identify correct character among similar-looking ones.
→ Common confusions: 已/己/巳, 折/拆, 析/淅, 钠/纳, 熔/溶/融
→ Reply: ANSWER: X

FORMAT 6 — Sentence forming (造句/排序):
→ Arrange given words into correct Chinese sentence order.
→ Chinese order: Subject + Time + Place + Manner + Verb + Object
→ Reply: ANSWER: X

FORMAT 7 — Sentence completion (补全句子):
→ Choose word/phrase that completes the sentence correctly.
→ Check grammar: 的/地/得 usage, 把/被 construction, 是...的 structure
→ Reply: ANSWER: X

FORMAT 8 — Word/phrase distinction (近义词辨析):
→ Choose the correct word among similar words.
→ Reply: ANSWER: X

=== KEY SIMILAR WORDS ===
STEM pairs:
- 发现/发明: 发现=discover(existing), 发明=invent(new)
- 研究/调查: 研究=research(deep), 调查=investigate(survey)
- 证明/证实: 证明=prove(logic), 证实=confirm(facts)
- 精确/准确: 精确=precise(numbers), 准确=accurate(general)
- 效率/效果: 效率=efficiency(ratio), 效果=effect(result)

Humanities pairs:
- 反映/反应: 反映=reflect(opinions), 反应=react(chemical/response)
- 以至/以致: 以至=to the extent, 以致=so that(negative result)
- 权利/权力: 权利=right(legal), 权力=power(political)
- 制定/制订: 制定=establish(policy/law), 制订=draft/formulate(plan)

=== GRAMMAR RULES ===
- 的/地/得: 的+noun(modifier), 地+verb(adverb), 得+complement(result)
- 把 structure: Subject + 把 + Object + Verb phrase (disposal)
- 被 passive: Object + 被 + Subject + Verb (passive voice)
- 是...的 structure: emphasis on time/place/manner of past action

=== TIPS ===
- This exam tests Chinese LANGUAGE ability, NOT calculation
- Read ALL choices before answering
- 根据文本(according to text) → answer must be found IN the passage
- 主要原因(main reason) → pick most direct cause
- 作者的观点(author's viewpoint) → look for 认为, 指出, 强调, 主张
- Watch for negation: 不正确的, 不属于, 不恰当, 错误的
- Eliminate obviously wrong choices first
- Pick the most formal/academic answer when unsure
"""

# Legacy aliases
_STEM_PROMPT = _CHINESE_PROMPT
_HUMANITIES_PROMPT = _CHINESE_PROMPT

# ── TIER2 prompts (RTX 40 series — medium length, key strategies + few examples) ──

_SOLVE_PROMPT_T2 = """\
Solve this exam problem. Show reasoning, then ANSWER: X (A/B/C/D).

PROBLEM: {question}

{choices_text}

KEY STRATEGIES:
- Sets: list elements, subset count = 2^n
- "At least one": Total - None
- Trig: cos2α = 2cos²α-1
- Substitute known values first
- Logic negation: ∀↔∃, <↔≥

EXAMPLES:
Q: A={{2,3}}, B={{1,2,3,4}}, count C where A⊆C⊆B → 2²=4 → C
Q: cosα=-1/4, cos2α? → 2(1/16)-1=-7/8 → A
Q: 5white+4red, pick 2, ≥1red → 36-10=26 → B

Solve step by step, then write ANSWER: X
"""

_PHYSICS_PROMPT_T2 = """\
Solve this physics exam problem. Show reasoning, then ANSWER: X (A/B/C/D).

PROBLEM: {question}

{choices_text}

KEY FORMULAS:
- Kinematics: v=v₀+at, s=v₀t+½at², v²=v₀²+2as
- Forces: F=ma, friction f=μN, gravity W=mg
- Energy: Ek=½mv², Ep=mgh, W=Fs
- Electricity: V=IR, P=IV, E=kq/r²
- Waves: v=fλ

For graphs: slope=rate of change, area=total quantity.
For conceptual: check each choice against the physics law.

EXAMPLES:
Q: Free fall 2s, distance? → s=½(10)(4)=20m → B
Q: Series R=10Ω+20Ω, I=0.5A, V? → V=0.5×30=15V → C

Solve step by step, then write ANSWER: X
"""

_CHEMISTRY_PROMPT_T2 = """\
You are a chemistry professor. Solve this problem. Show reasoning, then ANSWER: X (A/B/C/D).

PROBLEM: {question}

{choices_text}

KEY RULES:
- Balance equations (atoms equal both sides)
- Redox: lose e⁻=oxidation (reducing agent), gain e⁻=reduction (oxidizing agent)
- pH<7=acid, pH=7=neutral, pH>7=base. pH=-lg[H⁺]
- n=m/M, c=n/V. STP: 22.4L/mol
- Le Chatelier: catalyst NO shift. ↑reactant→forward. ↑T→endothermic
- Strong electrolytes: HCl, NaOH, NaCl. Weak: CH₃COOH, NH₃·H₂O
- Na+H₂O→NaOH+H₂. Na₂O₂+H₂O→NaOH+O₂ (NOT H₂!)
- Al is amphoteric. Conc H₂SO₄ is oxidizing.

EXAMPLES:
Q: pH of 0.01mol/L HCl? → [H⁺]=10⁻², pH=2 → C
Q: Test Fe³⁺? → KSCN → blood red → D
Q: C₂H₄+Br₂ type? → addition → A

BEFORE answering, double-check formulas and balance.
Solve step by step, then write ANSWER: X
"""

# ── TIER3 prompts (RTX 30 — minimal, fast, ~40-50 tokens) ──

_SOLVE_PROMPT_T3 = """\
Solve. Show short reasoning, then ANSWER: X

{question}
{choices_text}
ANSWER:"""

_PHYSICS_PROMPT_T3 = """\
Solve this physics problem. Short reasoning, then ANSWER: X

{question}
{choices_text}
ANSWER:"""

_CHEMISTRY_PROMPT_T3 = """\
Solve this chemistry problem. Short reasoning, then ANSWER: X

{question}
{choices_text}
ANSWER:"""

_CHINESE_PROMPT_T3 = """\
Chinese exam. Watch for 不正确/错误的. ANSWER: X

{question}
{choices_text}
ANSWER:"""

# Legacy aliases
_STEM_PROMPT_T3 = _CHINESE_PROMPT_T3
_HUMANITIES_PROMPT_T3 = _CHINESE_PROMPT_T3

# ── CPU prompts (short but with key hints, ~60-80 tokens) ──

_SOLVE_PROMPT_CPU = """\
Solve this math problem step by step. ANSWER: A, B, C, or D.

{question}
{choices_text}

Substitute values, simplify, check each choice. ANSWER:"""

_PHYSICS_PROMPT_CPU = """\
Solve this physics problem. Use formulas: F=ma, v=v₀+at, V=IR, W=Fs.

{question}
{choices_text}

Show key calculation, then ANSWER:"""

_CHEMISTRY_PROMPT_CPU = """\
Solve this chemistry problem. Balance equations, check oxidation states.

{question}
{choices_text}

Show reasoning, then ANSWER:"""

_CHINESE_PROMPT_CPU = """\
Chinese language exam. Read carefully, watch for 不正确/错误的 (negation words).

{question}
{choices_text}

Pick the correct answer. ANSWER:"""

# Legacy aliases
_STEM_PROMPT_CPU = _CHINESE_PROMPT_CPU
_HUMANITIES_PROMPT_CPU = _CHINESE_PROMPT_CPU

# ── TIER2 prompts (RTX 30 series — slightly shorter than full) ──

_CHINESE_PROMPT_T2 = """\
Chinese language exam (科技汉语 + 人文汉语) — science, literature, history, philosophy.

{question}

{choices_text}

FORMATS: Standard→ANSWER: X | Matching→MATCHING: 1→X 2→X | Blanks→BLANKS: (1) X (2) X | Passage→Q1: X Q2: X

RULES:
- Read ALL choices before answering
- Watch for negation: 不正确, 不恰当, 不属于, 错误的
- 根据文本 → answer must be IN the passage
- 作者的观点 → look for 认为, 指出, 强调
- Grammar: 的+noun, 地+verb, 得+complement. 把/被 constructions
- Key pairs: 发现/发明, 精确/准确, 反映/反应, 权利/权力, 制定/制订
- Pick the most formal/academic answer
"""

# Legacy aliases
_STEM_PROMPT_T2 = _CHINESE_PROMPT_T2
_HUMANITIES_PROMPT_T2 = _CHINESE_PROMPT_T2

# ── Prompt selection by GPU tier ──

def _get_prompts_for_tier(tier: str) -> dict:
    """Return prompt dict based on GPU tier."""
    if tier == "TIER1":
        # RTX 50 — full prompts (max accuracy)
        return {
            "math": _SOLVE_PROMPT,
            "physics": _PHYSICS_PROMPT,
            "chemistry": _CHEMISTRY_PROMPT,
            "chinese": _CHINESE_PROMPT,
            "stem": _CHINESE_PROMPT,
            "humanities": _CHINESE_PROMPT,
        }
    elif tier == "TIER2":
        # RTX 40 — medium prompts (strategies + few examples)
        return {
            "math": _SOLVE_PROMPT_T2,
            "physics": _PHYSICS_PROMPT_T2,
            "chemistry": _CHEMISTRY_PROMPT_T2,
            "chinese": _CHINESE_PROMPT,
            "stem": _CHINESE_PROMPT,
            "humanities": _CHINESE_PROMPT,
        }
    elif tier == "TIER3":
        # RTX 30 — compact prompts with key strategies, no examples
        return {
            "math": _SOLVE_PROMPT_T3,
            "physics": _PHYSICS_PROMPT_T3,
            "chemistry": _CHEMISTRY_PROMPT_T3,
            "chinese": _CHINESE_PROMPT_T3,
            "stem": _CHINESE_PROMPT_T3,
            "humanities": _CHINESE_PROMPT_T3,
        }
    elif tier == "TIER4":
        # RTX 20 — T3 prompts (compact)
        return {
            "math": _SOLVE_PROMPT_T3,
            "physics": _PHYSICS_PROMPT_T3,
            "chemistry": _CHEMISTRY_PROMPT_T3,
            "chinese": _CHINESE_PROMPT_T3,
            "stem": _CHINESE_PROMPT_T3,
            "humanities": _CHINESE_PROMPT_T3,
        }
    else:
        # TIER5 (GTX 10/16) and CPU — shortest prompts
        return {
            "math": _SOLVE_PROMPT_CPU,
            "physics": _PHYSICS_PROMPT_CPU,
            "chemistry": _CHEMISTRY_PROMPT_CPU,
            "chinese": _CHINESE_PROMPT_CPU,
            "stem": _CHINESE_PROMPT_CPU,
            "humanities": _CHINESE_PROMPT_CPU,
        }

# Available modes
MODES = ["math", "physics", "chemistry", "chinese", "stem", "humanities"]
MODE_NAMES = {"math": "Mathematics", "physics": "Physics", "chemistry": "Chemistry", "chinese": "Chinese", "stem": "Chinese", "humanities": "Chinese"}



class MathSolver:
    """Screenshot → LlamaCpp vision (CPU, no network) → solve → answer."""

    def __init__(self, config: Config) -> None:
        self._config = config
        self._lock = threading.Lock()
        self._busy = False
        self._answer_window = None
        self._mode = "math"  # default mode
        self._last_screenshot_b64 = None
        self._last_answer = None  # stores last answer for recall
        self._session = None  # Session logging disabled

    def set_answer_window(self, answer_window) -> None:
        self._answer_window = answer_window

    def show_last_answer(self) -> None:
        """Show the last answer again for 5 seconds."""
        if self._last_answer:
            self._show_answer(self._last_answer)
        else:
            self._show_answer("--")

    def start_session(self) -> None:
        """Start a new exam session (called when CSCA detected). DISABLED."""
        pass

    def end_session(self) -> None:
        """End exam session. DISABLED."""
        pass

    @property
    def mode(self) -> str:
        return self._mode

    def set_mode(self, mode: str) -> None:
        if mode in MODES:
            self._mode = mode
            self._console(f"Mode switched to: {MODE_NAMES.get(mode, mode)}")
        else:
            self._console(f"Unknown mode: {mode}. Available: {MODES}")

    def on_hotkey(self) -> None:
        with self._lock:
            if self._busy:
                self._console("Already processing — ignoring")
                return
            self._busy = True
        threading.Thread(target=self._run, daemon=True, name="math-solver").start()

    def on_translate_hotkey(self) -> None:
        """Ctrl+Shift+F8: screenshot → Qwen2.5-VL reads text → MarianMT translates Chinese to English."""
        with self._lock:
            if self._busy:
                self._console("Already processing — ignoring")
                return
            self._busy = True
        threading.Thread(target=self._run_translate, daemon=True, name="math-translate").start()

    def _run(self) -> None:
        try:
            self._pipeline()
        except Exception as exc:
            log.error("[math_solver] Pipeline failed: %s", exc, exc_info=True)
            self._console(f"PIPELINE ERROR: {exc}")
            self._show_answer(f"Error: {exc}")
        finally:
            with self._lock:
                self._busy = False

    def _run_translate(self) -> None:
        try:
            self._pipeline_translate()
        except Exception as exc:
            log.error("[math_solver] Translate failed: %s", exc, exc_info=True)
            self._console(f"TRANSLATE ERROR: {exc}")
            self._show_answer(f"Error: {exc}")
        finally:
            with self._lock:
                self._busy = False

    @staticmethod
    def _console(msg: str) -> None:
        print(f"[math_solver] {msg}", flush=True)

    @staticmethod
    def _has_gpu() -> bool:
        """Check if NVIDIA GPU with 4GB+ VRAM is available."""
        try:
            result = subprocess.run(
                ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
                capture_output=True, text=True, timeout=5,
            )
            if result.returncode == 0:
                vram = int(result.stdout.strip().split("\n")[0].strip())
                return vram >= 4000
        except Exception:
            pass
        return False

    @staticmethod
    def _get_gpu_tier() -> str:
        """Detect GPU generation and return tier for prompt selection.

        Returns: 'TIER1' (50 series), 'TIER2' (40 series), 'TIER3' (30 series),
                 'TIER4' (20 series), 'TIER5' (10/16 series), 'CPU' (no GPU)
        """
        try:
            result = subprocess.run(
                ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
                capture_output=True, text=True, timeout=5,
            )
            if result.returncode == 0:
                line = result.stdout.strip().split("\n")[0]
                name = line.split(",")[0].strip().upper()
                if "RTX 50" in name:
                    return "TIER1"  # Full prompts
                elif "RTX 40" in name:
                    return "TIER2"  # Medium prompts
                elif "RTX 30" in name:
                    return "TIER3"  # Shorter
                elif "RTX 20" in name:
                    return "TIER4"  # Short
                elif any(x in name for x in ["GTX 10", "GTX 16", "MX"]):
                    return "TIER5"  # Shortest GPU
                else:
                    return "TIER3"  # Unknown GPU — assume mid-range
        except Exception:
            pass
        return "CPU"


    # ------------------------------------------------------------------ #
    # Pipeline
    # ------------------------------------------------------------------ #

    def _pipeline(self) -> None:
        self._console("=" * 50)
        mode_label = MODE_NAMES.get(self._mode, self._mode)
        self._console(f"PIPELINE STARTED — mode: {mode_label}")

        # ── Step 1: Screenshot → crop → resize ──
        # ── Pre-check: verify models exist ──
        models_dir = os.path.expanduser("~/.muravey-china/models")
        qwen_vl = os.path.join(models_dir, "qwen25vl", "Qwen2.5-VL-3B-Instruct-Q4_K_M.gguf")
        qwen_7b = os.path.join(models_dir, "Qwen3-8B-Q4_K_M.gguf")
        if not os.path.isfile(qwen_7b):
            qwen_7b = os.path.join(models_dir, "qwen2.5-7b-instruct-q3_k_m.gguf")  # fallback
        missing = []
        if not os.path.isfile(qwen_vl):
            missing.append("Qwen2.5-VL-3B")
        if not os.path.isfile(qwen_7b):
            missing.append("Qwen2.5-7B")
        if missing:
            self._console(f"MODELS MISSING: {', '.join(missing)}")
            self._console("Models directory: " + models_dir)
            self._console("Please restart the program to download models.")
            self._show_answer("No models. Restart exe.")
            return

        self._show_answer("...")
        img_b64 = self._capture_and_crop()
        if not img_b64:
            self._show_answer("Screenshot failed")
            return
        self._last_screenshot_b64 = img_b64
        self._console("STEP 1 DONE: Screenshot captured & cropped")

        # ── Step 2: Qwen2.5-VL reads screenshot → question + choices ──
        self._console("STEP 2: Reading...")
        if self._mode in ("chinese", "stem", "humanities"):
            response = self._llamacpp_vision_with_prompt(img_b64, _CHINESE_VISION_PROMPT)
        else:
            response = self._llamacpp_vision(img_b64)
        if not response:
            self._console("ERROR: Qwen2.5-VL did not respond")
            self._show_answer("..")
            return
        self._console(f"STEP 2 DONE: Reading complete ({len(response)} chars)")
        self._console("----- SCREENSHOT TEXT -----")
        print(response, flush=True)
        self._console("----- END SCREENSHOT TEXT -----")

        # No math found — handle ": NOMATH:" and "NOMATH:" prefixes
        clean_response = response.strip().lstrip(": ").strip()
        if clean_response.startswith("NOMATH:"):
            screen_text = clean_response[7:].strip().strip("[]")
            self._console("No math found")
            self._show_answer("..")
            return

        # Parse question and choices from response
        question, choices = self._parse_question_choices(response)
        self._console(f"Question: {question}")
        if choices:
            self._console(f"Choices from screen: {choices}")

        # Build choices text for prompts
        choices_text = ""
        if choices:
            choices_text = "Answer choices:\n"
            for letter, val in choices.items():
                choices_text += f"{letter}) {val}\n"

        # ── Step 3: Qwen 7B solves (mode-dependent prompt) ──
        mode_name = MODE_NAMES.get(self._mode, self._mode)
        self._console(f"STEP 3: Solving ({mode_name} mode)...")

        import re as _re

        # Select prompt based on GPU tier (faster GPU = longer prompt = better accuracy)
        tier = self._get_gpu_tier()
        prompts = _get_prompts_for_tier(tier)
        self._console(f"GPU tier: {tier}")

        _tpl = prompts.get(self._mode, prompts["math"])
        if self._mode in ("chinese", "stem", "humanities"):
            solve_prompt = _tpl.format(question=response, choices_text="")
        else:
            solve_prompt = _tpl.format(question=question, choices_text=choices_text)
        solve_response = self._llamacpp_text(solve_prompt)
        if not solve_response:
            self._console("ERROR: Solver did not respond")
            self._show_answer("..")
            return

        # Qwen3 wraps reasoning in <think>...</think> — extract answer part
        if '</think>' in solve_response:
            # Complete think block — take text AFTER it (contains ANSWER: X)
            solve_response = solve_response.split('</think>')[-1].strip()
        elif '<think>' in solve_response:
            # Think block started but truncated — search entire text for answer
            pass  # keep full text, answer extractor will find ANSWER: X if present
        if not solve_response:
            self._show_answer("..")
            return

        self._console(f"STEP 3 DONE: Solved ({len(solve_response)} chars)")
        self._console("----- REASONING -----")
        print(solve_response, flush=True)
        self._console("----- END REASONING -----")

        # Extract answer based on mode
        if self._mode in ("chinese", "stem", "humanities"):
            # STEM/Humanities: various formats (matching, blanks, Q1:A, full text)
            answer = self._extract_stem_answer(solve_response)
        else:
            # Math/Physics/Chemistry: letter only (A/B/C/D)
            answer_letter = self._extract_answer_letter(solve_response)
            if answer_letter:
                answer = answer_letter
            else:
                answer = ".."

        self._console("----- ANSWER -----")
        print(answer, flush=True)
        self._console("----- END ANSWER -----")
        self._last_answer = answer
        self._show_answer(answer)

        # Save to session log
        if self._session:
            self._session.log_question(
                screenshot_b64=self._last_screenshot_b64,
                ocr_text=response,
                model_response=solve_response,
                answer=answer,
                mode=self._mode,
            )

    def _pipeline_translate(self) -> None:
        self._console("=" * 50)
        self._console("TRANSLATE STARTED (CPU only, no network)")

        # Step 1: Screenshot
        self._show_answer("Taking screenshot...")
        img_b64 = self._capture_and_crop()
        if not img_b64:
            self._show_answer("Screenshot failed")
            return
        self._console("STEP 1 DONE: Screenshot captured & cropped")

        # Step 2: Qwen2.5-VL reads all text from screenshot
        self._console("STEP 2: Reading...")
        self._show_answer("Reading text...")
        raw_text = self._llamacpp_vision_with_prompt(img_b64, _READ_TEXT_PROMPT)
        if not raw_text:
            self._console("ERROR: Model did not respond")
            self._show_answer("Failed to read text")
            return
        self._console(f"STEP 2 DONE: Text read ({len(raw_text)} chars)")
        self._console("----- RAW TEXT -----")
        print(raw_text, flush=True)
        self._console("----- END -----")

        # Step 3: MarianMT translates Chinese → English, removes English
        self._console("STEP 3: MarianMT translating Chinese → English...")
        self._show_answer("Translating...")
        translated = self._translate_zh_to_en(raw_text)
        self._console(f"STEP 3 DONE: Translation ({len(translated)} chars)")
        self._console("----- TRANSLATION -----")
        print(translated, flush=True)
        self._console("----- END -----")
        self._show_answer(translated)

    def _translate_zh_to_en(self, text: str) -> str:
        """Translate Chinese → English via MarianMT in separate process."""
        import json
        import os
        import subprocess
        import sys

        try:
            create_flags = 0
            if sys.platform == "win32":
                create_flags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0

            worker_dir = os.path.dirname(os.path.abspath(__file__))
            app_root = os.path.dirname(os.path.dirname(worker_dir))

            env = os.environ.copy()
            env["PYTHONIOENCODING"] = "utf-8"

            result = subprocess.run(
                [sys.executable, "-m", "src.math_solver.translate_worker"],
                input=text.encode("utf-8"),
                capture_output=True,
                timeout=120,
                cwd=app_root,
                creationflags=create_flags,
                env=env,
            )

            # Decode manually with error handling
            result.stdout = result.stdout.decode("utf-8", errors="replace")
            result.stderr = result.stderr.decode("utf-8", errors="replace")

            if result.returncode != 0:
                self._console(f"Translate worker error: {result.stderr[:300]}")
                return f"ERROR: {result.stderr[:200]}"

            for line in reversed(result.stdout.strip().split("\n")):
                line = line.strip()
                if line.startswith("{"):
                    data = json.loads(line)
                    return data.get("text", "")

            return result.stdout.strip() if result.stdout.strip() else text
        except Exception as exc:
            return f"ERROR: {exc}"

    # ------------------------------------------------------------------ #
    # Screenshot: capture → crop 15% top & 15% left → resize 640px
    # ------------------------------------------------------------------ #

    def _capture_and_crop(self) -> str | None:
        try:
            from ..screenshot import capture_screen

            img = capture_screen()
            if img is None:
                return None

            w, h = img.size
            crop_left = int(w * 0.15)
            crop_top = int(h * 0.15)
            img = img.crop((crop_left, crop_top, w, h))
            self._console(f"Cropped 15%%: {w}x{h} -> {img.size[0]}x{img.size[1]}")

            w, h = img.size
            # Chemistry/Physics/STEM/Humanities need higher resolution for text and formulas
            if self._mode in ("chemistry", "physics", "chinese", "stem", "humanities"):
                max_size = 1024
            else:
                max_size = 640
            if max(w, h) > max_size:
                ratio = max_size / max(w, h)
                new_w, new_h = int(w * ratio), int(h * ratio)
                from PIL import Image
                img = img.resize((new_w, new_h), Image.LANCZOS)
                self._console(f"Resized -> {new_w}x{new_h}")

            buf = io.BytesIO()
            rgb = img.convert("RGB") if img.mode == "RGBA" else img
            rgb.save(buf, format="JPEG", quality=85)
            self._console(f"Image: {len(buf.getvalue()) / 1024:.0f} KB")
            return base64.b64encode(buf.getvalue()).decode("ascii")
        except Exception as exc:
            log.error("[math_solver] Screenshot failed: %s", exc)
            return None

    # ------------------------------------------------------------------ #
    # Worker calls — subprocess.run() per call (stable)
    # ------------------------------------------------------------------ #

    def _run_worker(self, args: list, input_data: str, timeout: int = 300) -> str | None:
        """Run worker subprocess and return response text."""
        worker_dir = os.path.dirname(os.path.abspath(__file__))
        app_root = os.path.dirname(os.path.dirname(worker_dir))

        create_flags = 0
        if sys.platform == "win32":
            create_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)

        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"
        env["HF_HUB_OFFLINE"] = "1"

        result = subprocess.run(
            [sys.executable, "-m", "src.math_solver.worker"] + args,
            input=input_data.encode("utf-8"),
            capture_output=True,
            timeout=timeout,
            cwd=app_root,
            creationflags=create_flags,
            env=env,
        )

        stdout = result.stdout.decode("utf-8", errors="replace")
        stderr = result.stderr.decode("utf-8", errors="replace")

        if result.returncode != 0:
            # Show tail of stderr — the actual error is at the end
            self._console(f"Worker error: {stderr[-500:]}")
            return None

        output = stdout.strip()
        if not output:
            return None

        for line in reversed(output.split("\n")):
            line = line.strip()
            if line.startswith("{"):
                try:
                    data = json.loads(line)
                    if "error" in data:
                        err = data["error"]
                        if err == "BLANK_SCREENSHOT":
                            return "NOMATH"
                        elif "not found" in err.lower():
                            self._console(f"Model missing — restart to re-download")
                            self._show_answer("Model missing")
                        else:
                            self._console(f"Worker error: {err}")
                        return None
                    return data.get("text", "")
                except (json.JSONDecodeError, ValueError):
                    continue

        return output if output else None

    def _llamacpp_vision(self, img_b64: str) -> str | None:
        """Send screenshot to Qwen2.5-VL-3B."""
        img_data = base64.b64decode(img_b64)
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
            f.write(img_data)
            img_path = f.name

        try:
            self._console("Reading...")
            return self._run_worker([img_path], _VISION_PROMPT)
        finally:
            try:
                os.unlink(img_path)
            except Exception:
                pass

    def _llamacpp_text(self, prompt: str) -> str | None:
        """Send text prompt — model selected by mode (Phi-4 for math, Qwen3.5 for Chinese)."""
        self._console("Solving...")
        is_chinese = self._mode in ("chinese", "stem", "humanities")
        arg = "--text-chinese" if is_chinese else "--text"
        return self._run_worker([arg], prompt)

    def _llamacpp_vision_with_prompt(self, img_b64: str, prompt: str) -> str | None:
        """Vision with custom prompt."""
        img_data = base64.b64decode(img_b64)
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
            f.write(img_data)
            img_path = f.name

        try:
            return self._run_worker([img_path], prompt)
        finally:
            try:
                os.unlink(img_path)
            except Exception:
                pass

    def _llamacpp_fix_code(self, code: str, error: str) -> str | None:
        """Fix SymPy code — not implemented in subprocess mode."""
        # For now, no fix retry in subprocess mode
        return None

    # ------------------------------------------------------------------ #
    # SymPy execution
    # ------------------------------------------------------------------ #

    @staticmethod
    def _execute_sympy(code: str) -> tuple[dict, str | None]:
        """Execute SymPy code. Returns (namespace_dict, error_string)."""
        import sympy
        safe_ns: dict = {"__builtins__": {
            "__import__": __import__, "print": print,
            "range": range, "len": len, "abs": abs, "sum": sum,
            "min": min, "max": max, "round": round,
            "int": int, "float": float, "str": str,
            "list": list, "dict": dict, "tuple": tuple, "set": set,
            "enumerate": enumerate, "zip": zip, "map": map,
            "sorted": sorted, "isinstance": isinstance, "type": type,
            "True": True, "False": False, "None": None,
        }}
        safe_ns["sympy"] = sympy
        for name in dir(sympy):
            if not name.startswith("_"):
                safe_ns[name] = getattr(sympy, name)
        import math
        safe_ns["math"] = math

        # Remember keys before exec to find NEW variables after
        keys_before = set(safe_ns.keys())

        try:
            exec(code, safe_ns)  # noqa: S102
            # Tag new variables
            safe_ns["_new_vars"] = {k: safe_ns[k] for k in safe_ns if k not in keys_before and not k.startswith("_")}
            return safe_ns, None
        except Exception:
            return {}, traceback.format_exc().split("\n")[-2]

    # ------------------------------------------------------------------ #
    # Parsing and matching
    # ------------------------------------------------------------------ #

    @staticmethod
    def _parse_question_choices(text: str) -> tuple[str, dict[str, str]]:
        """Parse QUESTION and A/B/C/D choices from model output."""
        lines = text.strip().split("\n")
        question_parts = []
        choices = {}
        in_question = False

        for line in lines:
            stripped = line.strip()
            # Match choice lines: A) ..., B) ..., etc.
            choice_match = re.match(r'^([A-D])\)\s*(.+)', stripped)
            if choice_match:
                letter = choice_match.group(1)
                value = choice_match.group(2).strip()
                choices[letter] = value
                continue
            # QUESTION: prefix
            if stripped.startswith("QUESTION:"):
                in_question = True
                q = stripped[9:].strip()
                if q:
                    question_parts.append(q)
                continue
            # Regular line — part of question
            if in_question and stripped and not stripped.startswith("CHOICES"):
                question_parts.append(stripped)

        question = " ".join(question_parts) if question_parts else text.strip()
        return question, choices

    @staticmethod
    def _extract_number(text: str) -> float | None:
        """Extract numeric value from text like '40 N', '-7/8', '$\\frac{7}{8}$', '3.14', '2π'."""
        import re as _re
        text = text.strip()

        # Handle LaTeX fractions: $\frac{7}{8}$, -$\frac{7}{8}$
        latex_match = _re.search(r'(-?)\s*\$?\\frac\{(\d+)\}\{(\d+)\}\$?', text)
        if latex_match:
            sign = -1 if latex_match.group(1) == '-' else 1
            return sign * float(latex_match.group(2)) / float(latex_match.group(3))

        # Remove LaTeX $...$
        text = _re.sub(r'\$[^$]*\$', '', text).strip()

        # Remove units (N, m, kg, Ω, °, etc.)
        cleaned = _re.sub(r'[A-Za-zΩ°%]+$', '', text).strip()

        # Handle fractions like -7/8
        frac_match = _re.match(r'^(-?\d+)\s*/\s*(\d+)$', cleaned)
        if frac_match:
            return float(frac_match.group(1)) / float(frac_match.group(2))

        # Handle π
        if 'π' in cleaned or 'pi' in cleaned.lower():
            import math
            cleaned = cleaned.replace('π', '').replace('pi', '').replace('PI', '').strip()
            if not cleaned or cleaned == '-':
                return math.pi if cleaned != '-' else -math.pi
            return float(cleaned) * math.pi

        # Handle √
        if '√' in cleaned:
            cleaned = cleaned.replace('√', '')
            import math
            return math.sqrt(float(cleaned))

        try:
            return float(cleaned)
        except (ValueError, TypeError):
            return None

    @staticmethod
    def _match_to_choice(result, choices: dict[str, str]) -> tuple[str, str] | None:
        """Match SymPy result to the closest choice."""
        if not choices:
            return None

        result_str = str(result)

        # Exact string match first
        for letter, val in choices.items():
            if val.strip() == result_str.strip():
                return letter, val

        # Extract numeric value from result
        try:
            if isinstance(result, list):
                result_num = float(result[0])
            else:
                result_num = float(result)
        except (ValueError, TypeError):
            try:
                result_num = float(str(result).replace('*', '').strip())
            except (ValueError, TypeError):
                return None

        # Compare with each choice numerically
        best_letter = None
        best_diff = float("inf")
        for letter, val in choices.items():
            val_num = MathSolver._extract_number(val)
            if val_num is not None:
                diff = abs(val_num - result_num)
                if diff < best_diff:
                    best_diff = diff
                    best_letter = letter

        if best_letter is not None and best_diff < 0.01:
            return best_letter, choices[best_letter]

        # If no close match, pick closest
        if best_letter is not None:
            return best_letter, choices[best_letter]

        return None

    # ------------------------------------------------------------------ #
    # Helpers
    # ------------------------------------------------------------------ #

    @staticmethod
    def _extract_stem_answer(text: str) -> str:
        """Extract STEM answer in various formats."""
        import re as _re

        # Try MATCHING: 1→A 2→B 3→C 4→D
        matching = _re.search(r'MATCHING:\s*(.*)', text, _re.IGNORECASE)
        if matching:
            return matching.group(1).strip()

        # Try arrow format: 1→A 2→B
        arrows = _re.findall(r'(\d+)\s*[→\->]\s*([A-E])', text)
        if len(arrows) >= 3:
            return "  ".join(f"{n}→{l}" for n, l in arrows)

        # Try BLANKS: (1) A (2) B (3) C
        blanks = _re.search(r'BLANKS:\s*(.*)', text, _re.IGNORECASE)
        if blanks:
            return blanks.group(1).strip()

        # Try (1) A (2) B format
        fills = _re.findall(r'\((\d+)\)\s*([A-D])', text)
        if len(fills) >= 2:
            return "  ".join(f"({n}) {l}" for n, l in fills)

        # Try Q1: A Q2: B (multiple questions)
        q_answers = _re.findall(r'Q(\d+):\s*([A-D])', text, _re.IGNORECASE)
        if len(q_answers) >= 2:
            return "  ".join(f"Q{n}: {l}" for n, l in q_answers)

        # Try multiple ANSWER: lines
        all_answers = _re.findall(r'ANSWER:\s*([A-E])', text, _re.IGNORECASE)
        if len(all_answers) >= 2:
            return "  ".join(f"Q{i+1}: {a}" for i, a in enumerate(all_answers))

        # Single ANSWER: X
        m = _re.search(r'ANSWER:\s*([A-E])', text, _re.IGNORECASE)
        if m:
            return m.group(1)

        # Try boxed
        m = _re.search(r'\\boxed\{{([A-E])\}}', text)
        if m:
            return m.group(1)

        # Last standalone letter
        letters = _re.findall(r'\b([A-E])\b', text)
        if letters:
            return letters[-1]

        # Last resort
        return text.strip()[-80:]

    @staticmethod
    def _extract_answer_letter(text: str) -> str | None:
        """Extract answer letter (A/B/C/D) from model response using multiple patterns."""
        import re as _re
        # Pattern 1: ANSWER: X
        m = _re.search(r'ANSWER:\s*([A-D])', text, _re.IGNORECASE)
        if m:
            return m.group(1).upper()
        # Pattern 2: \boxed{X}
        m = _re.search(r'\\boxed\{([A-D])\}', text)
        if m:
            return m.group(1).upper()
        # Pattern 3: "the answer is X" / "答案是X" / "选X" / "应选X"
        m = _re.search(r'(?:answer is|答案[是为]|[应当]选|选择)\s*\(?([A-D])\)?', text, _re.IGNORECASE)
        if m:
            return m.group(1).upper()
        # Pattern 4: "correct .* is X" or "正确.*是.*X"
        m = _re.search(r'(?:correct|正确)[^A-D]*([A-D])', text, _re.IGNORECASE)
        if m:
            return m.group(1).upper()
        # Pattern 5: Last standalone letter A-D in the text
        all_letters = _re.findall(r'\b([A-D])\b', text)
        if all_letters:
            return all_letters[-1].upper()
        return None

    @staticmethod
    def _fix_common_code_errors(code: str) -> str:
        """Fix common Python errors generated by the model."""
        # .is_integer() doesn't work on int → use % 1 == 0 or % x == 0
        # Pattern: 6/i.is_integer() → (6 % i == 0)
        import re as _re
        # Fix: number/var.is_integer() → (number % var == 0)
        code = _re.sub(
            r'(\d+)/(\w+)\.is_integer\(\)',
            r'(\1 % \2 == 0)',
            code,
        )
        # Fix: (expr).is_integer() → (expr % 1 == 0)
        code = _re.sub(
            r'\.is_integer\(\)',
            r' % 1 == 0',
            code,
        )
        return code

    @staticmethod
    def _format_result(result: object) -> str:
        if result is None:
            return "No solution"
        if isinstance(result, list):
            if len(result) == 1:
                return str(result[0])
            return ", ".join(str(v) for v in result)
        if isinstance(result, dict):
            return ", ".join(f"{k}={v}" for k, v in result.items())
        return str(result)

    @staticmethod
    def _extract_python_code(text: str) -> str | None:
        match = re.search(r"```(?:python)?\s*\n(.*?)```", text, re.DOTALL)
        if match:
            return match.group(1).strip()
        match = re.search(r"```\s*\n(.*?)```", text, re.DOTALL)
        if match:
            return match.group(1).strip()
        lines = text.strip().split("\n")
        code_lines = [l for l in lines if l.strip() and not l.strip().startswith("#")]
        if code_lines and any(
            l.strip().startswith(("from ", "import ", "result")) for l in code_lines
        ):
            return text.strip()
        return None

    def _show_answer(self, text: str) -> None:
        # Skip loading/status indicators — only show real answers in popup
        _skip = {"...", "..", "⏳", "Reading text...", "Translating...",
                 "Taking screenshot...", "Screenshot failed", "No models. Restart exe.",
                 "Failed to read text"}
        if self._answer_window and text not in _skip and not text.startswith("Error:"):
            self._answer_window.show_text(text, timeout=5)
        # Always print to console
        print()
        print("  ╔══════════════════════════════╗")
        print(f"  ║  ANSWER:  {text[:20]:<20}║")
        print("  ╚══════════════════════════════╝")
        print()
