#!/usr/bin/env jython

# ===========================================
# hotkey-c.py
# Hotkey 配置工具（Job: Start / Step / Stop）
# - 基于 MainFrame.getHotkeyActionMap() 做自定义绑定
# - 将配置持久化到 script-state.xml 的 key：
#     hotkey.job.start
#     hotkey.job.step
#     hotkey.job.stop
# - 启动时会自动加载并应用上次保存的按键
# - 应用新热键时会移除旧的 Start/Step/Stop 绑定
# - 空输入表示禁用该动作热键
# - 检查重复键并防止与其他已存在绑定冲突
# ===========================================

from javax.swing import JDialog, JPanel, JLabel, JTextField, JButton, BoxLayout, JOptionPane, Box, JTextArea, JScrollPane, JFileChooser
from java.awt import BorderLayout
from java.awt.event import KeyAdapter, KeyEvent
from javax.swing import SwingUtilities, KeyStroke
from java.io import FileReader, BufferedReader, FileWriter, BufferedWriter, IOException, File
import java.lang
import time

try:
    from org.openpnp.model import Configuration
    from org.openpnp.gui import MainFrame
except Exception:
    Configuration = None
    MainFrame = None

# script-state keys
_STATE_KEYS = {
    "start": "hotkey.job.start",
    "step": "hotkey.job.step",
    "stop": "hotkey.job.stop",
}


def _get_mainframe():
    try:
        return MainFrame.get()
    except Exception:
        return None


def _get_job_panel(frame):
    if frame is None:
        return None
    # Try several access patterns for compatibility
    try:
        return frame.getJobPanel()
    except Exception:
        pass
    try:
        return frame.getJobTab()
    except Exception:
        pass
    try:
        # try field access
        return frame.jobPanel
    except Exception:
        pass
    return None


def _get_hotkey_map(frame):
    try:
        return frame.getHotkeyActionMap()
    except Exception:
        return None


def _get_action_map(frame):
    job_panel = _get_job_panel(frame)
    if job_panel is None:
        return None
    try:
        return {
            "start": job_panel.startPauseResumeJobAction,
            "step": job_panel.stepJobAction,
            "stop": job_panel.stopJobAction,
        }
    except Exception:
        return None


def _find_keystroke_for_action(hotkey_map, action):
    if hotkey_map is None or action is None:
        return None
    try:
        entries = list(hotkey_map.entrySet())
    except Exception:
        return None
    for entry in entries:
        try:
            if entry.getValue() == action:
                return entry.getKey()
        except Exception:
            pass
    return None


def _normalize_keystroke(text):
    if text is None:
        return None
    s = text.strip()
    if not s:
        return None
    try:
        ks = KeyStroke.getKeyStroke(s)
    except Exception:
        ks = None
    if ks is None:
        return None
    try:
        return ks.toString()
    except Exception:
        return None


def _keystroke_from_event(evt):
    try:
        return KeyStroke.getKeyStrokeForEvent(evt)
    except Exception:
        return None


def _keystroke_to_text(ks):
    if ks is None:
        return None
    try:
        return ks.toString()
    except Exception:
        return None


def _load_saved_hotkeys(cfg):
    saved = {}
    try:
        state = cfg.scriptState
    except Exception:
        state = None
    for k, state_key in _STATE_KEYS.items():
        v = None
        try:
            if state is not None and state.containsKey(state_key):
                v = state.get(state_key)
        except Exception:
            v = None
        saved[k] = v
    return saved


def _save_hotkeys(cfg, mapping):
    try:
        state = cfg.scriptState
    except Exception:
        state = None
    if state is None:
        return False
    for k, state_key in _STATE_KEYS.items():
        val = mapping.get(k)
        try:
            if val:
                state.put(state_key, val)
            else:
                if state.containsKey(state_key):
                    state.remove(state_key)
        except Exception:
            pass
    try:
        cfg.save()
    except Exception:
        try:
            frame = _get_mainframe()
            if frame is not None:
                frame.saveConfig()
        except Exception:
            pass
    return True


