"""Batch navigator: JFrame panel for reviewing p63+ results across all images in a run."""

import os

from javax.swing import (
    JFrame, JTable, JScrollPane, JPanel, JButton, JLabel, JSlider,
    ButtonGroup, JToggleButton, JComboBox, BorderFactory, SwingUtilities,
    WindowConstants, ListSelectionModel, AbstractAction, JComponent, KeyStroke,
)
from javax.swing.table import DefaultTableModel
from javax.swing.event import ListSelectionListener, ChangeListener
from java.awt import (
    BorderLayout, FlowLayout, GridBagLayout, GridBagConstraints,
    Dimension, Insets, GraphicsEnvironment,
)
from java.awt.event import (
    ActionListener, WindowAdapter, MouseListener, KeyAdapter, KeyEvent,
)
from java.util import Hashtable
from java.util.concurrent import CountDownLatch
from java.lang import Runnable

from ij import IJ, CompositeImage
from ij.process import ImageConverter

import display
import review


# Outline draw modes, in the order the 'O' hotkey cycles them. Index positions
# must match the _cmb_outlines combo box items.
_OUTLINE_MODES = ("all", "positive", "none")

# Padding between the tiled windows, in pixels.
_WIN_GAP = 8


def _usable_screen():
    """Screen rectangle excluding the taskbar / menu bar / dock."""
    env = GraphicsEnvironment.getLocalGraphicsEnvironment()
    return env.getMaximumWindowBounds()


class _TableModel(DefaultTableModel):
    """Read-only DefaultTableModel -- cells are never editable."""
    def isCellEditable(self, row, col):
        return False


class _CellTable(object):
    """Floating JFrame showing per-cell results for the currently-selected image."""

    def __init__(self, on_row_selected):
        self._on_row_selected = on_row_selected
        self._rois    = []   # list of (roi, mean_255) for current image
        self._updating = False

        self._model  = _TableModel(["Cell", "Red Mean", "p63+"], 0)
        self._jtable = JTable(self._model)
        self._jtable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
        self._jtable.setRowHeight(18)
        self._jtable.setAutoResizeMode(JTable.AUTO_RESIZE_LAST_COLUMN)
        self._jtable.getColumnModel().getColumn(0).setPreferredWidth(55)
        self._jtable.getColumnModel().getColumn(1).setPreferredWidth(80)
        self._jtable.getColumnModel().getColumn(2).setPreferredWidth(45)

        ct = self
        class _SelListener(ListSelectionListener):
            def valueChanged(self, e):
                if e.getValueIsAdjusting() or ct._updating:
                    return
                row = ct._jtable.getSelectedRow()
                if row >= 0:
                    ct._on_row_selected(row)

        self._jtable.getSelectionModel().addListSelectionListener(_SelListener())

        scroll = JScrollPane(self._jtable)
        scroll.setPreferredSize(Dimension(220, 420))

        self._frame = JFrame("p63 Cell Results")
        self._frame.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE)
        self._frame.setContentPane(scroll)
        self._frame.pack()

    def show(self):
        self._frame.setVisible(True)
        self._frame.toFront()

    def update_all(self, rois, labels, threshold):
        """Rebuild table rows for a new image."""
        self._rois = rois
        self._updating = True
        try:
            self._model.setRowCount(0)
            for i, (roi, mean) in enumerate(rois):
                lbl = labels[i] if i < len(labels) else (i + 1)
                self._model.addRow([
                    str(lbl),
                    "{:.1f}".format(mean),
                    "+" if mean >= threshold else "-",
                ])
        finally:
            self._updating = False
        self._jtable.clearSelection()

    def update_p63_col(self, threshold):
        """Update the p63+ column in-place without disturbing the row selection."""
        self._updating = True
        try:
            for i, (roi, mean) in enumerate(self._rois):
                self._model.setValueAt("+" if mean >= threshold else "-", i, 2)
        finally:
            self._updating = False

    def select_row(self, idx):
        """Programmatically highlight row idx and scroll to it."""
        self._updating = True
        try:
            self._jtable.setRowSelectionInterval(idx, idx)
            self._jtable.scrollRectToVisible(
                self._jtable.getCellRect(idx, 0, True)
            )
        finally:
            self._updating = False


