"""Calibration wizard for chart window — Tk-based, safe to import headlessly."""
from __future__ import annotations

import time
from datetime import datetime, timezone
from pathlib import Path


# ---------------------------------------------------------------------------
# TOML serialisation (stdlib tomllib is read-only; no third-party writer dep)
# ---------------------------------------------------------------------------

def _toml_scalar(v: object) -> str:
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, int):
        return str(v)
    if isinstance(v, float):
        return str(v)
    if isinstance(v, str):
        escaped = (
            v.replace("\\", "\\\\")
             .replace('"', '\\"')
             .replace("\n", "\\n")
             .replace("\r", "\\r")
        )
        return f'"{escaped}"'
    if isinstance(v, (list, tuple)):
        return "[" + ", ".join(_toml_scalar(x) for x in v) + "]"
    raise TypeError(f"Cannot TOML-serialize {type(v).__name__}: {v!r}")


def _emit_table(lines: list[str], data: dict, prefix: str) -> None:
    for k, v in data.items():
        if not isinstance(v, dict):
            lines.append(f"{k} = {_toml_scalar(v)}")
    for k, v in data.items():
        if isinstance(v, dict):
            full = f"{prefix}.{k}" if prefix else k
            lines.append("")
            lines.append(f"[{full}]")
            _emit_table(lines, v, full)


def _dict_to_toml(data: dict) -> str:
    lines: list[str] = []
    _emit_table(lines, data, "")
    return "\n".join(lines) + "\n"


def write_config(data: dict, out_dir: Path) -> Path:
    """Serialise *data* to a timestamped TOML file in *out_dir* and update current.txt."""
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    ts = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H%M")
    filename = f"{ts}.toml"
    config_path = out_dir / filename
    config_path.write_text(_dict_to_toml(data), encoding="utf-8")
    (out_dir / "current.txt").write_text(filename, encoding="utf-8")
    return config_path


# ---------------------------------------------------------------------------
# Window capture (Windows live) / screenshot fallback
# ---------------------------------------------------------------------------

def _find_candidate_windows(title_substr: str):
    """Return list of (title, width, height, window_obj) matching substring, sorted by area desc."""
    import pygetwindow as gw  # type: ignore[import-untyped]

    needle = title_substr.lower()
    out = []
    seen = set()
    for t in gw.getAllTitles():
        if not t or needle not in t.lower():
            continue
        if t in seen:
            continue
        seen.add(t)
        wins = gw.getWindowsWithTitle(t)
        if not wins:
            continue
        w = wins[0]
        try:
            width = int(w.width); height = int(w.height)
        except Exception:
            continue
        if width < 10 or height < 10:
            continue
        out.append((t, width, height, w))
    out.sort(key=lambda r: r[1] * r[2], reverse=True)
    return out


def _capture_via_mss(win):
    import mss  # type: ignore[import-untyped]
    from PIL import Image
    with mss.mss() as sct:
        mon = {
            "top": int(win.top), "left": int(win.left),
            "width": int(win.width), "height": int(win.height),
        }
        shot = sct.grab(mon)
    return Image.frombytes("RGB", shot.size, shot.rgb)


def _capture_via_printwindow(win):
    """Use Win32 PrintWindow with PW_RENDERFULLCONTENT. Reliable for GPU-accelerated apps."""
    import win32gui  # type: ignore[import-untyped]
    import win32ui  # type: ignore[import-untyped]
    from ctypes import windll  # type: ignore[attr-defined]
    from PIL import Image

    hwnd = win._hWnd  # pygetwindow exposes this
    rect = win32gui.GetWindowRect(hwnd)
    w = rect[2] - rect[0]
    h = rect[3] - rect[1]
    if w <= 0 or h <= 0:
        raise RuntimeError("window has zero size")

    hwnd_dc = win32gui.GetWindowDC(hwnd)
    mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
    save_dc = mfc_dc.CreateCompatibleDC()
    save_bitmap = win32ui.CreateBitmap()
    save_bitmap.CreateCompatibleBitmap(mfc_dc, w, h)
    save_dc.SelectObject(save_bitmap)

    PW_RENDERFULLCONTENT = 2
    result = windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), PW_RENDERFULLCONTENT)
    bmp_info = save_bitmap.GetInfo()
    bmp_bits = save_bitmap.GetBitmapBits(True)
    img = Image.frombuffer(
        "RGB", (bmp_info["bmWidth"], bmp_info["bmHeight"]),
        bmp_bits, "raw", "BGRX", 0, 1,
    )

    win32gui.DeleteObject(save_bitmap.GetHandle())
    save_dc.DeleteDC()
    mfc_dc.DeleteDC()
    win32gui.ReleaseDC(hwnd, hwnd_dc)

    if result != 1:
        raise RuntimeError(f"PrintWindow returned {result}")
    return img