def _check_conflicts(hotkey_map, new_mapping, own_actions):
    # new_mapping: dict name->normalized string or None
    # own_actions: set of Action instances that belong to Start/Step/Stop
    # Check duplicates in new_mapping
    values = [v for v in [new_mapping.get('start'), new_mapping.get('step'), new_mapping.get('stop')] if v]
    if len(values) != len(set(values)):
        return (False, "Duplicate hotkeys among Start/Step/Stop")
    # Check conflict with existing bindings (keys used by other actions)
    try:
        entries = list(hotkey_map.entrySet())
    except Exception:
        entries = []
    for name, val in new_mapping.items():
        if not val:
            continue
        try:
            ks = KeyStroke.getKeyStroke(val)
        except Exception:
            return (False, "Invalid keystroke: %s" % val)
        for entry in entries:
            try:
                existingKs = entry.getKey()
                existingAction = entry.getValue()
                if existingKs is None:
                    continue
                # If same keystroke and existingAction is not one of our job actions, it's a conflict
                if existingKs.equals(ks) and (existingAction not in own_actions):
                    return (False, "Conflict: key %s already bound to another action" % val)
            except Exception:
                pass
    return (True, None)


def _apply_hotkeys(frame, action_map, mapping):
    hotkey_map = _get_hotkey_map(frame)
    if hotkey_map is None:
        return False
    # gather our actions
    own_actions = set()
    for k in ('start', 'step', 'stop'):
        try:
            a = action_map.get(k)
            if a is not None:
                own_actions.add(a)
        except Exception:
            pass
    # remove existing entries that map to our actions
    try:
        entries = list(hotkey_map.entrySet())
    except Exception:
        entries = []
    for entry in entries:
        try:
            if entry.getValue() in own_actions:
                try:
                    hotkey_map.remove(entry.getKey())
                except Exception:
                    pass
        except Exception:
            pass
    # put new ones
    for key_name, action in action_map.items():
        txt = mapping.get(key_name)
        if txt:
            try:
                ks = KeyStroke.getKeyStroke(txt)
            except Exception:
                ks = None
            if ks is not None:
                try:
                    hotkey_map.put(ks, action)
                except Exception:
                    pass
    return True


def _build_used_hotkeys_text(hotkey_map, action_map=None):
    """返回当前 hotkeyActionMap 的可读文本，用于在对话框中展示已被占用的按键组合。"""
    try:
        entries = list(hotkey_map.entrySet())
    except Exception:
        return "(No hotkeys available)"
    lines = []
    for entry in entries:
        try:
            ks = entry.getKey()
            act = entry.getValue()
            try:
                ks_text = ks.toString() if ks is not None else "<none>"
            except Exception:
                ks_text = str(ks)
            # 尝试将 action 映射到友好名字（若为 job actions 则显示 start/step/stop）
            friendly = None
            if action_map is not None:
                for k, a in action_map.items():
                    try:
                        if a == act:
                            friendly = "job.%s" % k
                            break
                    except Exception:
                        pass
            if friendly is None:
                try:
                    friendly = str(act)
                except Exception:
                    friendly = "<action>"
            lines.append('%s -> %s' % (ks_text, friendly))
        except Exception:
            pass
    if not lines:
        return "(No hotkeys bound)"
    return "\n".join(lines)


