"""
renamer.py - Maya tool for renaming/replacing names of DAG objects and adding
prefix, suffix, numbering etc.  The UI is built with PySide2/PySide6 so the
list pane can be resized with a real QSplitter (which, unlike Maya's
paneLayout, supports non-collapsible panes and minimum sizes).

Usage (Script Editor or shelf):
    import renamer
    renamer.show()

Script Manager (Run command — use either line):
    show()
    import renamer; renamer.show()

Tested against Maya 2022+ (PySide2) and Maya 2025+ (PySide6).
"""

import os
import re
import sys
import types
import fnmatch
import inspect
from contextlib import contextmanager

import maya.cmds as cmds
import maya.mel as mel
import maya.OpenMayaUI as omui

try:
    from PySide2 import QtCore, QtWidgets
    from shiboken2 import wrapInstance
except ImportError:  # Maya 2025+
    from PySide6 import QtCore, QtWidgets
    from shiboken6 import wrapInstance


WINDOW_OBJECT = "renamerWindow"

# Blue theme (0-1 floats from the cmds version -> 0-255 for stylesheets).
PRIMARY_QSS = "background-color: rgb(66,128,184); color: white;"
SELECT_QSS = "background-color: rgb(41,92,153); color: white;"
QUICK_QSS = "background-color: rgb(107,140,179); color: white;"

# Section title bars (collapsible headers).
HEADER_QSS = (
    "QToolButton {"
    " background-color: rgb(58,80,107); color: white;"
    " border: none; font-weight: bold; text-align: left; padding: 4px 6px;"
    " }"
    "QToolButton:hover { background-color: rgb(72,98,130); }"
)

# Object types offered in the Select dropdown ("Any" = no type filter).
SELECT_TYPES = [
    "Any", "transform", "joint", "mesh", "nurbsCurve", "nurbsSurface",
    "locator", "camera", "ikHandle", "cluster", "lattice", "follicle",
    "light", "constraint",
]

QUICK_SUFFIXES = [
    "grp", "jnt", "anim", "loc", "crv",
    "ik", "geo", "proxyGeo", "set", "dummy",
    "clust", "const", "mtl",
]

# Scope ids — also the QButtonGroup ids for the Hierarchy/Selected/All radios.
HIERARCHY, SELECTED, ALL = 1, 2, 3

# do_rename modes.
MODE_PREFIX, MODE_SUFFIX, MODE_NUMBER = 1, 2, 3

# Default height (px) of the resizable Duplicate Names pane when expanded.
TOP_PANE_HEIGHT = 240


# ======================================================================
# Helpers
# ======================================================================
@contextmanager
def _undo_chunk():
    """Group every rename in the block into a single undoable action."""
    cmds.undoInfo(openChunk=True)
    try:
        yield
    finally:
        cmds.undoInfo(closeChunk=True)


def _safe_rename(obj, new_short):
    """cmds.rename that survives locked/referenced/read-only nodes.
    Returns the new name, or None (with a warning) if the rename failed."""
    try:
        return cmds.rename(obj, new_short)
    except RuntimeError as exc:
        cmds.warning("Could not rename '{0}': {1}".format(_short_name(obj), exc))
        return None


def _short_name(obj):
    """Return the last part of a (possibly) pipe-separated DAG path."""
    if not obj:
        return ""
    return obj.split("|")[-1]


def _sort_by_outliner(nodes):
    """Reorder nodes to match top-to-bottom Outliner order (DAG order)."""
    ordered = cmds.ls(long=True, dag=True) or []
    index = {n: i for i, n in enumerate(ordered)}
    return sorted(nodes, key=lambda n: index.get(n, len(ordered)))


def _selection_long():
    """Selected objects as long names, sorted then reversed so children
    (which sort after parents) are renamed before their parents."""
    return sorted(cmds.ls(selection=True, long=True) or [], reverse=True)


def _to_objects(nodes):
    """Map any shape nodes to their transform parent (select objects, not
    shapes). Order-preserving, de-duplicated."""
    out = []
    for n in nodes:
        if cmds.objectType(n, isAType="shape"):
            out.extend(cmds.listRelatives(n, parent=True, fullPath=True) or [])
        else:
            out.append(n)
    return list(dict.fromkeys(out))


def _replace_hash(template, number):
    """Replace the run of '#' in template with a zero-padded number."""
    match = re.search(r"#+", template)
    if not match:
        return template
    padding = len(match.group(0))
    return template[: match.start()] + str(number).zfill(padding) + template[match.end():]