def _image_looks_blank(pil_img, dark_thresh: int = 10) -> bool:
    """Heuristic: image is effectively blank if near-black or near-uniform."""
    import numpy as np
    arr = np.array(pil_img.resize((64, 36))).astype(np.int16)
    if arr.mean() < dark_thresh:
        return True
    return arr.std() < 3.0


def _capture_fullscreen_region():
    """Grab the entire virtual desktop and let the user drag a rectangle.

    Returns (cropped PIL RGB Image, (origin_x, origin_y, width, height)) in
    virtual-desktop coordinates. Runtime capture crops the same region.
    """
    import mss  # type: ignore[import-untyped]
    import tkinter as tk
    from PIL import Image, ImageTk

    with mss.mss() as sct:
        virt = sct.monitors[0]  # virtual screen (all monitors combined)
        shot = sct.grab(virt)
    full_img = Image.frombytes("RGB", shot.size, shot.rgb)
    virt_left = int(virt["left"])
    virt_top = int(virt["top"])

    root = tk.Tk()
    root.title("Select chart region")

    # Use most of the screen but leave OS chrome visible so user can close it
    screen_w = root.winfo_screenwidth()
    screen_h = root.winfo_screenheight()
    orig_w, orig_h = full_img.size
    avail_w = max(400, screen_w - 40)
    avail_h = max(300, screen_h - 120)
    scale = min(avail_w / orig_w, avail_h / orig_h, 1.0)
    disp_w, disp_h = int(orig_w * scale), int(orig_h * scale)

    root.geometry(f"{disp_w}x{disp_h + 40}+10+10")

    scaled = full_img.resize((disp_w, disp_h))
    tkimg = ImageTk.PhotoImage(scaled)

    status = tk.Label(
        root, bg="#e74c3c", fg="white", font=("Arial", 12, "bold"),
        text="DRAG a rectangle around the full chart area (including M2D MAPS strip). Enter = OK, Esc = cancel",
        pady=6,
    )
    status.pack(fill="x")
    canvas = tk.Canvas(
        root, width=disp_w, height=disp_h, cursor="crosshair",
        highlightthickness=0, bg="#000",
    )
    canvas.pack(fill="both", expand=True)
    canvas.create_image(0, 0, anchor="nw", image=tkimg)

    state: dict = {"x0": 0, "y0": 0, "x1": 0, "y1": 0, "rect_id": None, "started": False}
    result: dict = {"img": None}

    def on_press(e):
        state["started"] = True
        state["x0"], state["y0"] = e.x, e.y
        state["x1"], state["y1"] = e.x, e.y
        if state["rect_id"] is not None:
            canvas.delete(state["rect_id"])
        state["rect_id"] = canvas.create_rectangle(
            e.x, e.y, e.x, e.y, outline="#ff3838", width=3,
        )

    def on_drag(e):
        state["x1"], state["y1"] = e.x, e.y
        if state["rect_id"] is not None:
            canvas.coords(state["rect_id"], state["x0"], state["y0"], e.x, e.y)

    def on_ok(_=None):
        if not state["started"]:
            return
        x0 = int(min(state["x0"], state["x1"]) / scale)
        y0 = int(min(state["y0"], state["y1"]) / scale)
        x1 = int(max(state["x0"], state["x1"]) / scale)
        y1 = int(max(state["y0"], state["y1"]) / scale)
        if x1 - x0 < 100 or y1 - y0 < 100:
            return  # too small, ignore
        result["img"] = full_img.crop((x0, y0, x1, y1))
        # Convert back to virtual-desktop absolute coordinates.
        result["region"] = (virt_left + x0, virt_top + y0, x1 - x0, y1 - y0)
        root.destroy()

    def on_cancel(_=None):
        root.destroy()

    canvas.bind("<Button-1>", on_press)
    canvas.bind("<B1-Motion>", on_drag)
    canvas.bind("<ButtonRelease-1>", lambda e: None)  # keep rect visible until Enter
    root.bind("<Return>", on_ok)
    root.bind("<KP_Enter>", on_ok)
    root.bind("<Escape>", on_cancel)
    root.focus_force()

    root.mainloop()
    if result["img"] is None:
        raise ValueError("Region selection cancelled")
    return result["img"], result["region"]


