"""Sulcora Planner — desktop GUI (VTK + PyQt5).

Three orthogonal MPR viewers + a 3-D reconstruction, with click-to-set
target / entry, linked crosshair navigation, anatomical orientation labels,
and live trajectory metrics. Requires a display.

Run:  python -m sulcora.app  [path-to-nifti-or-dicom-dir]
"""
from __future__ import annotations
import sys
import numpy as np

from .io import load
from .planning import Trajectory, risk_band
from .vtk_utils import volume_to_vtk
from .mpr import VIEW_ORIENTATION as ORI, orientation_labels


def main(argv=None):
    argv = argv or sys.argv[1:]
    import vtk
    from PyQt5 import QtWidgets, QtCore, QtGui
    from vtk.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor

    class Planner(QtWidgets.QMainWindow):
        def __init__(self):
            super().__init__()
            self.setWindowTitle("Sulcora Planner — research use only")
            self.resize(1360, 900)
            self.vol = None
            self.vtkimg = None
            self.entry = None
            self.target = None
            self.focus = None            # linked-crosshair focus point (VTK world mm)
            self.arm = None              # 'entry' | 'target'
            self.viewers = {}
            self.overlays = {}           # per-view 2D display-space overlay actors
            self.structures = []         # loaded RiskStructures (atlas / vessels)
            self.procedure = None        # selected procedure key (see procedures.py)
            self._target_items = []      # procedure-specific target candidates
            self._build_ui()

        # ---------- UI ----------
        def _build_ui(self):
            central = QtWidgets.QWidget(); self.setCentralWidget(central)
            grid = QtWidgets.QGridLayout(central); grid.setSpacing(2)
            self.mpr = {}
            for idx, name in enumerate(["Axial", "Coronal", "Sagittal"]):
                w = QVTKRenderWindowInteractor(central)
                self.mpr[name] = w
                grid.addWidget(self._titled(name, w), idx // 2, idx % 2)
            self.threed = QVTKRenderWindowInteractor(central)
            grid.addWidget(self._titled("3D Reconstruction", self.threed), 1, 1)

            self._build_dock()
            self._build_toolbar()
            self._build_menubar()
            self.statusBar().showMessage(
                "Open a patient scan to begin — File ▸ Open patient CT / MRI (DICOM series), "
                "or the toolbar. The scan is processed on this machine and never uploaded.")
            for w in list(self.mpr.values()) + [self.threed]:
                w.Initialize(); w.Start()
            if len(sys.argv) > 1:
                self.open_path(sys.argv[1])

        def _build_menubar(self):
            mb = self.menuBar()
            fm = mb.addMenu("&File")
            fm.addAction("Open patient CT / MRI — DICOM series…", self.open_dicom_dialog)
            fm.addAction("Open NIfTI (.nii / .nii.gz)…", self.open_dialog)
            fm.addSeparator()
            fm.addAction("Load Atlas", self.load_atlas)
            fm.addAction("Load Vessels (CTA/MRA)…", self.load_vessels)
            fm.addSeparator()
            fm.addAction("Load Plan…", self.load_plan_dialog)
            fm.addAction("Save Plan…", self.save_plan_dialog)
            fm.addAction("Export Report (PDF)…", self.export_report_dialog)
            fm.addSeparator()
            fm.addAction("Quit", self.close)
            hm = mb.addMenu("&Help")
            hm.addAction("How do I import a patient scan?", self._show_import_help)

        def _show_import_help(self):
            QtWidgets.QMessageBox.information(
                self, "Importing a patient scan",
                "Sulcora opens the scan locally on THIS machine — it is never uploaded.\n\n"
                "• CT or MRI from PACS (a folder of DICOM files):\n"
                "    File ▸ Open patient CT / MRI — DICOM series…  then pick the folder.\n\n"
                "• A single NIfTI file (.nii / .nii.gz):\n"
                "    File ▸ Open NIfTI…\n\n"
                "CT is auto-detected in Hounsfield units (for haemorrhage/ICH); MRI is used for "
                "DBS / biopsy / tumour. After opening, choose the Procedure and a Target in the "
                "right-hand panel, then ③ Plan / ④ Optimize.")

        def _titled(self, title, widget):
            box = QtWidgets.QWidget(); v = QtWidgets.QVBoxLayout(box)
            v.setContentsMargins(0, 0, 0, 0); v.setSpacing(0)
            lab = QtWidgets.QLabel(title); lab.setStyleSheet(
                "color:#9fd0ff;background:#0b1322;padding:4px 8px;font-weight:600;")
            v.addWidget(lab); v.addWidget(widget)
            return box

        def _build_toolbar(self):
            tb = self.addToolBar("main")
            act = lambda t, f: tb.addAction(t, f)
            act("📂 Open patient CT / MRI (DICOM)…", self.open_dicom_dialog)
            act("Open NIfTI…", self.open_dialog)
            tb.addSeparator()
            self.aTarget = tb.addAction("① Set Target", lambda: self._arm("target"))
            self.aEntry = tb.addAction("② Set Entry", lambda: self._arm("entry"))
            self.aTarget.setCheckable(True); self.aEntry.setCheckable(True)
            tb.addAction("③ Plan", self.plan)
            tb.addAction("Clear", self.clear_plan)
            tb.addSeparator()
            tb.addAction("Load Atlas", self.load_atlas)
            tb.addAction("Load Vessels…", self.load_vessels)
            tb.addAction("④ Optimize", self.optimize)
            tb.addSeparator()
            tb.addAction("Probe's-eye…", self.probes_eye_dialog)
            tb.addAction("Export Report", self.export_report_dialog)
            tb.addAction("Save Plan", self.save_plan_dialog)
            tb.addAction("Load Plan", self.load_plan_dialog)

        def _build_dock(self):
            dock = QtWidgets.QDockWidget("Plan", self)
            dock.setAllowedAreas(QtCore.Qt.RightDockWidgetArea)
            panel = QtWidgets.QWidget(); form = QtWidgets.QFormLayout(panel)
            # ---- procedure picker ----
            from .procedures import PROCEDURES
            self.cboProc = QtWidgets.QComboBox()
            for p in PROCEDURES:
                self.cboProc.addItem(p.name, p.key)
            self.lblProcInfo = QtWidgets.QLabel(""); self.lblProcInfo.setWordWrap(True)
            self.lblProcInfo.setStyleSheet("color:#8aa0c0;font-size:11px;")
            self.cboProc.currentIndexChanged.connect(self._on_procedure)
            form.addRow("Procedure:", self.cboProc)
            form.addRow(self.lblProcInfo)
            self.cboTarget = QtWidgets.QComboBox()
            self.cboTarget.currentIndexChanged.connect(self._on_target_selected)
            form.addRow("Targets:", self.cboTarget)
            self.lblCursorMM = QtWidgets.QLabel("—")
            self.lblCursorVox = QtWidgets.QLabel("—")
            self.lblTarget = QtWidgets.QLabel("—")
            self.lblEntry = QtWidgets.QLabel("—")
            self.lblLen = QtWidgets.QLabel("—")
            self.lblAng = QtWidgets.QLabel("—")
            self.lblBand = QtWidgets.QLabel("No plan")
            form.addRow("Cursor (mm):", self.lblCursorMM)
            form.addRow("Cursor (vox):", self.lblCursorVox)
            form.addRow("Target (mm):", self.lblTarget)
            form.addRow("Entry (mm):", self.lblEntry)
            form.addRow("Length:", self.lblLen)
            form.addRow("Angle from vertical:", self.lblAng)
            form.addRow("Status:", self.lblBand)
            # ---- risk readout (from loaded structures) ----
            self.lblStructures = QtWidgets.QLabel("none loaded")
            self.lblRisk = QtWidgets.QLabel("—")
            self.lblRiskDef = QtWidgets.QLabel("—")
            self.lblRiskPer = QtWidgets.QLabel("—"); self.lblRiskPer.setWordWrap(True)
            self.lblAlts = QtWidgets.QLabel("—"); self.lblAlts.setWordWrap(True)
            for lab in (self.lblStructures, self.lblRisk, self.lblRiskDef, self.lblRiskPer, self.lblAlts):
                lab.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
            form.addRow("Structures:", self.lblStructures)
            form.addRow("Nearest / clearance:", self.lblRisk)
            form.addRow("Deformation-aware:", self.lblRiskDef)
            form.addRow("Per structure:", self.lblRiskPer)
            form.addRow("Optimizer (min mm):", self.lblAlts)
            self.opacity = QtWidgets.QSlider(QtCore.Qt.Horizontal)
            self.opacity.setRange(0, 100); self.opacity.setValue(30)
            self.opacity.valueChanged.connect(self._set_brain_opacity)
            form.addRow("Brain opacity:", self.opacity)
            # ---- CT window presets (HU) ----
            wrow = QtWidgets.QWidget(); wl = QtWidgets.QHBoxLayout(wrow)
            wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
            for nm in ("brain", "blood", "bone", "stroke"):
                btn = QtWidgets.QPushButton(nm.capitalize())
                btn.clicked.connect(lambda _=False, n=nm: self._set_ct_window(n))
                wl.addWidget(btn)
            form.addRow("CT window:", wrow)
            # ---- manual coordinate entry (e.g. frame/atlas RAS mm) ----
            self.txtTarget = QtWidgets.QLineEdit(); self.txtTarget.setPlaceholderText("x, y, z mm")
            self.txtEntry = QtWidgets.QLineEdit(); self.txtEntry.setPlaceholderText("x, y, z mm")
            btnT = QtWidgets.QPushButton("Set target (mm)")
            btnE = QtWidgets.QPushButton("Set entry (mm)")
            btnT.clicked.connect(lambda: self._set_point_manual("target"))
            btnE.clicked.connect(lambda: self._set_point_manual("entry"))
            form.addRow(btnT, self.txtTarget)
            form.addRow(btnE, self.txtEntry)
            tip = QtWidgets.QLabel("Click any view to navigate (linked crosshair).\n"
                                   "Left-drag adjusts brightness/contrast.")
            tip.setStyleSheet("color:#8aa0c0;font-size:11px;"); tip.setWordWrap(True)
            form.addRow(tip)
            dock.setWidget(panel)
            self.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock)
            self._on_procedure()                     # initialise procedure + info

        # ---------- data ----------
        def open_dialog(self):
            p, _ = QtWidgets.QFileDialog.getOpenFileName(
                self, "Open NIfTI", "", "NIfTI (*.nii *.nii.gz)")
            if p:
                self.open_path(p)

        def open_dicom_dialog(self):
            # A DICOM series is a folder of slices (as exported from PACS). All
            # loading and processing is local — the image never leaves this device.
            d = QtWidgets.QFileDialog.getExistingDirectory(self, "Open DICOM series folder")
            if d:
                self.open_path(d)

        def open_path(self, path):
            self.vol = load(path)
            from .ct import is_ct
            self.is_ct = is_ct(self.vol)
            self.vtkimg = volume_to_vtk(self.vol)
            self._origin = self.vol.affine[:3, 3]
            self._spacing = self.vol.spacing
            self._setup_mpr()
            self._setup_3d()
            # start focused at the volume centre
            self.focus = self._vtk_pos((np.array(self.vol.shape) - 1) / 2.0)
            self._navigate_to(self.focus)
            self._refresh_targets()                  # populate procedure-specific targets

        # ---------- VTK <-> voxel helpers (VTK display space is axis-aligned) ----------
        def _vtk_pos(self, voxel):
            return self._origin + np.asarray(voxel, float) * self._spacing

        def _voxel_from_vtk(self, p):
            return (np.asarray(p, float) - self._origin) / self._spacing

        # VTK display space is axis-aligned; structures live in the true affine
        # (RAS) space, so convert picked points before scoring / optimising.
        def _true_world(self, p_vtk):
            return self.vol.voxel_to_world(self._voxel_from_vtk(p_vtk))

        def _vtk_from_true(self, p_true):
            return self._vtk_pos(self.vol.world_to_voxel(p_true))

        # ---------- structures + risk ----------
        _BAND_COL = {"HIGH": "#ff5d5d", "CAUTION": "#f5b041", "LOW": "#2ecc71"}

        def _update_structures_label(self):
            names = ", ".join(s.name for s in self.structures) or "none loaded"
            self.lblStructures.setText(names)

        def load_atlas(self):
            if self.vol is None:
                return
            from . import atlas as A
            from .risk import select_labels
            from .optimizer import RiskStructure
            QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
            try:
                cort, clab = A.load_harvard_oxford("cort-maxprob-thr25-2mm")
                cort = A.resample_atlas_to(cort, self.vol)
                sub, slab = A.load_harvard_oxford("sub-maxprob-thr25-2mm")
                sub = A.resample_atlas_to(sub, self.vol)
            except Exception as e:
                QtWidgets.QApplication.restoreOverrideCursor()
                QtWidgets.QMessageBox.warning(self, "Atlas", f"Could not load atlas:\n{e}")
                return
            QtWidgets.QApplication.restoreOverrideCursor()
            elo = [l for l in A.ELOQUENT_DEFAULT if l in clab]
            deep = select_labels(slab, A.SUBCORTICAL_DEFAULT)
            self.structures = [s for s in self.structures if s.name not in ("eloquent", "deep")]
            self.structures.append(RiskStructure("eloquent", A.structure_mask(cort, clab, elo), 3.0, 1.0))
            self.structures.append(RiskStructure("deep", A.structure_mask(sub, slab, deep), 2.0, 1.0))
            self._update_structures_label(); self._update_risk_readout()

        def load_vessels(self):
            if self.vol is None:
                return
            p, _ = QtWidgets.QFileDialog.getOpenFileName(
                self, "Open angiographic NIfTI (CTA/MRA/TOF)", "", "NIfTI (*.nii *.nii.gz)")
            if not p:
                return
            from .io import load
            from . import vessels as Ve
            from .optimizer import RiskStructure
            QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
            try:
                angio = load(p)
                vmask = Ve.vessel_mask(angio, scales=(1.0, 2.0, 3.0), bright_vessels=True, percentile=99.0)
            except Exception as e:
                QtWidgets.QApplication.restoreOverrideCursor()
                QtWidgets.QMessageBox.warning(self, "Vessels", f"Could not segment vessels:\n{e}")
                return
            QtWidgets.QApplication.restoreOverrideCursor()
            self.structures = [s for s in self.structures if s.name != "vessels"]
            self.structures.append(RiskStructure("vessels", vmask, 3.0, 1.5))
            self._update_structures_label(); self._update_risk_readout()

        def optimize(self):
            if self.target is None:
                QtWidgets.QMessageBox.information(self, "Optimize", "Set a target first (① Set Target)."); return
            if not self.structures:
                QtWidgets.QMessageBox.information(self, "Optimize", "Load structures first (Load Atlas / Vessels)."); return
            from . import session
            QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
            try:
                plans = session.recommend(self.vol, self._true_world(self.target), self.structures)
            finally:
                QtWidgets.QApplication.restoreOverrideCursor()
            feasible = [p for p in plans if p.feasible]
            best = feasible[0] if feasible else plans[0]
            self.entry = self._vtk_from_true(best.entry)
            self._update_markers(); self._draw_trajectory(); self._update_overlays()
            self._update_readout(planned=True); self._update_risk_readout()
            shown = ([best] + feasible[1:4]) if feasible else plans[:1]
            self.lblAlts.setText(f"{len(feasible)}/{len(plans)} feasible · " +
                                 " | ".join(f"{p.min_clearance_mm:.1f}({p.band[0]})" for p in shown))

        def _update_risk_readout(self):
            if not self.structures or self.entry is None or self.target is None:
                self.lblRisk.setText("—"); self.lblRiskDef.setText("—"); self.lblRiskPer.setText("—")
                return
            from .planning import Trajectory
            from .deformation import ProbeDeformation
            from . import session
            tr = Trajectory(self._true_world(self.entry), self._true_world(self.target))
            rep = session.risk_summary(tr, self.structures, deformation=ProbeDeformation(tr))
            band = rep["band"]
            self.lblRisk.setText(f"{rep['nearest']} · {rep['min_clearance_mm']:.1f} mm ({band})")
            self.lblRisk.setStyleSheet(f"color:{self._BAND_COL.get(band, '#e8eef9')};font-weight:700")
            if "deformed_min_clearance_mm" in rep:
                db = rep.get("deformed_band", band)
                self.lblRiskDef.setText(f"{rep['deformed_min_clearance_mm']:.1f} mm ({db})")
                self.lblRiskDef.setStyleSheet(f"color:{self._BAND_COL.get(db, '#e8eef9')};font-weight:700")
            self.lblRiskPer.setText("  ·  ".join(
                f"{k} {v['clearance_mm']:.1f}mm" for k, v in rep["per_structure"].items()))

        # ---------- MPR ----------
        def _setup_mpr(self):
            import vtk
            for name, ori in ORI.items():
                w = self.mpr[name]
                viewer = vtk.vtkImageViewer2()
                viewer.SetRenderWindow(w.GetRenderWindow())
                viewer.SetupInteractor(w.GetRenderWindow().GetInteractor())
                viewer.SetInputData(self.vtkimg)
                viewer.SetSliceOrientation(ori)
                viewer.SetSlice((viewer.GetSliceMax() + viewer.GetSliceMin()) // 2)
                if getattr(self, "is_ct", False):
                    viewer.SetColorWindow(80); viewer.SetColorLevel(40)   # CT brain window
                else:
                    rng = self.vtkimg.GetScalarRange()
                    viewer.SetColorWindow(rng[1] - rng[0]); viewer.SetColorLevel((rng[0] + rng[1]) / 2)
                try:
                    viewer.GetImageActor().InterpolateOn()      # smooth, not blocky
                except Exception:
                    pass
                viewer.Render()
                self.viewers[name] = viewer
                self._add_orientation_labels(name, viewer)
                self._init_overlays(name, viewer)
                self._add_pick(name, w, viewer)

        def _add_orientation_labels(self, name, viewer):
            """R/L/A/P/S/I labels at the four edges, derived from the affine."""
            import vtk
            try:
                labels = orientation_labels(self.vol.affine, name)
            except Exception:
                return
            ren = viewer.GetRenderer()
            places = {"left": (0.02, 0.5), "right": (0.98, 0.5),
                      "top": (0.5, 0.97), "bottom": (0.5, 0.03)}
            for edge, (x, y) in places.items():
                t = vtk.vtkTextActor()
                t.SetInput(labels[edge])
                t.GetTextProperty().SetFontSize(16)
                t.GetTextProperty().SetColor(0.62, 0.82, 1.0)
                t.GetTextProperty().SetJustificationToCentered()
                t.GetTextProperty().SetVerticalJustificationToCentered()
                t.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport()
                t.SetPosition(x, y)
                ren.AddActor2D(t)

        # ---------- 2D display-space overlays (crosshair + trajectory) ----------
        def _new_2d_actor(self, ren, color, line_width=1.4, point_size=1.0):
            import vtk
            mapper = vtk.vtkPolyDataMapper2D()
            coord = vtk.vtkCoordinate(); coord.SetCoordinateSystemToDisplay()
            mapper.SetTransformCoordinate(coord)
            actor = vtk.vtkActor2D(); actor.SetMapper(mapper)
            actor.GetProperty().SetColor(*color)
            actor.GetProperty().SetLineWidth(line_width)
            actor.GetProperty().SetPointSize(point_size)
            ren.AddActor2D(actor)
            return actor

        def _init_overlays(self, name, viewer):
            ren = viewer.GetRenderer()
            self.overlays[name] = {
                "cross": self._new_2d_actor(ren, (0.55, 0.85, 1.0), 1.0),
                "traj": self._new_2d_actor(ren, (1.0, 0.72, 0.25), 2.2),
                "entry": self._new_2d_actor(ren, (0.22, 1.0, 0.62), 1.0, 11),
                "target": self._new_2d_actor(ren, (1.0, 0.36, 0.36), 1.0, 11),
            }

        def _to_display(self, ren, world_xyz):
            ren.SetWorldPoint(float(world_xyz[0]), float(world_xyz[1]), float(world_xyz[2]), 1.0)
            ren.WorldToDisplay()
            d = ren.GetDisplayPoint()
            return d[0], d[1]

        def _set_2d(self, actor, pts_display, lines=None, verts=None):
            import vtk
            pd = vtk.vtkPolyData(); vpts = vtk.vtkPoints()
            for (x, y) in pts_display:
                vpts.InsertNextPoint(x, y, 0.0)
            pd.SetPoints(vpts)
            if lines:
                ca = vtk.vtkCellArray()
                for a, b in lines:
                    ca.InsertNextCell(2); ca.InsertCellPoint(a); ca.InsertCellPoint(b)
                pd.SetLines(ca)
            if verts:
                va = vtk.vtkCellArray()
                for i in verts:
                    va.InsertNextCell(1); va.InsertCellPoint(i)
                pd.SetVerts(va)
            actor.GetMapper().SetInputData(pd)

        def _update_overlays(self):
            if not self.viewers:
                return
            import vtk  # noqa: F401
            for name, viewer in self.viewers.items():
                ren = viewer.GetRenderer()
                ov = self.overlays[name]
                W, H = viewer.GetRenderWindow().GetSize()
                # crosshair through the focus point
                if self.focus is not None and W and H:
                    cx, cy = self._to_display(ren, self.focus)
                    self._set_2d(ov["cross"], [(0, cy), (W, cy), (cx, 0), (cx, H)],
                                 lines=[(0, 1), (2, 3)])
                else:
                    self._set_2d(ov["cross"], [])
                # planned trajectory + markers (projected to this view's display)
                if self.entry is not None and self.target is not None:
                    e = self._to_display(ren, self.entry); t = self._to_display(ren, self.target)
                    self._set_2d(ov["traj"], [e, t], lines=[(0, 1)])
                else:
                    self._set_2d(ov["traj"], [])
                self._set_2d(ov["entry"],
                             [self._to_display(ren, self.entry)] if self.entry is not None else [],
                             verts=[0] if self.entry is not None else None)
                self._set_2d(ov["target"],
                             [self._to_display(ren, self.target)] if self.target is not None else [],
                             verts=[0] if self.target is not None else None)
                viewer.GetRenderWindow().Render()

        def _navigate_to(self, world_xyz):
            """Linked-crosshair: point every view at `world_xyz` and refresh overlays."""
            self.focus = np.asarray(world_xyz, float)
            vox = self._voxel_from_vtk(self.focus)
            for name, viewer in self.viewers.items():
                s_ax = {"Axial": 2, "Coronal": 1, "Sagittal": 0}[name]
                idx = int(np.clip(round(float(vox[s_ax])),
                                  viewer.GetSliceMin(), viewer.GetSliceMax()))
                viewer.SetSlice(idx)
            self._update_overlays()
            self._update_cursor_readout()

        def _update_cursor_readout(self):
            if self.focus is None:
                return
            vox = self._voxel_from_vtk(self.focus)
            world = self.vol.voxel_to_world(vox)               # anatomically correct RAS mm
            self.lblCursorMM.setText(f"{world[0]:.0f}, {world[1]:.0f}, {world[2]:.0f}")
            self.lblCursorVox.setText(f"{vox[0]:.0f}, {vox[1]:.0f}, {vox[2]:.0f}")

        def _add_pick(self, name, widget, viewer):
            import vtk
            picker = vtk.vtkPropPicker()
            iren = widget.GetRenderWindow().GetInteractor()

            def on_click(obj, evt):
                x, y = iren.GetEventPosition()
                picker.Pick(x, y, 0, viewer.GetRenderer())
                pos = np.array(picker.GetPickPosition(), float)
                if self.arm:
                    self._set_point(pos)
                else:
                    self._navigate_to(pos)

            iren.AddObserver("LeftButtonPressEvent", on_click, 1.0)
            # keep overlays aligned after zoom / pan
            iren.AddObserver("EndInteractionEvent", lambda o, e: self._update_overlays(), 0.0)

        def _setup_3d(self):
            import vtk
            ren = vtk.vtkRenderer(); ren.SetBackground(0.02, 0.03, 0.05)
            self.threed.GetRenderWindow().AddRenderer(ren)
            self.ren3d = ren
            mc = vtk.vtkFlyingEdges3D()
            mc.SetInputData(self.vtkimg)
            rng = self.vtkimg.GetScalarRange()
            if getattr(self, "is_ct", False):
                mc.SetValue(0, -400)                          # CT: air/tissue -> head surface
            else:
                mc.SetValue(0, rng[0] + 0.18 * (rng[1] - rng[0]))
            mc.Update()
            mapper = vtk.vtkPolyDataMapper(); mapper.SetInputConnection(mc.GetOutputPort())
            mapper.ScalarVisibilityOff()
            actor = vtk.vtkActor(); actor.SetMapper(mapper)
            actor.GetProperty().SetColor(0.80, 0.71, 0.68)
            actor.GetProperty().SetOpacity(0.30)
            ren.AddActor(actor); self.brain_actor = actor
            self.traj_actor = None
            self.markers = {}
            ren.ResetCamera(); self.threed.GetRenderWindow().Render()

        # ---------- planning ----------
        def _arm(self, which):
            self.arm = which
            self.aTarget.setChecked(which == "target")
            self.aEntry.setChecked(which == "entry")

        def _set_point(self, world_xyz):
            if self.arm == "target":
                self.target = world_xyz
            elif self.arm == "entry":
                self.entry = world_xyz
            self.arm = None
            self.aTarget.setChecked(False); self.aEntry.setChecked(False)
            self._update_markers()
            self._navigate_to(world_xyz)          # jump all views to the new point
            self._update_readout(planned=False)
            self._update_risk_readout()

        # ---------- plan I/O, report, probe's-eye ----------
        def _current_plan(self, pid="CASE"):
            from .plan import SurgicalPlan
            plan = SurgicalPlan(patient_id=pid)
            plan.add("Trajectory 1", Trajectory(self._true_world(self.entry),
                                                self._true_world(self.target)))
            return plan

        def _need_plan(self, title):
            if self.entry is None or self.target is None:
                QtWidgets.QMessageBox.information(self, title, "Set target and entry first.")
                return False
            return True

        def save_plan_dialog(self):
            if not self._need_plan("Save Plan"):
                return
            p, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save plan", "plan.json", "JSON (*.json)")
            if not p:
                return
            from .plan import save_plan
            save_plan(self._current_plan(), p)
            QtWidgets.QMessageBox.information(self, "Save Plan", f"Saved:\n{p}")

        def load_plan_dialog(self):
            p, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load plan", "", "JSON (*.json)")
            if not p:
                return
            from .plan import load_plan
            try:
                plan = load_plan(p)
            except Exception as e:
                QtWidgets.QMessageBox.warning(self, "Load Plan", str(e)); return
            if len(plan):
                tr = plan.items[0].trajectory
                self.entry = self._vtk_from_true(tr.entry)
                self.target = self._vtk_from_true(tr.target)
                self._update_markers(); self._draw_trajectory(); self._navigate_to(self.target)
                self._update_readout(planned=True); self._update_risk_readout()

        def export_report_dialog(self):
            if not self._need_plan("Export Report"):
                return
            p, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Export report", "plan_report.pdf", "PDF (*.pdf)")
            if not p:
                return
            from .report import build_report
            QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
            try:
                build_report(self._current_plan(), self.vol, p, structures=self.structures or None)
            except Exception as e:
                QtWidgets.QApplication.restoreOverrideCursor()
                QtWidgets.QMessageBox.warning(self, "Export Report", str(e)); return
            QtWidgets.QApplication.restoreOverrideCursor()
            QtWidgets.QMessageBox.information(self, "Export Report", f"Saved:\n{p}")

        def probes_eye_dialog(self):
            if not self._need_plan("Probe's-eye"):
                return
            import os, tempfile
            import matplotlib; matplotlib.use("Agg")
            import matplotlib.pyplot as plt
            from . import reslice as Re
            tr = Trajectory(self._true_world(self.entry), self._true_world(self.target))
            eye = Re.probes_eye(self.vol, tr, fov_mm=50.0, spacing_mm=0.6)
            inl, _ = Re.along_trajectory(self.vol, tr, width_mm=60.0, pad_mm=12.0, spacing_mm=1.0)
            fig, axs = plt.subplots(1, 2, figsize=(9, 4.5), facecolor="#0b0f18")
            axs[0].imshow(inl, cmap="gray", origin="lower", aspect="auto")
            axs[0].set_title("In-line (trajectory plane)", color="#9fd0ff"); axs[0].axis("off")
            axs[1].imshow(eye, cmap="gray", origin="lower")
            axs[1].set_title("Probe's-eye (at target)", color="#9fd0ff"); axs[1].axis("off")
            fig.tight_layout()
            png = os.path.join(tempfile.mkdtemp(), "probeseye.png")
            fig.savefig(png, dpi=110, facecolor=fig.get_facecolor()); plt.close(fig)
            dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Probe-aligned views")
            lay = QtWidgets.QVBoxLayout(dlg); lab = QtWidgets.QLabel()
            lab.setPixmap(QtGui.QPixmap(png)); lay.addWidget(lab); dlg.exec_()

        def _on_procedure(self):
            from .procedures import PROCEDURE_BY_KEY
            p = PROCEDURE_BY_KEY.get(self.cboProc.currentData())
            if not p:
                return
            self.procedure = p.key
            self.lblProcInfo.setText(f"{p.modality} · {p.status} — {p.summary}")
            if getattr(self, "is_ct", False) and "CT" in p.modality and self.viewers:
                self._set_ct_window("brain")
            self._refresh_targets()

        def _refresh_targets(self):
            """Populate the target list with the procedure-appropriate candidates
            (detected clots for ICH, frontal horns for EVD, nuclei for DBS)."""
            self.cboTarget.blockSignals(True)
            self.cboTarget.clear(); self._target_items = []
            if self.vol is not None and self.procedure:
                try:
                    from .session import procedure_targets
                    res = procedure_targets(self.vol, self.procedure)
                except Exception:
                    res = {"items": []}
                for it in res.get("items", []):
                    if it.get("centroid") is not None:
                        vcc, loc = it.get("volume_cc"), it.get("location", "")
                        label = it.get("name") or (f"clot {vcc} cc · {loc}" if vcc else "target")
                    else:
                        label = it.get("name", "target") + ("  (needs atlas)" if "centroid" not in it else "")
                    self.cboTarget.addItem(label); self._target_items.append(it)
                if not self._target_items:
                    self.cboTarget.addItem("(no targets detected)")
            self.cboTarget.blockSignals(False)

        def _on_target_selected(self):
            i = self.cboTarget.currentIndex()
            if i < 0 or i >= len(self._target_items):
                return
            it = self._target_items[i]
            c = it.get("centroid")
            if c is None:
                QtWidgets.QMessageBox.information(
                    self, "Target",
                    f"{it.get('name')}: locating this named target on the patient needs atlas "
                    "registration (Load Atlas) or manual coordinates.")
                return
            self.target = self._vtk_from_true(np.asarray(c, float))
            self._update_markers(); self._navigate_to(self.target)
            self._update_readout(planned=False); self._update_risk_readout()

        def _set_ct_window(self, name):
            from .ct import WINDOWS
            level, width = WINDOWS[name]
            for v in self.viewers.values():
                v.SetColorLevel(level); v.SetColorWindow(width); v.Render()

        def _set_point_manual(self, which):
            """Set target/entry from typed RAS (world) mm coordinates 'x, y, z'."""
            txt = (self.txtTarget if which == "target" else self.txtEntry).text()
            try:
                parts = [float(v) for v in txt.replace(";", ",").split(",")]
                assert len(parts) == 3
            except Exception:
                QtWidgets.QMessageBox.warning(self, "Manual entry",
                                              "Enter coordinates as:  x, y, z  (mm)")
                return
            p_vtk = self._vtk_from_true(np.array(parts, float))
            if which == "target":
                self.target = p_vtk
            else:
                self.entry = p_vtk
            self._update_markers(); self._navigate_to(p_vtk)
            self._update_readout(planned=False); self._update_risk_readout()

        def plan(self):
            if self.entry is None or self.target is None:
                QtWidgets.QMessageBox.information(self, "Plan", "Set both target and entry first.")
                return
            self._draw_trajectory()
            self._update_overlays()
            self._update_readout(planned=True)
            self._update_risk_readout()

        def clear_plan(self):
            self.entry = self.target = None
            self._update_markers(); self._draw_trajectory()
            self._update_overlays(); self._update_readout(planned=False)
            self.lblAlts.setText("—"); self._update_risk_readout()

        def _update_readout(self, planned):
            def fmt(p):
                if p is None:
                    return "—"
                w = self.vol.voxel_to_world(self._voxel_from_vtk(p))
                return f"{w[0]:.0f}, {w[1]:.0f}, {w[2]:.0f}"
            self.lblTarget.setText(fmt(self.target))
            self.lblEntry.setText(fmt(self.entry))
            if self.entry is not None and self.target is not None:
                t = Trajectory(self.entry, self.target)
                self.lblLen.setText(f"{t.length:.1f} mm")
                self.lblAng.setText(f"{t.angle_from_axis():.0f}°")
                self.lblBand.setText("Trajectory set" if planned else "Ready to plan")
            else:
                self.lblLen.setText("—"); self.lblAng.setText("—")
                self.lblBand.setText("No plan")

        # ---------- 3D actors ----------
        def _sphere(self, center, color):
            import vtk
            s = vtk.vtkSphereSource(); s.SetRadius(3.0); s.SetCenter(*center)
            s.SetThetaResolution(18); s.SetPhiResolution(18)
            m = vtk.vtkPolyDataMapper(); m.SetInputConnection(s.GetOutputPort())
            a = vtk.vtkActor(); a.SetMapper(m); a.GetProperty().SetColor(*color)
            return a

        def _update_markers(self):
            for k in list(self.markers):
                self.ren3d.RemoveActor(self.markers.pop(k))
            if self.entry is not None:
                self.markers["e"] = self._sphere(self.entry, (0.22, 1.0, 0.62))
                self.ren3d.AddActor(self.markers["e"])
            if self.target is not None:
                self.markers["t"] = self._sphere(self.target, (1.0, 0.36, 0.36))
                self.ren3d.AddActor(self.markers["t"])
            self.threed.GetRenderWindow().Render()

        def _draw_trajectory(self):
            import vtk
            if self.traj_actor is not None:
                self.ren3d.RemoveActor(self.traj_actor); self.traj_actor = None
            if self.entry is not None and self.target is not None:
                line = vtk.vtkLineSource()
                line.SetPoint1(*self.entry); line.SetPoint2(*self.target)
                tube = vtk.vtkTubeFilter(); tube.SetInputConnection(line.GetOutputPort())
                tube.SetRadius(1.1); tube.SetNumberOfSides(12)
                m = vtk.vtkPolyDataMapper(); m.SetInputConnection(tube.GetOutputPort())
                a = vtk.vtkActor(); a.SetMapper(m); a.GetProperty().SetColor(0.18, 0.80, 0.44)
                self.ren3d.AddActor(a); self.traj_actor = a
            self.threed.GetRenderWindow().Render()

        def _set_brain_opacity(self, v):
            if hasattr(self, "brain_actor"):
                self.brain_actor.GetProperty().SetOpacity(v / 100.0)
                self.threed.GetRenderWindow().Render()

    appqt = QtWidgets.QApplication(sys.argv)
    win = Planner(); win.show()
    return appqt.exec_()


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