# 已知默认快捷键说明（静态列表）
_DEFAULT_KNOWN_SHORTCUTS = [
    ("Ctrl+H", "Home the machine."),
    ("Ctrl+Arrow Key", "Jog the currently selected Nozzle in X and Y. Up/Down -> Y, Left/Right -> X."),
    ("Ctrl+/, Ctrl+'", "Jog nozzle down/up in Z."),
    ("Ctrl+<, Ctrl+>", "Rotate nozzle counter-clockwise/clockwise."),
    ("Ctrl+Plus, Ctrl+Minus", "Change jog distance slider."),
    ("Shift+Left Mouse Click", "Move camera to clicked position in camera view."),
    ("Ctrl-Shift-R", "Start a job."),
    ("Ctrl-Shift-S", "Step through job."),
    ("Ctrl-Shift-A", "Stop job."),
    ("Ctrl-Shift-P", "Park head (Z retract then XY park)."),
    ("Ctrl-Shift-L", "Park Z axis only."),
    ("Ctrl-Shift-Z", "Move head to safe Z."),
    ("Ctrl-Shift-D", "Discard component."),
    ("Ctrl-Shift-F1..F5", "Preset jog increments."),
    ("Modifier+O/N/S/E/`", "Open/New/Save/Enable/Disable/Home (system modifier: Ctrl on Win/Linux, Cmd on Mac)."),
]


def _build_known_shortcuts_text(hotkey_map, action_map=None):
    """构建展示文本：先显示内置已知快捷键及说明，再显示当前 hotkeyActionMap 中的实际绑定，便于比对。"""
    parts = []
    parts.append("Known builtin shortcuts:\n")
    for k, d in _DEFAULT_KNOWN_SHORTCUTS:
        parts.append("%s : %s" % (k, d))
    parts.append("\nCurrently bound shortcuts from hotkeyActionMap:\n")
    try:
        entries = list(hotkey_map.entrySet())
    except Exception:
        entries = []
    for entry in entries:
        try:
            ks = entry.getKey()
            act = entry.getValue()
            try:
                ks_text = ks.toString() if ks is not None else "<none>"
            except Exception:
                ks_text = str(ks)
            friendly = None
            if action_map is not None:
                for k, a in action_map.items():
                    try:
                        if a == act:
                            friendly = "job.%s" % k
                            break
                    except Exception:
                        pass
            if friendly is None:
                try:
                    friendly = str(act)
                except Exception:
                    friendly = "<action>"
            parts.append('%s -> %s' % (ks_text, friendly))
        except Exception:
            pass
    return "\n".join(parts)