# ======================================================================
# Scene operations (pure logic; values come from the caller, not the UI)
# ======================================================================
def remove_first_char():
    with _undo_chunk():
        for obj in _selection_long():
            short = _short_name(obj)
            if len(short) > 1:
                _safe_rename(obj, short[1:])


def remove_last_char():
    with _undo_chunk():
        for obj in _selection_long():
            short = _short_name(obj)
            if len(short) > 1:
                _safe_rename(obj, short[:-1])


def rename_selected_shapes():
    with _undo_chunk():
        for transform in cmds.ls(selection=True, long=True) or []:
            shapes = cmds.listRelatives(transform, shapes=True, fullPath=True) or []
            short = _short_name(transform)
            for i, shape in enumerate(shapes, start=1):
                _safe_rename(shape, "{0}_Shape{1}".format(short, i))


def quick_suffix(suffix):
    with _undo_chunk():
        for obj in _selection_long():
            _safe_rename(obj, _short_name(obj) + suffix)


def do_rename(mode, prefix, suffix, rename, start, padding):
    """mode 1 = add prefix, 2 = add suffix, 3 = rename and number."""
    # Validate once up front rather than on every loop iteration.
    if mode == MODE_PREFIX and not prefix:
        cmds.warning("Can't add prefix, prefix entry field is blank!")
        return
    if mode == MODE_SUFFIX and not suffix:
        cmds.warning("Can't add suffix, suffix entry field is blank!")
        return
    if mode == MODE_NUMBER and not rename:
        cmds.warning("Can't rename and number, rename entry field is blank!")
        return
    if mode not in (MODE_PREFIX, MODE_SUFFIX, MODE_NUMBER):
        return

    objs = _sort_by_outliner(cmds.ls(selection=True, long=True) or [])

    with _undo_chunk():
        for i, obj in enumerate(objs):
            short = _short_name(obj)

            if mode == MODE_PREFIX:
                new_short = prefix + short
            elif mode == MODE_SUFFIX:
                new_short = short + suffix
            else:  # MODE_NUMBER
                new_short = "{0}{1}".format(rename, str(i + start).zfill(padding))

            new_name = _safe_rename(obj, new_short)
            if new_name is None:
                continue
            # Resolve the long name without disturbing the selection.
            new_long = (cmds.ls(new_name, long=True) or [new_name])[0]

            # Patch the remaining long names in case a parent path changed.
            for j in range(len(objs)):
                tmp = objs[j] + "|"
                tmp = tmp.replace(obj + "|", new_long + "|")
                objs[j] = tmp[:-1]

        if objs:
            cmds.select(objs, replace=True)


def hash_rename(template):
    if not template:
        return
    if not re.search(r"#+", template):
        cmds.warning("Hash Rename template needs at least one '#' placeholder.")
        return
    objs = _sort_by_outliner(cmds.ls(selection=True, long=True) or [])
    if not objs:
        return

    renamed = []
    with _undo_chunk():
        for i, obj in enumerate(objs, start=1):
            result = _safe_rename(obj, _replace_hash(template, i))
            if result is None:
                continue
            renamed.append(result)
            print("// Renamed: {0} --> {1}".format(obj, result))

        if renamed:
            cmds.select(renamed, replace=True)


def search_and_replace(search, replace, scope):
    if not search:
        cmds.warning("Search field is blank.")
        return
    scope_str = {HIERARCHY: "hierarchy", SELECTED: "selected",
                 ALL: "all"}.get(scope, "selected")

    def _mel_escape(text):
        return text.replace("\\", "\\\\").replace('"', '\\"')

    # searchReplaceNames is MEL-only; replace may legitimately be empty.
    mel.eval('searchReplaceNames "{0}" "{1}" "{2}"'.format(
        _mel_escape(search), _mel_escape(replace), scope_str))