def _capture_window(title_substr: str):
    """Capture a window by title substring. Tries PrintWindow first (reliable for GPU
    apps), falls back to mss. If multiple windows match, picks the largest by area.

    Returns a PIL RGB Image.
    """
    import pygetwindow as gw  # type: ignore[import-untyped]

    candidates = _find_candidate_windows(title_substr)
    if not candidates:
        all_titles = sorted({t for t in gw.getAllTitles() if t})
        preview = "\n  ".join(all_titles[:40])
        raise RuntimeError(
            f"No window title contains {title_substr!r} (case-insensitive).\n"
            f"Visible windows (first 40):\n  {preview}"
        )

    # If multiple candidates, ask user to pick (Tk listbox)
    if len(candidates) > 1:
        picked = _pick_window_dialog(candidates)
    else:
        picked = candidates[0]
    title, w_, h_, win = picked

    try:
        win.activate()
    except Exception:
        pass
    time.sleep(0.3)

    errors = []
    img = None
    for fn, name in ((_capture_via_printwindow, "PrintWindow"),
                     (_capture_via_mss, "mss")):
        try:
            candidate_img = fn(win)
            if _image_looks_blank(candidate_img):
                errors.append(f"{name}: returned blank/uniform image")
                continue
            img = candidate_img
            break
        except Exception as e:
            errors.append(f"{name}: {e}")

    if img is None:
        raise RuntimeError(
            f"Capture of window {title!r} ({w_}x{h_}) failed:\n  " + "\n  ".join(errors)
        )

    # Save debug copy so user can verify
    try:
        debug_path = Path("logs") / f"calibrate_capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
        debug_path.parent.mkdir(exist_ok=True)
        img.save(debug_path)
    except Exception:
        pass

    return img


def _pick_window_dialog(candidates):
    """Show a Tk listbox for the user to pick a window."""
    import tkinter as tk

    root = tk.Tk()
    root.title("Pick window")
    root.geometry("600x400+100+100")

    tk.Label(root, text="Multiple windows match. Pick the main TradeStation chart:",
             font=("Arial", 11), pady=6).pack(fill="x")

    frame = tk.Frame(root)
    frame.pack(fill="both", expand=True, padx=8, pady=4)
    scroll = tk.Scrollbar(frame)
    scroll.pack(side="right", fill="y")

    lb = tk.Listbox(frame, font=("Consolas", 10), yscrollcommand=scroll.set)
    lb.pack(side="left", fill="both", expand=True)
    scroll.config(command=lb.yview)

    for title, w, h, _win in candidates:
        lb.insert(tk.END, f"[{w}x{h}]  {title}")
    lb.selection_set(0)

    choice = {"idx": None}

    def _ok():
        sel = lb.curselection()
        if sel:
            choice["idx"] = sel[0]
        root.destroy()

    tk.Button(root, text="OK", command=_ok, bg="#2ecc71", fg="white",
              font=("Arial", 11), pady=4).pack(pady=6)
    lb.bind("<Double-Button-1>", lambda e: _ok())

    root.mainloop()
    if choice["idx"] is None:
        raise ValueError("Window selection cancelled")
    return candidates[choice["idx"]]


# ---------------------------------------------------------------------------
# Wizard
# ---------------------------------------------------------------------------

_COLOR_ORDER = [
    "turquoise", "yellow", "dark_green", "dark_red",
    "light_green", "light_red", "gray", "background",
]