class HotkeyDialog(object):
    def __init__(self):
        self.frame = _get_mainframe()
        self.cfg = Configuration.get() if Configuration is not None else None
        self.action_map = _get_action_map(self.frame) if self.frame is not None else None
        self.saved = _load_saved_hotkeys(self.cfg) if self.cfg is not None else {}
        self.saved_action_keys = _load_saved_action_hotkeys(self.cfg) if self.cfg is not None else {}

        self.dialog = JDialog()
        self.dialog.setModal(False)
        self.dialog.setTitle("Hotkeys - all actions (hotkey-c)")
        self.dialog.setSize(700, 600)
        self.dialog.setLocationRelativeTo(None)
        self.dialog.setLayout(BorderLayout())

        main_panel = JPanel()
        main_panel.setLayout(BoxLayout(main_panel, BoxLayout.Y_AXIS))

        hint = JLabel("Click a field and press the hotkey to record. Empty = disable. Conflicts will be detected.")
        main_panel.add(hint)

        # area showing known builtins and current map
        try:
            self.usedArea = JTextArea()
            self.usedArea.setEditable(False)
            self.usedArea.setLineWrap(False)
            scroll_info = JScrollPane(self.usedArea)
            scroll_info.setPreferredSize(scroll_info.getPreferredSize())
            main_panel.add(Box.createVerticalStrut(6))
            main_panel.add(scroll_info)
        except Exception:
            self.usedArea = None

        # dynamic action list area
        try:
            self.listPanel = JPanel()
            self.listPanel.setLayout(BoxLayout(self.listPanel, BoxLayout.Y_AXIS))
            self.listScroll = JScrollPane(self.listPanel)
            self.listScroll.setPreferredSize(self.listScroll.getPreferredSize())
            main_panel.add(Box.createVerticalStrut(6))
            main_panel.add(self.listScroll)
        except Exception:
            self.listPanel = None
            self.listScroll = None

        # buttons
        btn_panel = JPanel()
        btn_panel.setLayout(BoxLayout(btn_panel, BoxLayout.X_AXIS))
        self.btn_apply = JButton("Apply")
        self.btn_save_all = JButton("Save & Persist")
        self.btn_close = JButton("Close")
        # new buttons: Restore Defaults, Export, Import
        self.btn_restore = JButton("Restore Defaults")
        self.btn_export = JButton("Export")
        self.btn_import = JButton("Import")
        btn_panel.add(self.btn_apply)
        btn_panel.add(Box.createHorizontalStrut(8))
        btn_panel.add(self.btn_save_all)
        btn_panel.add(Box.createHorizontalStrut(8))
        btn_panel.add(self.btn_restore)
        btn_panel.add(Box.createHorizontalStrut(8))
        btn_panel.add(self.btn_export)
        btn_panel.add(Box.createHorizontalStrut(8))
        btn_panel.add(self.btn_import)
        btn_panel.add(Box.createHorizontalStrut(8))
        btn_panel.add(self.btn_close)

        self.dialog.add(main_panel, BorderLayout.CENTER)
        self.dialog.add(btn_panel, BorderLayout.SOUTH)

        # storage for dynamic fields and actions
        self._rows = []  # list of (aid, friendly, action, textfield)

        # wire buttons
        self.btn_apply.addActionListener(self._on_apply_all)
        self.btn_save_all.addActionListener(self._on_save_and_persist)
        self.btn_restore.addActionListener(self._on_restore_defaults)
        self.btn_export.addActionListener(self._on_export)
        self.btn_import.addActionListener(self._on_import)
        self.btn_close.addActionListener(self._on_close)

        # initially populate
        try:
            self._populate_actions()
        except Exception:
            pass

    def _refresh_context(self):
        # keep compatibility with previous helper
        if self.frame is None:
            self.frame = _get_mainframe()
        if self.cfg is None and Configuration is not None:
            try:
                if Configuration.isInstanceInitialized():
                    self.cfg = Configuration.get()
            except Exception:
                try:
                    self.cfg = Configuration.get()
                except Exception:
                    pass
        if self.frame is not None:
            self.action_map = _get_action_map(self.frame)
        # update info display
        try:
            if self.usedArea is not None and self.frame is not None:
                hm = _get_hotkey_map(self.frame)
                txt = _build_known_shortcuts_text(hm, self.action_map)
                try:
                    self.usedArea.setText(txt)
                except Exception:
                    pass
        except Exception:
            pass
        return True

    def _populate_actions(self):
        # clear existing
        try:
            if self.listPanel is None:
                return
            self.listPanel.removeAll()
        except Exception:
            pass
        # collect actions
        hm = _get_hotkey_map(self.frame)
        entries = _collect_actions_from_hotkey_map(hm)
        # also include any saved action hotkeys not currently bound
        saved_actions = _load_saved_action_hotkeys(self.cfg) if self.cfg is not None else {}
        for aid, friendly, action, ks_text in entries:
            try:
                row = JPanel()
                row.setLayout(BoxLayout(row, BoxLayout.X_AXIS))
                label = JLabel(friendly + " : ")
                tf = JTextField(30)
                # prefer saved_action_keys over current binding
                cur = None
                try:
                    if aid in self.saved_action_keys and self.saved_action_keys[aid]:
                        cur = self.saved_action_keys[aid]
                except Exception:
                    cur = None
                if cur is None:
                    cur = ks_text
                if cur:
                    try:
                        tf.setText(cur)
                    except Exception:
                        pass
                row.add(label)
                row.add(tf)
                self.listPanel.add(row)
                self._rows.append((aid, friendly, action, tf))
            except Exception:
                pass
        # add any saved actions that aren't in entries
        for aid, val in saved_actions.items():
            try:
                found = False
                for r in self._rows:
                    if r[0] == aid:
                        found = True
                        break
                if found:
                    continue
                row = JPanel()
                row.setLayout(BoxLayout(row, BoxLayout.X_AXIS))
                label = JLabel(aid + " (saved): ")
                tf = JTextField(30)
                if val:
                    try:
                        tf.setText(val)
                    except Exception:
                        pass
                row.add(label)
                row.add(tf)
                self.listPanel.add(row)
                self._rows.append((aid, aid, None, tf))
            except Exception:
                pass
        try:
            self.listPanel.revalidate()
            self.listPanel.repaint()
        except Exception:
            pass
        # refresh info area
        self._refresh_context()

    def _gather_mapping_from_rows(self):
        mapping = {}
        for aid, friendly, action, tf in self._rows:
            try:
                txt = tf.getText()
            except Exception:
                txt = None
            normalized = _normalize_keystroke(txt) if txt is not None else None
            mapping[aid] = normalized
        return mapping

    def _check_conflicts_for_all(self, mapping):
        # mapping: aid->ks_string or None
        # get existing hotkey map
        hm = _get_hotkey_map(self.frame)
        try:
            entries = list(hm.entrySet())
        except Exception:
            entries = []
        # build set of ks for mapping
        vals = [v for v in mapping.values() if v]
        if len(vals) != len(set(vals)):
            return (False, "Duplicate hotkeys among your entries")
        # check conflict with existing bindings not in our action set
        our_actions = set([r[2] for r in self._rows if r[2] is not None])
        for aid, ks_text in mapping.items():
            if not ks_text:
                continue
            try:
                ks = KeyStroke.getKeyStroke(ks_text)
            except Exception:
                return (False, "Invalid keystroke: %s" % ks_text)
            for entry in entries:
                try:
                    existingKs = entry.getKey()
                    existingAct = entry.getValue()
                    if existingKs is None:
                        continue
                    if existingKs.equals(ks) and (existingAct not in our_actions):
                        return (False, "Conflict: %s already bound to other action" % ks_text)
                except Exception:
                    pass
        return (True, None)

    def _apply_action_mapping(self, mapping):
        # mapping: aid->ks_string
        hm = _get_hotkey_map(self.frame)
        if hm is None:
            return False
        # remove existing bindings for our actions
        try:
            entries = list(hm.entrySet())
        except Exception:
            entries = []
        our_actions = set([r[2] for r in self._rows if r[2] is not None])
        for entry in entries:
            try:
                if entry.getValue() in our_actions:
                    try:
                        hm.remove(entry.getKey())
                    except Exception:
                        pass
            except Exception:
                pass
        # put new bindings
        aid_to_action = {r[0]: r[2] for r in self._rows}
        for aid, ks_text in mapping.items():
            if not ks_text:
                continue
            try:
                ks = KeyStroke.getKeyStroke(ks_text)
            except Exception:
                ks = None
            if ks is None:
                continue
            act = aid_to_action.get(aid)
            if act is None:
                # cannot bind to unknown action
                continue
            try:
                hm.put(ks, act)
            except Exception:
                pass
        return True

    def _on_apply_all(self, evt=None):
        if not self._refresh_context():
            JOptionPane.showMessageDialog(self.dialog, "OpenPnP not ready.", "Error", JOptionPane.ERROR_MESSAGE)
            return
        mapping = self._gather_mapping_from_rows()
        ok, reason = self._check_conflicts_for_all(mapping)
        if not ok:
            JOptionPane.showMessageDialog(self.dialog, reason or "Conflict detected", "Error", JOptionPane.ERROR_MESSAGE)
            return
        _applied = self._apply_action_mapping(mapping)
        if _applied:
            JOptionPane.showMessageDialog(self.dialog, "Applied to runtime (not persisted). Use Save & Persist to persist.", "OK", JOptionPane.INFORMATION_MESSAGE)
        else:
            JOptionPane.showMessageDialog(self.dialog, "Failed to apply.", "Error", JOptionPane.ERROR_MESSAGE)

    def _on_save_and_persist(self, evt=None):
        if not self._refresh_context():
            JOptionPane.showMessageDialog(self.dialog, "OpenPnP not ready.", "Error", JOptionPane.ERROR_MESSAGE)
            return
        mapping = self._gather_mapping_from_rows()
        ok, reason = self._check_conflicts_for_all(mapping)
        if not ok:
            JOptionPane.showMessageDialog(self.dialog, reason or "Conflict detected", "Error", JOptionPane.ERROR_MESSAGE)
            return
        # apply to runtime
        self._apply_action_mapping(mapping)
        # persist action-level keys
        try:
            _save_action_hotkeys(self.cfg, mapping)
        except Exception:
            pass
        # also maintain backward compatibility for known job actions
        try:
            job_actions = _get_action_map(self.frame)
            if job_actions is not None:
                backward = {}
                for name, act in job_actions.items():
                    try:
                        aid = _action_id(act)
                        val = mapping.get(aid)
                        if val:
                            backward[name] = val
                        else:
                            backward[name] = None
                    except Exception:
                        pass
                try:
                    _save_hotkeys(self.cfg, backward)
                except Exception:
                    pass
        except Exception:
            pass
        JOptionPane.showMessageDialog(self.dialog, "Hotkeys applied and saved to script-state.xml.", "OK", JOptionPane.INFORMATION_MESSAGE)

    def _on_restore_defaults(self, evt=None):
        """Remove all persisted custom hotkeys (hotkey.action.* and legacy hotkey.job.*) and refresh UI."""
        if self.cfg is None:
            JOptionPane.showMessageDialog(self.dialog, "Configuration not available.", "Error", JOptionPane.ERROR_MESSAGE)
            return
        ans = JOptionPane.showConfirmDialog(self.dialog, "Restore defaults will remove all custom hotkeys. Continue?", "Confirm", JOptionPane.YES_NO_OPTION)
        try:
            if ans != JOptionPane.YES_OPTION:
                return
        except Exception:
            # fallback: if not numeric, proceed only on truthy
            if not ans:
                return
        try:
            state = self.cfg.scriptState
        except Exception:
            state = None
        if state is None:
            JOptionPane.showMessageDialog(self.dialog, "No script state available.", "Error", JOptionPane.ERROR_MESSAGE)
            return
        # collect keys to remove
        to_remove = []
        try:
            it = state.keySet().iterator()
            while it.hasNext():
                k = it.next()
                try:
                    ks = str(k)
                except Exception:
                    ks = None
                if ks is None:
                    continue
                if ks.startswith('hotkey.action.') or ks in _STATE_KEYS.values():
                    to_remove.append(k)
        except Exception:
            pass
        for k in to_remove:
            try:
                state.remove(k)
            except Exception:
                pass
        try:
            self.cfg.save()
        except Exception:
            try:
                frame = _get_mainframe()
                if frame is not None:
                    frame.saveConfig()
            except Exception:
                pass
        # refresh UI to reflect defaults
        try:
            self.saved_action_keys = _load_saved_action_hotkeys(self.cfg)
        except Exception:
            self.saved_action_keys = {}
        try:
            self._populate_actions()
        except Exception:
            pass
        JOptionPane.showMessageDialog(self.dialog, "Restored defaults (removed custom hotkeys).", "OK", JOptionPane.INFORMATION_MESSAGE)

    def _on_export(self, evt=None):
        """Export current action->keystroke mapping to a file (simple key=value lines)."""
        chooser = JFileChooser()
        try:
            res = chooser.showSaveDialog(self.dialog)
        except Exception:
            res = JFileChooser.CANCEL_OPTION
        if res != JFileChooser.APPROVE_OPTION:
            return
        f = chooser.getSelectedFile()
        try:
            fw = FileWriter(f)
            bw = BufferedWriter(fw)
            mapping = self._gather_mapping_from_rows()
            for k, v in mapping.items():
                try:
                    line = "%s=%s\n" % (k, v if v is not None else "")
                    bw.write(line)
                except Exception:
                    pass
            try:
                bw.close()
            except Exception:
                try:
                    fw.close()
                except Exception:
                    pass
            JOptionPane.showMessageDialog(self.dialog, "Exported to %s" % f.getAbsolutePath(), "OK", JOptionPane.INFORMATION_MESSAGE)
        except Exception as e:
            JOptionPane.showMessageDialog(self.dialog, "Export failed: %s" % str(e), "Error", JOptionPane.ERROR_MESSAGE)

    def _on_import(self, evt=None):
        """Import action->keystroke mapping from a file and populate the dialog fields. Does not auto-apply."""
        chooser = JFileChooser()
        try:
            res = chooser.showOpenDialog(self.dialog)
        except Exception:
            res = JFileChooser.CANCEL_OPTION
        if res != JFileChooser.APPROVE_OPTION:
            return
        f = chooser.getSelectedFile()
        parsed = {}
        try:
            fr = FileReader(f)
            br = BufferedReader(fr)
            line = br.readLine()
            while line is not None:
                try:
                    ln = line.strip()
                except Exception:
                    ln = line
                if ln and ('=' in ln):
                    try:
                        k, v = ln.split('=', 1)
                        k = k.strip()
                        v = v.strip()
                        if v == '':
                            v = None
                        parsed[k] = v
                    except Exception:
                        pass
                line = br.readLine()
            try:
                br.close()
            except Exception:
                pass
        except Exception as e:
            JOptionPane.showMessageDialog(self.dialog, "Import failed: %s" % str(e), "Error", JOptionPane.ERROR_MESSAGE)
            return
        # populate fields
        updated = 0
        for aid, friendly, action, tf in self._rows:
            if aid in parsed:
                try:
                    val = parsed[aid]
                    tf.setText(val if val is not None else '')
                    updated += 1
                except Exception:
                    pass
        JOptionPane.showMessageDialog(self.dialog, "Imported %d entries. Click Apply or Save to apply." % updated, "OK", JOptionPane.INFORMATION_MESSAGE)

    def _on_close(self, evt=None):
        try:
            self.dialog.dispose()
        except Exception:
            pass

    def show(self):
        try:
            self._refresh_context()
            self.dialog.setVisible(True)
        except Exception:
            pass