def select_by_filter(name, obj_type, scope):
    pattern = name.strip() if name and name.strip() else "*"
    use_type = bool(obj_type) and obj_type != "Any"

    if scope == ALL:
        if use_type:
            nodes = cmds.ls(type=obj_type, long=True) or []
        else:
            nodes = cmds.ls(transforms=True, long=True) or []
    else:
        sel = cmds.ls(selection=True, long=True) or []
        if not sel:
            cmds.warning("Nothing selected to define the scope.")
            return
        if scope == HIERARCHY:
            extra = cmds.listRelatives(sel, allDescendents=True, fullPath=True,
                                       noIntermediate=True) or []
        else:  # Selected -> include the selection's own shapes
            extra = cmds.listRelatives(sel, shapes=True, fullPath=True,
                                       noIntermediate=True) or []
        pool = list(dict.fromkeys(sel + extra))
        nodes = (cmds.ls(pool, type=obj_type, long=True) or []) if use_type else pool

    objs = _to_objects(nodes)
    matches = [o for o in objs if fnmatch.fnmatch(_short_name(o), pattern)]

    if matches:
        cmds.select(matches, replace=True)
        print("// Selected {0} object(s).".format(len(matches)))
    else:
        cmds.select(clear=True)
        cmds.warning("No objects matched.")


def find_duplicate_names(scope):
    """Return (ordered_names, {name: [paths]}) for non-unique (short) names.
    Shape nodes are ignored. Returns (None, None) if the scope needs a
    selection and there is none."""
    if scope == ALL:  # every transform (shapes excluded by the flag)
        pool = cmds.ls(long=True, transforms=True) or []
    else:
        sel = cmds.ls(selection=True, long=True) or []
        if not sel:
            cmds.warning("Nothing selected to define the scope.")
            return None, None
        if scope == HIERARCHY:
            pool = list(dict.fromkeys(sel + (cmds.listRelatives(
                sel, allDescendents=True, fullPath=True) or [])))
        else:  # Selected
            pool = sel
        pool = [o for o in pool if not cmds.objectType(o, isAType="shape")]

    groups = {}
    for obj in pool:
        groups.setdefault(_short_name(obj), []).append(obj)

    dup_map = {n: objs for n, objs in groups.items() if len(objs) > 1}
    return sorted(dup_map), dup_map


# ======================================================================
# UI - collapsible section widget
# ======================================================================
class _CollapsibleBox(QtWidgets.QWidget):
    """A section with a clickable arrow header that hides/shows its body."""

    toggled = QtCore.Signal(bool)

    def __init__(self, title, expanded=True, parent=None):
        super(_CollapsibleBox, self).__init__(parent)

        self.toggle = QtWidgets.QToolButton()
        self.toggle.setText(title)
        self.toggle.setCheckable(True)
        self.toggle.setChecked(expanded)
        self.toggle.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
        self.toggle.setArrowType(
            QtCore.Qt.DownArrow if expanded else QtCore.Qt.RightArrow)
        self.toggle.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
                                  QtWidgets.QSizePolicy.Fixed)
        self.toggle.setStyleSheet(HEADER_QSS)
        self.toggle.toggled.connect(self._on_toggled)

        self.content = QtWidgets.QWidget()
        self.body = QtWidgets.QVBoxLayout(self.content)
        self.body.setContentsMargins(8, 4, 8, 8)

        outer = QtWidgets.QVBoxLayout(self)
        outer.setContentsMargins(0, 0, 0, 0)
        outer.setSpacing(0)
        outer.addWidget(self.toggle)
        outer.addWidget(self.content, 1)

        # Set visibility only AFTER content is parented into this box.
        # Doing it while content is still parent-less would briefly show it
        # as its own top-level window (a flash before the main window opens).
        self.content.setVisible(expanded)

    def _on_toggled(self, checked):
        self.toggle.setArrowType(
            QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow)
        self.content.setVisible(checked)
        self.toggled.emit(checked)


# ======================================================================
# UI - main window
# ======================================================================
def _maya_main_window():
    ptr = omui.MQtUtil.mainWindow()
    return wrapInstance(int(ptr), QtWidgets.QWidget) if ptr else None