def run_calibration(
    out_dir: Path,
    screenshot: Path | None = None,
    capture_delay: float = 3.0,
) -> Path:
    """Launch the guided calibration wizard and return the saved config path.

    If *screenshot* is provided, use that image instead of live window capture
    (useful for dev/testing on non-Windows hosts).
    *capture_delay* seconds countdown before the full-desktop snapshot — gives
    the user time to bring TradeStation to the foreground and minimize anything
    that covers it (PowerShell, VS Code, etc.).
    """
    import tkinter as tk
    from tkinter import simpledialog, messagebox
    from PIL import Image

    out_dir = Path(out_dir)
    configs_dir = out_dir / "configs" if out_dir.name != "configs" else out_dir
    configs_dir.mkdir(parents=True, exist_ok=True)

    # ------------------------------------------------------------------
    # 1. Acquire image + window title
    # ------------------------------------------------------------------
    pil_img: "Image.Image"
    chart_region: tuple[int, int, int, int] | None = None
    if screenshot is not None:
        pil_img = Image.open(screenshot).convert("RGB")
        root0 = tk.Tk(); root0.withdraw()
        window_title = simpledialog.askstring(
            "Window title",
            "Window title substring used by 'atm run' to locate the chart at runtime:",
            parent=root0,
            initialvalue=screenshot.stem,
        ) or screenshot.stem
        root0.destroy()
    else:
        root0 = tk.Tk(); root0.withdraw()
        window_title = simpledialog.askstring(
            "Window title",
            "Substring of the chart window title (used at runtime by 'atm run' to find it).\n"
            "Example: 'TradeStation' or 'DIA'.",
            parent=root0,
        ) or ""
        root0.destroy()
        if not window_title:
            raise ValueError("Window title is required to proceed.")

        # Default: region-select on full desktop (reliable with GPU-rendered apps).
        # Returns (cropped image, virtual-desktop region) so runtime capture can
        # re-crop the same area without needing pygetwindow to find the chart.
        if capture_delay > 0:
            root_msg = tk.Tk(); root_msg.withdraw()
            messagebox.showinfo(
                "Ready?",
                f"Bring TradeStation to the foreground so it's FULLY VISIBLE.\n\n"
                f"Minimize this terminal / VS Code / anything covering the chart.\n\n"
                f"After you press OK, the desktop screenshot is taken in {capture_delay:.0f}s.",
                parent=root_msg,
            )
            root_msg.destroy()
            for i in range(int(capture_delay), 0, -1):
                print(f"  capture in {i}...", flush=True)
                time.sleep(1.0)
        try:
            pil_img, chart_region = _capture_fullscreen_region()
        except ValueError:
            pil_img = _capture_window(window_title)

    # Persist captured frame for debugging + as reference corpus.
    try:
        debug_path = Path("logs") / f"calibrate_capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
        debug_path.parent.mkdir(parents=True, exist_ok=True)
        pil_img.save(debug_path)
    except Exception:
        pass

    # ------------------------------------------------------------------
    # 2. Launch interactive wizard on the image
    # ------------------------------------------------------------------
    wizard = _CalibrationWizard(pil_img, window_title)
    data = wizard.run()

    # ------------------------------------------------------------------
    # 3. Secrets live in .env at the project root — see .env.example.
    #    TOML stays 100% public (calibration only).
    # ------------------------------------------------------------------
    data.setdefault("options", {})

    if chart_region is not None:
        cx, cy, cw, ch = chart_region
        data["chart_window_region"] = {"x": cx, "y": cy, "w": cw, "h": ch}

    return write_config(data, configs_dir)