def _apply_saved_on_startup():
    if Configuration is None or MainFrame is None:
        return False
    try:
        if not Configuration.isInstanceInitialized():
            return False
    except Exception:
        pass
    cfg = None
    try:
        cfg = Configuration.get()
    except Exception:
        return False
    frame = _get_mainframe()
    if frame is None:
        return False
    action_map = _get_action_map(frame)
    if action_map is None:
        return False
    saved = _load_saved_hotkeys(cfg)
    # mapping is already stored as normalized strings; apply directly
    try:
        _apply_hotkeys(frame, action_map, saved)
        return True
    except Exception:
        return False


# Entry points

def _run():
    # Show dialog
    try:
        dlg = HotkeyDialog()
        dlg.show()
    except Exception:
        pass


def _run_async():
    try:
        class _Runner(java.lang.Runnable):
            def run(self):
                # show dialog on EDT
                _run()
        SwingUtilities.invokeLater(_Runner())
    except Exception:
        _run()


# On import / startup try to auto-apply saved keys. Give small delay to allow MainFrame init.
try:
    # try immediate apply
    applied = _apply_saved_on_startup()
    if not applied:
        # schedule a delayed attempt via SwingUtilities after short wait
        try:
            class _Delayed(java.lang.Runnable):
                def run(self):
                    try:
                        _apply_saved_on_startup()
                    except Exception:
                        pass
            SwingUtilities.invokeLater(_Delayed())
        except Exception:
            pass