class RenamerWindow(QtWidgets.QDialog):

    def __init__(self, parent=None):
        # Pass the window flag to the constructor so Qt never has to destroy
        # and re-create the native window afterwards (a source of flashing).
        super(RenamerWindow, self).__init__(parent, QtCore.Qt.Window)
        self.setObjectName(WINDOW_OBJECT)
        self.setWindowTitle("Renamer")
        self.setMinimumWidth(330)
        self.resize(340, 820)

        self._dup_names = []
        self._dup_map = {}

        self._build_ui()

    # ---- small builders ---------------------------------------------
    def _button(self, text, qss, slot, height=24, width=None):
        b = QtWidgets.QPushButton(text)
        b.setStyleSheet("QPushButton {{ {0} }}".format(qss))
        b.setMinimumHeight(height)
        if width:
            b.setFixedWidth(width)
        b.clicked.connect(slot)
        return b

    def _scope_widget(self, default):
        w = QtWidgets.QWidget()
        h = QtWidgets.QHBoxLayout(w)
        h.setContentsMargins(0, 0, 0, 0)
        group = QtWidgets.QButtonGroup(w)
        for i, label in enumerate(["Hierarchy", "Selected", "All"], start=1):
            rb = QtWidgets.QRadioButton(label)
            group.addButton(rb, i)
            h.addWidget(rb)
            if i == default:
                rb.setChecked(True)
        return w, group

    @staticmethod
    def _hline():
        line = QtWidgets.QFrame()
        line.setFrameShape(QtWidgets.QFrame.HLine)
        line.setFrameShadow(QtWidgets.QFrame.Sunken)
        return line

    # ---- layout -----------------------------------------------------
    def _build_ui(self):
        main = QtWidgets.QVBoxLayout(self)
        main.setContentsMargins(0, 0, 0, 0)

        self.splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical)
        self.splitter.setChildrenCollapsible(False)  # bar can never vanish
        main.addWidget(self.splitter)

        # Top pane: the whole Duplicate Names section (incl. Select button);
        # the splitter handle sits directly under it.
        self.splitter.addWidget(self._build_duplicate_section())

        # Bottom pane: every other section in a scroll area.
        scroll = QtWidgets.QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        scroll.setFrameShape(QtWidgets.QFrame.NoFrame)

        body = QtWidgets.QWidget()
        body_layout = QtWidgets.QVBoxLayout(body)
        body_layout.setContentsMargins(0, 0, 0, 0)
        body_layout.setSpacing(2)
        body_layout.addWidget(self._build_select_section())
        body_layout.addWidget(self._build_rename_section())
        body_layout.addWidget(self._build_prefix_suffix_section())
        body_layout.addWidget(self._build_quick_suffix_section())
        body_layout.addWidget(self._build_search_replace_section())
        body_layout.addStretch(1)
        scroll.setWidget(body)
        self.splitter.addWidget(scroll)

        self.splitter.setStretchFactor(0, 0)
        self.splitter.setStretchFactor(1, 1)
        self.splitter.setSizes([TOP_PANE_HEIGHT, 560])

    def _build_duplicate_section(self):
        box = _CollapsibleBox("Duplicate Names", expanded=True)
        self.dup_box = box

        row = QtWidgets.QHBoxLayout()
        scope_w, self.dup_group = self._scope_widget(ALL)
        row.addWidget(scope_w)
        row.addStretch(1)
        row.addWidget(self._button("List", PRIMARY_QSS, self._on_list_dupes, width=80))
        box.body.addLayout(row)

        self.dup_info = QtWidgets.QLabel("")
        box.body.addWidget(self.dup_info)

        self.dup_list = QtWidgets.QListWidget()
        self.dup_list.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
        self.dup_list.itemDoubleClicked.connect(lambda *_: self._on_select_dupes())
        box.body.addWidget(self.dup_list, 1)

        box.body.addWidget(
            self._button("Select Objects", SELECT_QSS, self._on_select_dupes, height=28))

        box.toggled.connect(self._on_dup_toggled)
        return box

    def _build_select_section(self):
        box = _CollapsibleBox("Select", expanded=True)
        form = QtWidgets.QFormLayout()
        self.sel_name = QtWidgets.QLineEdit("*")
        form.addRow("Name:", self.sel_name)
        self.sel_type = QtWidgets.QComboBox()
        self.sel_type.addItems(SELECT_TYPES)
        form.addRow("Type:", self.sel_type)
        box.body.addLayout(form)
        scope_w, self.sel_group = self._scope_widget(SELECTED)
        box.body.addWidget(scope_w)
        box.body.addWidget(self._button("Select", SELECT_QSS, self._on_select_filter))
        return box

    def _build_rename_section(self):
        box = _CollapsibleBox("Rename Tools", expanded=True)

        form = QtWidgets.QFormLayout()
        self.rename_field = QtWidgets.QLineEdit("")
        form.addRow("Rename:", self.rename_field)
        box.body.addLayout(form)

        nrow = QtWidgets.QHBoxLayout()
        self.start_spin = QtWidgets.QSpinBox()
        self.start_spin.setRange(0, 99999)
        self.start_spin.setValue(1)
        self.pad_spin = QtWidgets.QSpinBox()
        self.pad_spin.setRange(1, 10)
        self.pad_spin.setValue(2)
        nrow.addWidget(QtWidgets.QLabel("Start #:"))
        nrow.addWidget(self.start_spin)
        nrow.addWidget(QtWidgets.QLabel("Padding:"))
        nrow.addWidget(self.pad_spin)
        box.body.addLayout(nrow)

        box.body.addWidget(self._button("Rename and Number", PRIMARY_QSS,
                                        lambda: self._on_do_rename(MODE_NUMBER)))

        box.body.addWidget(self._hline())
        rrow = QtWidgets.QHBoxLayout()
        rrow.addWidget(QtWidgets.QLabel("Remove..."))
        rrow.addWidget(self._button("First Character", PRIMARY_QSS,
                                    lambda: remove_first_char()))
        rrow.addWidget(self._button("Last Character", PRIMARY_QSS,
                                    lambda: remove_last_char()))
        box.body.addLayout(rrow)

        srow = QtWidgets.QHBoxLayout()
        srow.addWidget(QtWidgets.QLabel("Rename Shapes..."))
        srow.addWidget(self._button("Apply", PRIMARY_QSS,
                                    lambda: rename_selected_shapes()))
        box.body.addLayout(srow)

        box.body.addWidget(self._hline())
        hrow = QtWidgets.QHBoxLayout()
        hrow.addWidget(QtWidgets.QLabel("Hash Rename:"))
        self.hash_field = QtWidgets.QLineEdit("name_####_suffix")
        hrow.addWidget(self.hash_field)
        hrow.addWidget(self._button("Rename", PRIMARY_QSS, self._on_hash))
        box.body.addLayout(hrow)
        return box

    def _build_prefix_suffix_section(self):
        box = _CollapsibleBox("Prefix - Suffix", expanded=True)
        prow = QtWidgets.QHBoxLayout()
        prow.addWidget(QtWidgets.QLabel("Prefix:"))
        self.prefix_field = QtWidgets.QLineEdit("prefix_")
        prow.addWidget(self.prefix_field)
        prow.addWidget(self._button("Add", PRIMARY_QSS,
                                    lambda: self._on_do_rename(MODE_PREFIX)))
        box.body.addLayout(prow)

        srow = QtWidgets.QHBoxLayout()
        srow.addWidget(QtWidgets.QLabel("Suffix:"))
        self.suffix_field = QtWidgets.QLineEdit("_suffix")
        srow.addWidget(self.suffix_field)
        srow.addWidget(self._button("Add", PRIMARY_QSS,
                                    lambda: self._on_do_rename(MODE_SUFFIX)))
        box.body.addLayout(srow)
        return box

    def _build_quick_suffix_section(self):
        box = _CollapsibleBox("Quick Suffix", expanded=False)
        grid = QtWidgets.QGridLayout()
        for idx, name in enumerate(QUICK_SUFFIXES):
            r, c = divmod(idx, 5)
            slot = (lambda s: (lambda: quick_suffix("_" + s)))(name)
            grid.addWidget(self._button(name, QUICK_QSS, slot), r, c)
        box.body.addLayout(grid)
        return box

    def _build_search_replace_section(self):
        box = _CollapsibleBox("Search and Replace", expanded=True)
        form = QtWidgets.QFormLayout()
        self.search_field = QtWidgets.QLineEdit("pasted__")
        self.replace_field = QtWidgets.QLineEdit("")
        form.addRow("Search:", self.search_field)
        form.addRow("Replace:", self.replace_field)
        box.body.addLayout(form)
        scope_w, self.sr_group = self._scope_widget(HIERARCHY)
        box.body.addWidget(scope_w)
        box.body.addWidget(self._button("Apply", PRIMARY_QSS, self._on_search))
        return box

    # ---- slots ------------------------------------------------------
    def _on_do_rename(self, mode):
        do_rename(mode, self.prefix_field.text(), self.suffix_field.text(),
                  self.rename_field.text(), self.start_spin.value(),
                  self.pad_spin.value())

    def _on_hash(self):
        hash_rename(self.hash_field.text())

    def _on_search(self):
        search_and_replace(self.search_field.text(), self.replace_field.text(),
                           self.sr_group.checkedId())

    def _on_select_filter(self):
        select_by_filter(self.sel_name.text(), self.sel_type.currentText(),
                         self.sel_group.checkedId())

    def _on_list_dupes(self):
        names, dup_map = find_duplicate_names(self.dup_group.checkedId())
        if names is None:
            return
        self._dup_names, self._dup_map = names, dup_map

        self.dup_list.clear()
        total = 0
        for name in names:
            count = len(dup_map[name])
            total += count
            self.dup_list.addItem("{0}   ({1})".format(name, count))

        if names:
            self.dup_info.setText(
                "{0} duplicated name(s), {1} object(s)".format(len(names), total))
        else:
            self.dup_info.setText("No duplicate names found.")

    def _on_select_dupes(self):
        objs = []
        for item in self.dup_list.selectedItems():
            name = self._dup_names[self.dup_list.row(item)]
            objs.extend(self._dup_map.get(name, []))

        valid = [o for o in objs if cmds.objExists(o)]
        if valid:
            cmds.select(valid, replace=True)
            print("// Selected {0} object(s).".format(len(valid)))
        else:
            cmds.warning("Highlight one or more names in the list first.")

    def _on_dup_toggled(self, checked):
        total = sum(self.splitter.sizes()) or self.height()
        if checked:
            top = TOP_PANE_HEIGHT
        else:
            top = self.dup_box.toggle.sizeHint().height() + 8
        self.splitter.setSizes([top, max(50, total - top)])