class _CalibrationWizard:
    """Single-window Tk wizard with step-by-step click instructions."""

    def __init__(self, pil_img, window_title: str) -> None:
        import tkinter as tk
        from PIL import ImageTk

        self._pil_img = pil_img
        self._window_title = window_title

        self._root = tk.Tk()
        self._root.title("ATM calibration wizard")
        self._result: dict | None = None

        # --- build step plan ---
        self._steps: list[tuple[str, str]] = [
            ("dot_roi_tl",  "M2D MAPS strip: click TOP-LEFT corner"),
            ("dot_roi_br",  "M2D MAPS strip: click BOTTOM-RIGHT corner"),
        ]
        for c in _COLOR_ORDER:
            if c == "background":
                self._steps.append(("color_background", "Click on the chart BACKGROUND (empty area)"))
            else:
                self._steps.append((f"color_{c}", f"Click on a {c.replace('_',' ').upper()} dot"))
        self._steps += [
            ("chart_roi_tl", "Chart area: click TOP-LEFT (where SL/TP lines will appear)"),
            ("chart_roi_br", "Chart area: click BOTTOM-RIGHT"),
            ("y1",           "Click on a known price level (any horizontal grid line) — reference 1"),
            ("y2",           "Click on ANOTHER price level — reference 2"),
            ("canary_tl",    "Canary region: click TOP-LEFT of a stable UI element (axis label, logo)"),
            ("canary_br",    "Canary region: click BOTTOM-RIGHT"),
            ("done",         "Calibration complete — click Save"),
        ]
        self._idx = 0

        self._data: dict = {
            "window_title": window_title,
            "dot_roi": None,
            "chart_roi": None,
            "colors": {},
            "y_axis": {},
            "canary_roi": None,
        }
        self._tmp: dict = {}  # first-click holders

        # --- layout (force geometry; Tk defaults to a tiny window otherwise) ---
        self._root.update_idletasks()
        screen_w = self._root.winfo_screenwidth()
        screen_h = self._root.winfo_screenheight()
        orig_w, orig_h = pil_img.size
        # Reserve ~110px for status bar + button row + window chrome.
        avail_w = max(400, screen_w - 40)
        avail_h = max(400, screen_h - 140)
        self._scale = min(avail_w / orig_w, avail_h / orig_h, 1.0)
        disp_w = int(orig_w * self._scale)
        disp_h = int(orig_h * self._scale)

        scaled = pil_img.resize((disp_w, disp_h))
        self._tkimg = ImageTk.PhotoImage(scaled)

        win_w = disp_w
        win_h = disp_h + 90
        self._root.geometry(f"{win_w}x{win_h}+10+10")
        self._root.minsize(600, 400)

        self._status = tk.Label(
            self._root, text="", font=("Arial", 13, "bold"), fg="white", bg="#2b7de9",
            padx=10, pady=8, anchor="w",
        )
        self._status.pack(fill="x", side="top")

        bar = tk.Frame(self._root, bg="#eee")
        bar.pack(fill="x", side="top")
        self._back_btn = tk.Button(bar, text="← Back", command=self._back)
        self._back_btn.pack(side="left", padx=4, pady=4)
        self._skip_btn = tk.Button(bar, text="Skip (can't find)", command=self._skip)
        self._skip_btn.pack(side="left", padx=4, pady=4)
        self._save_btn = tk.Button(
            bar, text="💾 Save", command=self._save, state="disabled",
            bg="#2ecc71", fg="white", activebackground="#27ae60",
        )
        self._save_btn.pack(side="right", padx=4, pady=4)

        self._canvas = tk.Canvas(
            self._root, width=disp_w, height=disp_h, bg="#222", cursor="crosshair",
            highlightthickness=0,
        )
        self._canvas.pack(side="top", fill="both", expand=True)
        self._canvas.create_image(0, 0, anchor="nw", image=self._tkimg)
        self._canvas.bind("<Button-1>", self._on_click)

        self._markers: list[int] = []
        self._refresh()

    # -------- state control --------
    def _refresh(self) -> None:
        name, prompt = self._steps[self._idx]
        self._status.config(text=f"Step {self._idx + 1}/{len(self._steps)} — {prompt}")
        self._back_btn.config(state=("normal" if self._idx > 0 else "disabled"))
        self._skip_btn.config(state=("normal" if name.startswith("color_") else "disabled"))
        self._save_btn.config(state=("normal" if name == "done" else "disabled"))

    def _advance(self) -> None:
        self._idx = min(self._idx + 1, len(self._steps) - 1)
        self._refresh()

    def _back(self) -> None:
        if self._idx == 0:
            return
        self._idx -= 1
        if self._markers:
            self._canvas.delete(self._markers.pop())
        self._refresh()

    def _skip(self) -> None:
        name, _ = self._steps[self._idx]
        if name.startswith("color_"):
            cname = name.split("_", 1)[1]
            self._data["colors"][cname] = {"rgb": [128, 128, 128], "tolerance": 30.0}
            self._advance()

    # -------- click handler --------
    def _on_click(self, event) -> None:
        name, _ = self._steps[self._idx]
        if name == "done":
            return
        ox = int(event.x / self._scale)
        oy = int(event.y / self._scale)

        # draw marker
        r = 8
        mk = self._canvas.create_oval(
            event.x - r, event.y - r, event.x + r, event.y + r,
            outline="#ff3838", width=3,
        )
        self._markers.append(mk)

        if name == "dot_roi_tl":
            self._tmp["dot_tl"] = (ox, oy)
        elif name == "dot_roi_br":
            x0, y0 = self._tmp["dot_tl"]
            self._data["dot_roi"] = {
                "x": min(ox, x0), "y": min(oy, y0),
                "w": abs(ox - x0), "h": abs(oy - y0),
            }
        elif name.startswith("color_"):
            cname = name.split("_", 1)[1]
            rgb = self._sample_rgb(ox, oy)
            tol = 25.0 if cname == "background" else 60.0
            self._data["colors"][cname] = {"rgb": list(rgb), "tolerance": tol}
        elif name == "chart_roi_tl":
            self._tmp["chart_tl"] = (ox, oy)
        elif name == "chart_roi_br":
            x0, y0 = self._tmp["chart_tl"]
            self._data["chart_roi"] = {
                "x": min(ox, x0), "y": min(oy, y0),
                "w": abs(ox - x0), "h": abs(oy - y0),
            }
        elif name in ("y1", "y2"):
            from tkinter import simpledialog
            price = simpledialog.askfloat(
                "Price", f"Enter the price at this y-coordinate (pixel y={oy}):",
                parent=self._root,
            )
            if price is None:
                self._canvas.delete(self._markers.pop())
                return
            if name == "y1":
                self._data["y_axis"]["p1_y"] = oy
                self._data["y_axis"]["p1_price"] = price
            else:
                if oy == self._data["y_axis"].get("p1_y"):
                    from tkinter import messagebox
                    messagebox.showerror("Invalid", "Reference 2 must have a DIFFERENT y-coordinate.")
                    self._canvas.delete(self._markers.pop())
                    return
                self._data["y_axis"]["p2_y"] = oy
                self._data["y_axis"]["p2_price"] = price
        elif name == "canary_tl":
            self._tmp["canary_tl"] = (ox, oy)
        elif name == "canary_br":
            x0, y0 = self._tmp["canary_tl"]
            self._data["canary_roi"] = {
                "x": min(ox, x0), "y": min(oy, y0),
                "w": abs(ox - x0), "h": abs(oy - y0),
            }

        self._advance()

    # -------- helpers --------
    def _sample_rgb(self, x: int, y: int) -> tuple[int, int, int]:
        """Sample RGB around (x,y), snapping to the most-saturated pixel in a
        ±15px radius so rough clicks still pick the dot's pure colour."""
        import numpy as np
        arr = np.array(self._pil_img)
        h, w = arr.shape[:2]
        r = 15
        x0, x1 = max(0, x - r), min(w, x + r + 1)
        y0, y1 = max(0, y - r), min(h, y + r + 1)
        patch = arr[y0:y1, x0:x1].astype(np.int32)
        # Saturation proxy = max-min per pixel across RGB channels
        sat = patch.max(axis=2) - patch.min(axis=2)
        if sat.max() > 30:
            iy, ix = np.unravel_index(int(np.argmax(sat)), sat.shape)
            cx, cy = x0 + int(ix), y0 + int(iy)
        else:
            cx, cy = x, y
        # 5x5 mean at the (snapped) centre.
        box = (max(0, cx - 2), max(0, cy - 2), cx + 3, cy + 3)
        pixels = list(self._pil_img.crop(box).getdata())
        n = len(pixels) or 1
        return (
            sum(p[0] for p in pixels) // n,
            sum(p[1] for p in pixels) // n,
            sum(p[2] for p in pixels) // n,
        )

    def _save(self) -> None:
        from tkinter import messagebox
        missing = [k for k in ("dot_roi", "chart_roi", "canary_roi") if not self._data.get(k)]
        if missing:
            messagebox.showerror("Incomplete", f"Missing: {', '.join(missing)}")
            return
        if len(self._data["colors"]) < 7:
            messagebox.showerror("Incomplete", "All dot colours are required (background optional).")
            return
        if "p1_y" not in self._data["y_axis"] or "p2_y" not in self._data["y_axis"]:
            messagebox.showerror("Incomplete", "Both y-axis reference points are required.")
            return

        # Compute canary baseline phash
        import numpy as np
        from .vision import phash, crop_roi
        from .config import ROI
        arr = np.array(self._pil_img)[:, :, ::-1]  # RGB → BGR
        roi = ROI(**self._data["canary_roi"])
        ph = phash(crop_roi(arr, roi))

        out = {
            "window_title": self._window_title,
            "dot_roi": self._data["dot_roi"],
            "chart_roi": self._data["chart_roi"],
            "colors": self._data["colors"],
            "y_axis": self._data["y_axis"],
            "canary": {
                "roi": self._data["canary_roi"],
                "baseline_phash": ph,
                "drift_threshold": 8,
            },
        }
        self._result = out
        self._root.destroy()

    def run(self) -> dict:
        self._root.mainloop()
        if self._result is None:
            raise ValueError("Calibration cancelled (window closed before save)")
        return self._result