except Exception:
    pass

# Expose dialog when script is run manually (async)
try:
    _run_async()
except Exception:
    pass

def _action_id(action):
    """生成可持久化的 action id。优先使用 Action.NAME 属性（通过字符串 'Name'），否则使用类名或简单类名。返回已清理的字符串。"""
    friendly = None
    try:
        # Prefer Action.NAME without importing javax.swing.Action (use literal key)
        try:
            val = action.getValue("Name")
            if val is not None:
                friendly = val
        except Exception:
            friendly = None
    except Exception:
        friendly = None

    if friendly is None:
        try:
            # try class simple name first for readability
            friendly = action.getClass().getSimpleName()
        except Exception:
            try:
                friendly = action.getClass().getName()
            except Exception:
                friendly = str(action)
    # sanitize to ASCII id
    try:
        s = str(friendly)
    except Exception:
        s = repr(friendly)
    import re
    id = re.sub(r"[^0-9A-Za-z_\-]", "_", s)
    if len(id) > 120:
        id = id[:120]
    return id


def _load_saved_action_hotkeys(cfg):
    '''Load keys from scriptState with prefix hotkey.action. Return dict id->string.'''
    res = {}
    try:
        state = cfg.scriptState
    except Exception:
        state = None
    if state is None:
        return res
    try:
        # iterate keys (Java Map)
        it = state.keySet().iterator()
        while it.hasNext():
            k = it.next()
            try:
                ks = str(k)
            except Exception:
                continue
            if ks.startswith('hotkey.action.'):
                try:
                    val = state.get(k)
                    res[ks[len('hotkey.action.'):]] = val
                except Exception:
                    pass
    except Exception:
        # best-effort: try containsKey lookups for known ones later
        pass
    return res