# ======================================================================
# Entry point
# ======================================================================
_renamer_win = None


def _this_script_path():
    """Path to this .py file (works when Maya exec-sources without setting __file__)."""
    try:
        path = __file__
    except NameError:
        path = None
    if path:
        return os.path.abspath(path)
    for fr in inspect.stack():
        fn = fr.filename
        if fn and os.path.basename(fn).lower() == "renamer.py":
            return os.path.abspath(fn)
    return None


def _running_under_script_manager():
    """True when Script Manager is exec-sourcing this file into __main__."""
    for fr in inspect.stack():
        if fr.function == "_source_py" and fr.filename and "ScriptManager" in fr.filename:
            return True
    return False


def _register_renamer_module():
    """Let ``import renamer`` work after Script Manager sources this file."""
    path = _this_script_path()
    if not path:
        return

    root = os.path.dirname(path)
    if root not in sys.path:
        sys.path.insert(0, root)

    name = "renamer"
    existing = sys.modules.get(name)
    if existing is not None and getattr(existing, "__file__", None) == path:
        return

    mod = types.ModuleType(name)
    mod.__file__ = path
    mod.__package__ = ""
    for key, value in globals().items():
        if key.startswith("__"):
            continue
        setattr(mod, key, value)
    sys.modules[name] = mod