class _Navigator(object):
    """Swing JFrame that lists all images and provides per-image threshold review."""

    def __init__(self, summary_rows, cells_by_image, image_map, output_dir,
                 variant_str, threshold_col, seg_channel, quant_channel,
                 results_table, latch):
        self._summary_rows    = summary_rows
        self._cells_by_image  = cells_by_image
        self._image_map       = image_map
        self._output_dir      = output_dir
        self._variant_str     = variant_str
        self._seg_channel     = seg_channel
        self._quant_channel   = quant_channel
        self._results_table   = results_table
        self._latch           = latch

        self._image_names = [r["image"] for r in summary_rows]

        # Seed per-image thresholds from summary.csv
        self._otsu_thresholds    = {}
        self._current_thresholds = {}
        for row in summary_rows:
            name = row["image"]
            try:
                t = float(row[threshold_col])
            except (KeyError, ValueError):
                t = 128.0
            self._otsu_thresholds[name]    = t
            self._current_thresholds[name] = t

        # Runtime state
        self._current_image    = None
        self._current_imp      = None
        self._channel_mode     = "merge"
        self._outline_mode     = "all"  # "all" | "positive" | "none"; persists across images
        self._roi_cache        = {}
        self._label_cache      = {}
        self._accepted_images  = set()
        self._updating_slider  = False
        self._updating_outlines = False
        self._image_win_loc    = None   # (x, y) once the user drags the image window
        # Default top-left corner for the image window, between the two side
        # columns; set by _position_frames.
        self._image_win_home = (0, 0)
        self._cell_table       = None   # created in _build_ui

        self._build_ui()

    # ------------------------------------------------------------------
    # UI construction
    # ------------------------------------------------------------------

    def _build_ui(self):
        # ---- Table ----
        col_names = ["Image", "Cells", "p63+ %", "Threshold", "Status"]
        self._table_model = _TableModel(col_names, 0)
        vl = self._variant_str.lower()
        for row in self._summary_rows:
            name    = row["image"]
            n_cells = row.get("n_cells", "?")
            try:
                frac    = float(row.get(vl + "_p63_fraction", 0.0)) * 100.0
                frac_s  = "{:.1f}%".format(frac)
            except (ValueError, TypeError):
                frac_s  = "?"
            t = self._otsu_thresholds.get(name, 128.0)
            self._table_model.addRow([name, n_cells, frac_s, "{:.1f}".format(t), "Pending"])

        self._table = JTable(self._table_model)
        self._table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
        self._table.setRowHeight(20)
        self._table.setAutoResizeMode(JTable.AUTO_RESIZE_LAST_COLUMN)
        self._table.getColumnModel().getColumn(0).setPreferredWidth(220)
        self._table.getColumnModel().getColumn(1).setPreferredWidth(50)
        self._table.getColumnModel().getColumn(2).setPreferredWidth(70)
        self._table.getColumnModel().getColumn(3).setPreferredWidth(80)
        self._table.getColumnModel().getColumn(4).setPreferredWidth(80)

        n_visible = min(len(self._image_names), 15)
        scroll = JScrollPane(self._table)
        scroll.setPreferredSize(Dimension(560, max(80, self._table.getRowHeight() * n_visible + 30)))

        # ---- Channel toggle buttons ----
        self._btn_red   = JToggleButton("Red (p63)")
        self._btn_blue  = JToggleButton("Blue (Nuclei)")
        self._btn_merge = JToggleButton("Merge")
        self._btn_merge.setSelected(True)
        grp = ButtonGroup()
        grp.add(self._btn_red)
        grp.add(self._btn_blue)
        grp.add(self._btn_merge)

        # ---- Outline visibility ----
        # Index order must match _OUTLINE_MODES.
        self._cmb_outlines = JComboBox()
        for _mode_label in ("All", "p63+ only", "None"):
            self._cmb_outlines.addItem(_mode_label)
        self._cmb_outlines.setSelectedIndex(0)
        self._cmb_outlines.setToolTipText("Which cell outlines to draw (press O to cycle)")

        # ---- Threshold slider ----
        # Internal range is *100 (0-25500) so the value represents a float
        # 0.00-255.00 with 0.01 precision. Tick labels are remapped to show
        # the real 0-255 scale.
        self._slider = JSlider(0, 25500, 12800)
        self._slider.setMajorTickSpacing(6400)
        self._slider.setMinorTickSpacing(1600)
        self._slider.setPaintTicks(True)
        self._slider.setPaintLabels(True)
        self._slider.setPreferredSize(Dimension(300, 50))
        self._slider.setEnabled(False)
        _tick_labels = Hashtable()
        for _v, _lbl in [(0, "0"), (6400, "64"), (12800, "128"),
                         (19200, "192"), (25500, "255")]:
            _tick_labels.put(_v, JLabel(_lbl))
        self._slider.setLabelTable(_tick_labels)

        self._count_label = JLabel("Select an image above to begin")

        self._btn_reset      = JButton("Reset to Otsu")
        self._btn_reset.setEnabled(False)
        self._btn_accept     = JButton("Accept for this image")
        self._btn_accept.setEnabled(False)
        self._btn_accept_all = JButton("Accept All & Close")

        # ---- Wire listeners ----
        nav = self

        class _TableListener(ListSelectionListener):
            def valueChanged(self, e):
                if e.getValueIsAdjusting():
                    return
                row = nav._table.getSelectedRow()
                if row >= 0:
                    nav._select_image(nav._image_names[row])

        class _SliderListener(ChangeListener):
            def stateChanged(self, e):
                if nav._updating_slider or nav._current_image is None:
                    return
                nav._apply_threshold(nav._slider.getValue() / 100.0)

        # Three separate listener classes avoid the Jython Java-proxy issue that
        # arises when an interface implementor has a custom __init__ with args.
        class _RedListener(ActionListener):
            def actionPerformed(self, e):
                nav._channel_mode = "red"
                nav._apply_channel_mode()

        class _BlueListener(ActionListener):
            def actionPerformed(self, e):
                nav._channel_mode = "blue"
                nav._apply_channel_mode()

        class _MergeListener(ActionListener):
            def actionPerformed(self, e):
                nav._channel_mode = "merge"
                nav._apply_channel_mode()

        class _OutlineListener(ActionListener):
            def actionPerformed(self, e):
                if nav._updating_outlines:
                    return
                idx = nav._cmb_outlines.getSelectedIndex()
                if idx >= 0:
                    nav._set_outline_mode(_OUTLINE_MODES[idx])

        # Swing Action backing the 'O' hotkey on the navigator / cell-table frames.
        class _OutlineAction(AbstractAction):
            def actionPerformed(self, e):
                nav._cycle_outline_mode()

        class _ResetListener(ActionListener):
            def actionPerformed(self, e):
                if nav._current_image is None:
                    return
                otsu = nav._otsu_thresholds.get(nav._current_image, 128.0)
                nav._updating_slider = True
                nav._slider.setValue(int(round(otsu * 100)))
                nav._updating_slider = False
                nav._apply_threshold(otsu)

        class _AcceptListener(ActionListener):
            def actionPerformed(self, e):
                if nav._current_image is not None:
                    nav._accept_image(nav._current_image, nav._slider.getValue() / 100.0)

        class _AcceptAllListener(ActionListener):
            def actionPerformed(self, e):
                nav._accept_all_and_close()

        class _CloseListener(WindowAdapter):
            def windowClosing(self, e):
                nav._accept_all_and_close()

        self._slider_listener = _SliderListener()
        self._table.getSelectionModel().addListSelectionListener(_TableListener())
        self._slider.addChangeListener(self._slider_listener)
        self._btn_red.addActionListener(_RedListener())
        self._btn_blue.addActionListener(_BlueListener())
        self._btn_merge.addActionListener(_MergeListener())
        self._cmb_outlines.addActionListener(_OutlineListener())
        self._btn_reset.addActionListener(_ResetListener())
        self._btn_accept.addActionListener(_AcceptListener())
        self._btn_accept_all.addActionListener(_AcceptAllListener())

        # ---- Layout ----
        channel_panel = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2))
        channel_panel.add(JLabel("Channel:"))
        channel_panel.add(self._btn_red)
        channel_panel.add(self._btn_blue)
        channel_panel.add(self._btn_merge)
        channel_panel.add(JLabel("  Outlines:"))
        channel_panel.add(self._cmb_outlines)

        slider_panel = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2))
        slider_panel.add(JLabel("Threshold (0-255):"))
        slider_panel.add(self._slider)

        count_panel = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2))
        count_panel.add(self._count_label)
        count_panel.add(self._btn_reset)

        action_panel = JPanel(FlowLayout(FlowLayout.RIGHT, 4, 2))
        action_panel.add(self._btn_accept)
        action_panel.add(self._btn_accept_all)

        ctrl_panel = JPanel(GridBagLayout())
        ctrl_panel.setBorder(BorderFactory.createEmptyBorder(6, 4, 4, 4))
        gbc = GridBagConstraints()
        gbc.gridx   = 0
        gbc.fill    = GridBagConstraints.HORIZONTAL
        gbc.weightx = 1.0
        gbc.insets  = Insets(1, 0, 1, 0)
        for idx, panel in enumerate([channel_panel, slider_panel, count_panel, action_panel]):
            gbc.gridy = idx
            ctrl_panel.add(panel, gbc)

        main_panel = JPanel(BorderLayout())
        main_panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8))
        main_panel.add(scroll, BorderLayout.CENTER)
        main_panel.add(ctrl_panel, BorderLayout.SOUTH)

        self._frame = JFrame("p63 Batch Navigator")
        self._frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE)
        self._frame.addWindowListener(_CloseListener())
        self._frame.setContentPane(main_panel)
        self._frame.pack()

        self._cell_table = _CellTable(self._on_results_row_selected)
        self._position_frames()

        # 'O' cycles outline modes whenever the navigator or the cell table has focus.
        # WHEN_IN_FOCUSED_WINDOW fires regardless of which child widget is focused.
        _outline_action = _OutlineAction()
        for _rp in (self._frame.getRootPane(), self._cell_table._frame.getRootPane()):
            _im = _rp.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
            _im.put(KeyStroke.getKeyStroke("pressed O"), "p63ToggleOutlines")
            _im.put(KeyStroke.getKeyStroke("shift pressed O"), "p63ToggleOutlines")
            _rp.getActionMap().put("p63ToggleOutlines", _outline_action)

    # ------------------------------------------------------------------
    # Threshold / outline display
    # ------------------------------------------------------------------

    def _apply_threshold(self, t):
        """Recolor ROIs at threshold t (honoring the outline toggle) and refresh the labels."""
        if self._current_imp is None or self._current_image is None:
            return
        rois = self._roi_cache.get(self._current_image, [])
        n_pos, n_neg = review._recolor_rois(
            rois, t, None, self._current_imp, self._outline_mode)
        self._count_label.setText(
            "p63+: {}  p63-: {}  (T={:.2f})".format(n_pos, n_neg, t))
        self._cell_table.update_p63_col(t)

    def _set_outline_mode(self, mode):
        """Set which outlines are drawn; keeps the combo box and the hotkey in sync."""
        if mode not in _OUTLINE_MODES:
            mode = "all"
        self._outline_mode = mode
        idx = _OUTLINE_MODES.index(mode)
        if self._cmb_outlines.getSelectedIndex() != idx:
            self._updating_outlines = True
            try:
                self._cmb_outlines.setSelectedIndex(idx)
            finally:
                self._updating_outlines = False
        self._apply_threshold(self._slider.getValue() / 100.0)

    def _cycle_outline_mode(self):
        """'O' hotkey: step All -> p63+ only -> None -> All."""
        nxt = (_OUTLINE_MODES.index(self._outline_mode) + 1) % len(_OUTLINE_MODES)
        self._set_outline_mode(_OUTLINE_MODES[nxt])

    def _forward_to_ij(self, method, e):
        """Hand a key event back to ImageJ's own listener (we detached it to claim 'O')."""
        ij_inst = IJ.getInstance()
        if ij_inst is None:
            return
        try:
            getattr(ij_inst, method)(e)
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Window placement
    # ------------------------------------------------------------------

    def _position_frames(self):
        """Tile the two Swing frames into side columns, reserving the middle for the image.

        Layout: navigator on the left, image window in the middle, cell results
        on the right. The cell list is tall and narrow, so it gets a column of
        its own rather than being stacked under the navigator.
        Both frames are already packed here, so getWidth/getHeight are final.
        """
        screen = _usable_screen()
        nav_x  = screen.x + _WIN_GAP
        nav_y  = screen.y + _WIN_GAP
        self._frame.setLocation(nav_x, nav_y)

        # Cell results: right edge, keeping its packed size.
        ct   = self._cell_table._frame
        ct_x = screen.x + screen.width - ct.getWidth() - _WIN_GAP
        ct.setLocation(ct_x, screen.y + _WIN_GAP)

        # The image window opens in the gap between the two columns, at
        # whatever zoom ImageJ picks for it.
        self._image_win_home = (
            nav_x + self._frame.getWidth() + _WIN_GAP,
            screen.y + _WIN_GAP,
        )

    def _place_image_window(self, imp):
        """Pin the image window between the two columns, or wherever the user last dragged it.

        ImageJ cascades each new ImageWindow by default, which would make the
        image jump on every batch step. Setting the location after show() wins.
        """
        win = imp.getWindow()
        if win is None:
            return
        if self._image_win_loc is not None:
            x, y = self._image_win_loc
        else:
            screen = _usable_screen()
            x, y = self._image_win_home
            # Keep it on screen -- at 100% zoom a large image can be very wide.
            x = min(x, screen.x + screen.width - win.getWidth() - _WIN_GAP)
            x = max(x, screen.x)
        win.setLocation(int(x), int(y))

    # ------------------------------------------------------------------
    # Image selection
    # ------------------------------------------------------------------

    def _select_image(self, name):
        # Disable controls during load
        self._slider.setEnabled(False)
        self._btn_reset.setEnabled(False)
        self._btn_accept.setEnabled(False)
        self._count_label.setText("Loading {} ...".format(name))

        # Close previous image window
        if self._current_imp is not None:
            prev = self._current_imp
            # Carry the window position forward so a window the user dragged
            # somewhere stays there for the rest of the batch.
            try:
                prev_win = prev.getWindow()
                if prev_win is not None:
                    self._image_win_loc = (prev_win.getX(), prev_win.getY())
            except Exception:
                pass
            self._current_imp = None
            prev.close()

        self._current_image = name
        orig_path = self._image_map.get(name)
        mask_path = os.path.join(self._output_dir, name + "_cp_masks.tif")

        if not orig_path or not os.path.isfile(str(orig_path)):
            self._count_label.setText("[WARN] Image file not found: " + name)
            return
        if not os.path.isfile(mask_path):
            self._count_label.setText("[WARN] Mask not found for: " + name)
            return

        imp = IJ.openImage(str(orig_path))
        if imp is None:
            self._count_label.setText("[WARN] Could not open image: " + name)
            return
        if imp.getBitDepth() == 24:
            # Convert 24-bit RGB to a 3-channel 8-bit composite so setMode/setC
            # work. convertRGBtoRGBStack modifies imp in place (R=ch1, G=ch2,
            # B=ch3), matching quantChannel=1 (p63/red) and segChannel=3 (Nuclei).
            ImageConverter(imp).convertToRGBStack()
            imp.setDimensions(3, 1, 1)
            imp = CompositeImage(imp, CompositeImage.COMPOSITE)
        elif imp.getNChannels() > 1 and not isinstance(imp, CompositeImage):
            imp = CompositeImage(imp, CompositeImage.COMPOSITE)
        # Assign LUTs so single-channel COLOR mode shows the right hue.
        # ch1=Red (p63), ch2=Green, ch3=Blue (Nuclei).
        try:
            imp.setC(1); IJ.run(imp, "Red", "")
            imp.setC(2); IJ.run(imp, "Green", "")
            imp.setC(3); IJ.run(imp, "Blue", "")
            imp.setC(1)
        except Exception:
            pass
        self._current_imp = imp

        # Build ROI list (lazy; cached after first load)
        if name not in self._roi_cache:
            try:
                mask_imp = display.load_mask(mask_path)
                rois_with_labels = display.masks_to_rois(mask_imp)
                mask_imp.close()
            except Exception as exc:
                self._count_label.setText("[WARN] ROI load failed for {}: {}".format(name, str(exc)))
                return
            cells = self._cells_by_image.get(name, [])
            mean_by_label = {int(c["cell_label"]): float(c["red_mean"]) for c in cells}
            self._label_cache[name] = [lbl for lbl, roi in rois_with_labels]
            self._roi_cache[name] = [
                (roi, mean_by_label.get(lbl, 0.0))
                for lbl, roi in rois_with_labels
            ]

        rois = self._roi_cache[name]
        t = self._current_thresholds.get(name, 128.0)

        # Update slider without firing the change listener
        self._updating_slider = True
        self._slider.setValue(int(round(t * 100)))
        self._slider.setEnabled(True)
        self._updating_slider = False

        imp.show()
        self._place_image_window(imp)
        self._apply_channel_mode()

        # Attach a mouse listener so clicking a cell highlights its table row.
        _nav_ref = self
        class _CanvasListener(MouseListener):
            def mouseClicked(self, e):
                src = e.getSource()
                _nav_ref._on_canvas_click(
                    src.offScreenX(e.getX()),
                    src.offScreenY(e.getY()),
                )
            def mousePressed(self, e): pass
            def mouseReleased(self, e): pass
            def mouseEntered(self, e): pass
            def mouseExited(self, e): pass
        canvas = imp.getCanvas()
        if canvas is not None:
            canvas.addMouseListener(_CanvasListener())

        # 'O' also cycles outline modes over the image window. ImageJ maps bare 'O'
        # to File>Open (and shift+O to Open Next) unless the user requires the
        # control key, and consume() does NOT stop other AWT listeners -- so we
        # detach ImageJ's own key listener, handle 'O' ourselves, and forward
        # every other key back to it so all normal IJ shortcuts keep working.
        class _ImageKeyListener(KeyAdapter):
            def keyPressed(self, e):
                if e.getKeyCode() == KeyEvent.VK_O and not e.isControlDown() \
                        and not e.isMetaDown() and not e.isAltDown():
                    e.consume()
                    _nav_ref._cycle_outline_mode()
                    return
                _nav_ref._forward_to_ij("keyPressed", e)
            def keyReleased(self, e):
                _nav_ref._forward_to_ij("keyReleased", e)
            def keyTyped(self, e):
                _nav_ref._forward_to_ij("keyTyped", e)

        try:
            ij_inst = IJ.getInstance()
            win = imp.getWindow()
            key_listener = _ImageKeyListener()
            for comp in (canvas, win):
                if comp is None:
                    continue
                if ij_inst is not None:
                    comp.removeKeyListener(ij_inst)
                comp.addKeyListener(key_listener)
        except Exception as exc:
            # Not fatal -- the navigator/cell-table 'O' binding still works.
            IJ.log("[WARN] Could not bind outline hotkey to image window: {}".format(str(exc)))

        labels = self._label_cache.get(name, [])
        self._cell_table.update_all(rois, labels, t)
        self._cell_table.show()

        # After update_all, so update_p63_col has rows to write into.
        self._apply_threshold(t)

        self._btn_reset.setEnabled(True)
        self._btn_accept.setEnabled(True)

    # ------------------------------------------------------------------
    # Channel display
    # ------------------------------------------------------------------

    def _apply_channel_mode(self):
        imp = self._current_imp
        if imp is None:
            return
        n_ch = imp.getNChannels()
        if n_ch < 2:
            return
        if self._channel_mode == "merge":
            try:
                imp.setMode(CompositeImage.COMPOSITE)
            except Exception:
                pass
        else:
            ch = self._quant_channel if self._channel_mode == "red" else self._seg_channel
            ch = min(max(ch, 1), n_ch)
            try:
                imp.setMode(CompositeImage.COLOR)
            except Exception:
                pass
            imp.setC(ch)
        imp.updateAndDraw()

    # ------------------------------------------------------------------
    # Bidirectional selection helpers
    # ------------------------------------------------------------------

    def _on_results_row_selected(self, row):
        """Called when the user clicks a row in the cell table; highlights that ROI."""
        if self._current_imp is None or self._current_image is None:
            return
        rois = self._roi_cache.get(self._current_image, [])
        if row < len(rois):
            self._current_imp.setRoi(rois[row][0])

    def _on_canvas_click(self, img_x, img_y):
        """Called when the user clicks the image canvas; selects the matching table row."""
        name = self._current_image
        if name is None:
            return
        rois = self._roi_cache.get(name, [])
        for idx, (roi, _mean) in enumerate(rois):
            if roi.contains(img_x, img_y):
                self._cell_table.select_row(idx)
                break

    # ------------------------------------------------------------------
    # Accept / save
    # ------------------------------------------------------------------

    def _accept_image(self, name, final_t):
        otsu = self._otsu_thresholds.get(name, 128.0)
        was_manual = abs(float(final_t) - otsu) > 0.5
        cells = self._cells_by_image.get(name, [])
        review._write_final_calls(cells, final_t, self._output_dir, was_manual, self._variant_str)
        self._current_thresholds[name] = float(final_t)
        self._accepted_images.add(name)
        for i, iname in enumerate(self._image_names):
            if iname == name:
                self._table_model.setValueAt("Reviewed", i, 4)
                self._table_model.setValueAt("{:.1f}".format(float(final_t)), i, 3)
                break

    def _accept_all_and_close(self):
        # Collect all unreviewed images and write both CSVs in a single pass.
        pending_thresholds = {}
        pending_manual     = {}
        for name in self._image_names:
            if name not in self._accepted_images:
                t    = self._current_thresholds.get(name, self._otsu_thresholds.get(name, 128.0))
                otsu = self._otsu_thresholds.get(name, 128.0)
                pending_thresholds[name] = t
                pending_manual[name]     = abs(t - otsu) > 0.5
        if pending_thresholds:
            try:
                review._write_final_calls_batch(
                    self._cells_by_image, pending_thresholds,
                    self._output_dir, pending_manual, self._variant_str,
                )
            except Exception as exc:
                IJ.log("[WARN] Could not save thresholds: {}".format(str(exc)))
        if self._current_imp is not None:
            self._current_imp.close()
            self._current_imp = None
        if self._cell_table is not None:
            self._cell_table._frame.dispose()
        self._frame.dispose()
        self._latch.countDown()


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

def show_navigator(summary_rows, cells_by_image, image_map, output_dir,
                   variant_str, threshold_col, seg_channel, quant_channel,
                   results_table):
    """Create and show the batch navigator JFrame; block until user closes or accepts all.

    summary_rows   -- list of dicts from read_summary_csv
    cells_by_image -- {image_name: [cell_row_dicts]} pre-grouped from read_cells_csv
    image_map      -- {basename: absolute_path_string} for original images
    output_dir     -- string path to output directory
    seg_channel    -- 1-based int (blue/Nuclei channel)
    quant_channel  -- 1-based int (red/p63 channel)
    results_table  -- ImageJ ResultsTable populated with all cells
    """
    if not summary_rows:
        return

    latch = CountDownLatch(1)
    nav_holder = [None]

    class _Build(Runnable):
        def run(self):
            nav = _Navigator(
                summary_rows, cells_by_image, image_map, output_dir,
                variant_str, threshold_col, seg_channel, quant_channel,
                results_table, latch,
            )
            nav_holder[0] = nav
            nav._frame.setVisible(True)

    SwingUtilities.invokeLater(_Build())
    latch.await()