def _save_action_hotkeys(cfg, mapping):
    '''Save mapping id->string into scriptState under hotkey.action.<id>. Remove if empty.'''
    try:
        state = cfg.scriptState
    except Exception:
        state = None
    if state is None:
        return False
    try:
        for aid, val in mapping.items():
            key = 'hotkey.action.' + aid
            try:
                if val:
                    state.put(key, val)
                else:
                    if state.containsKey(key):
                        state.remove(key)
            except Exception:
                pass
        # optional: persist
        try:
            cfg.save()
        except Exception:
            try:
                frame = _get_mainframe()
                if frame is not None:
                    frame.saveConfig()
            except Exception:
                pass
        return True
    except Exception:
        return False


# 新：构建全动作列表并生成 UI 元素的辅助
def _collect_actions_from_hotkey_map(hotkey_map):
    '''返回一个 list of (action_id, friendly_name, action_obj, current_keystroke_string_or_None)'''
    lst = []
    if hotkey_map is None:
        return lst
    try:
        entries = list(hotkey_map.entrySet())
    except Exception:
        entries = []
    seen = set()
    for entry in entries:
        try:
            act = entry.getValue()
            if act is None:
                continue
            aid = _action_id(act)
            if aid in seen:
                continue
            seen.add(aid)
            # friendly name: prefer Action.NAME via 'Name' key, else simple class name
            try:
                fn = None
                try:
                    fn = act.getValue("Name")
                except Exception:
                    fn = None
                if fn is None:
                    try:
                        fn = act.getClass().getSimpleName()
                    except Exception:
                        fn = str(act)
            except Exception:
                fn = str(act)
            # find current keystroke bound to it
            try:
                ks = _find_keystroke_for_action(hotkey_map, act)
                ks_text = ks.toString() if ks is not None else None
            except Exception:
                ks_text = None
            lst.append((aid, str(fn), act, ks_text))
        except Exception:
            pass
    return lst

# 修改 HotkeyDialog，生成可滚动的多行 action binding 编辑区
# We'll replace parts of HotkeyDialog.__init__ to build dynamic list