def _existing_windows():
    """Every live top-level widget carrying our objectName (there may be more
    than one transiently, since Qt deletes closed widgets asynchronously)."""
    return [w for w in QtWidgets.QApplication.topLevelWidgets()
            if w.objectName() == WINDOW_OBJECT]


def _destroy(win):
    try:
        win.close()
        win.setParent(None)
        win.deleteLater()
    except Exception:
        pass


def _center_on_parent(win):
    """Place the window centred over Maya's main window *before* it is shown,
    so it never flashes at the default (0, 0) corner on first paint."""
    parent = win.parentWidget() or _maya_main_window()
    if parent is None:
        return
    geo = win.frameGeometry()
    geo.moveCenter(parent.geometry().center())
    win.move(geo.topLeft())


def show():
    global _renamer_win

    # Reuse the first window that belongs to the *current* class object and
    # dispose of everything else. After a module reload every prior window is
    # an instance of the previous class (isinstance is False), so they are all
    # cleaned up and a fresh one is built.
    reuse = None
    for w in _existing_windows():
        if reuse is None and isinstance(w, RenamerWindow):
            reuse = w
        else:
            _destroy(w)

    if reuse is None:
        # Build fully while hidden, position it, then show exactly once.
        _renamer_win = RenamerWindow(parent=_maya_main_window())
        _center_on_parent(_renamer_win)
    else:
        _renamer_win = reuse

    _renamer_win.show()
    _renamer_win.raise_()
    _renamer_win.activateWindow()
    return _renamer_win


_register_renamer_module()

if __name__ == "__main__" and not _running_under_script_manager():
    show()
