"""
tripo_pbr_builder_ui.py

A Maya UI tool that:
  1. Imports an FBX file
  2. Creates an aiStandardSurface material and assigns it to the imported mesh
  3. Lets you browse-load each PBR map (Base Color, Roughness, Metallic,
     Normal) one at a time, automatically wiring each into the correct
     shader attribute with the correct color space (sRGB for Base Color,
     Raw for the data maps), and building a tangent-space bump2d node
     for the normal map.
  4. Creates an aiSkyDomeLight and gives you live sliders for Intensity,
     Exposure, Rotation Y, and Color so you can light the scene enough
     to actually see the material (Arnold's viewport renders black with
     no lights in the scene -- this section exists specifically to fix
     that).

Requires: Maya with Arnold (MtoA) available. The tool will attempt to
auto-load the mtoa and fbxmaya plug-ins if they aren't already loaded.

USAGE (Maya Script Editor, Python tab):

    import tripo_pbr_builder_ui as tpbr
    import importlib; importlib.reload(tpbr)  # if iterating on the script
    tpbr.launch()
"""

import os
import functools

try:
    import maya.cmds as cmds
except ImportError:
    cmds = None  # allows the file to be imported/inspected outside Maya


WINDOW_NAME = "tripoPBRBuilderWindow"

IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tga *.tif *.tiff *.exr);;All Files (*.*)"
FBX_FILTER = "FBX Files (*.fbx);;All Files (*.*)"

PLACE2D_ATTR_PAIRS = [
    ("coverage", "coverage"), ("translateFrame", "translateFrame"),
    ("rotateFrame", "rotateFrame"), ("mirrorU", "mirrorU"),
    ("mirrorV", "mirrorV"), ("stagger", "stagger"),
    ("wrapU", "wrapU"), ("wrapV", "wrapV"),
    ("repeatUV", "repeatUV"), ("offset", "offset"),
    ("rotateUV", "rotateUV"), ("noiseUV", "noiseUV"),
    ("vertexUvOne", "vertexUvOne"), ("vertexUvTwo", "vertexUvTwo"),
    ("vertexUvThree", "vertexUvThree"), ("vertexCameraOne", "vertexCameraOne"),
]

IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".tga", ".tif", ".tiff", ".exr")

# Filename suffixes (before the extension) checked when auto-detecting maps
# in a "Textures" folder next to the imported FBX. Longer/explicit suffixes
# are listed first so e.g. "_Color" is matched before the single-letter "_C"
# fallback.
AUTO_DETECT_SUFFIXES = {
    "base_color": ["_Color", "_color", "_C", "_c"],
    "roughness":  ["_Roughness", "_roughness", "_R", "_r"],
    "metallic":   ["_Metallic", "_metallic", "_M", "_m"],
    "normal":     ["_Normal", "_normal", "_N", "_n"],
}


def _ensure_plugin_loaded(plugin_name):
    """Try to load a plugin if it isn't already. Returns True on success."""
    try:
        if not cmds.pluginInfo(plugin_name, query=True, loaded=True):
            cmds.loadPlugin(plugin_name)
        return True
    except Exception:
        return False


class TripoPBRBuilder(object):

    MAP_SLOTS = ["base_color", "roughness", "metallic", "normal"]
    MAP_LABELS = {
        "base_color": "Base Color",
        "roughness": "Roughness",
        "metallic": "Metallic",
        "normal": "Normal",
    }

    def __init__(self):
        self.imported_transforms = []
        self.material_name = None
        self.shader = None
        self.shading_group = None
        self.map_file_nodes = {}    # slot -> file node name
        self.map_path_fields = {}   # slot -> textField UI control name
        self.bump_node = None
        self.skydome_transform = None
        self.skydome_shape = None

        self.fbx_path_field = None
        self.material_name_field = None
        self.status_text = None
        self.intensity_slider = None
        self.exposure_slider = None
        self.rotation_slider = None
        self.color_slider = None
        self.arnold_viewport_checkbox = None

        self.build_ui()

    # ------------------------------------------------------------------
    # UI construction
    # ------------------------------------------------------------------
    def build_ui(self):
        if cmds.window(WINDOW_NAME, exists=True):
            cmds.deleteUI(WINDOW_NAME)

        window = cmds.window(WINDOW_NAME, title="Tripo PBR Material Builder",
                              widthHeight=(430, 600), sizeable=True)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=6,
                           columnAttach=("both", 8))

        # --- Section 1: Import FBX -----------------------------------------
        cmds.frameLayout(label="1. Import FBX", collapsable=True,
                          marginHeight=8, marginWidth=8)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=4)

        cmds.rowLayout(numberOfColumns=2, adjustableColumn=1,
                        columnWidth2=(290, 90))
        self.fbx_path_field = cmds.textField(editable=False,
                                              placeholderText="No FBX imported")
        cmds.button(label="Import FBX", command=self._on_browse_and_import_fbx)
        cmds.setParent("..")  # back to section1 columnLayout

        cmds.rowLayout(numberOfColumns=1, adjustableColumn=1,
                        columnWidth1=(390))
        self.material_name_field = cmds.textField(
            placeholderText="Material name (auto-filled from FBX)")
        cmds.setParent("..")  # back to section1 columnLayout

        cmds.button(label="Create Material + Assign to Imported Mesh",
                    height=32, command=self._on_create_material)

        cmds.frameLayout(label="Texture Auto-Detect Info", collapsable=True,
                          collapse=True, marginHeight=6, marginWidth=6)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=2)
        cmds.text(
            label="If a 'Textures' folder sits next to the FBX, maps are "
                  "auto-loaded when the material is created.",
            align="left", wordWrap=True, width=380)
        cmds.text(label="Name files with one of these suffixes before the "
                        "extension:", align="left", wordWrap=True, width=380)
        cmds.text(label="  Base Color:  _Color  /  _color  /  _C  /  _c",
                  align="left")
        cmds.text(label="  Roughness:   _Roughness  /  _roughness  /  _R  /  _r",
                  align="left")
        cmds.text(label="  Metallic:    _Metallic  /  _metallic  /  _M  /  _m",
                  align="left")
        cmds.text(label="  Normal:      _Normal  /  _normal  /  _N  /  _n",
                  align="left")
        cmds.text(label="e.g. goldenBarnacle_Color.jpg -> Base Color",
                  align="left", wordWrap=True, width=380)
        cmds.setParent("..")  # back to frameLayout (info box)
        cmds.setParent("..")  # back to section1 columnLayout

        cmds.setParent("..")  # back to frameLayout (section 1)
        cmds.setParent("..")  # back to main columnLayout

        # --- Section 2: Load Texture Maps -----------------------------------
        cmds.frameLayout(label="2. Load PBR Texture Maps", collapsable=True,
                          marginHeight=8, marginWidth=8)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=4)

        for slot in self.MAP_SLOTS:
            cmds.rowLayout(numberOfColumns=3, adjustableColumn=2,
                            columnWidth3=(85, 225, 70))
            cmds.text(label=self.MAP_LABELS[slot] + ":")
            field = cmds.textField(editable=False, placeholderText="not loaded")
            self.map_path_fields[slot] = field
            cmds.button(label="Load...",
                        command=functools.partial(self._on_load_map, slot))
            cmds.setParent("..")  # back to section2 columnLayout

        cmds.setParent("..")  # back to frameLayout
        cmds.setParent("..")  # back to main columnLayout

        # --- Section 3: Skydome Light ----------------------------------------
        cmds.frameLayout(label="3. Scene Lighting (Skydome)", collapsable=True,
                          marginHeight=8, marginWidth=8)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=4)

        self.arnold_viewport_checkbox = cmds.checkBox(
            label="Arnold Viewport Renderer", value=False,
            changeCommand=self._on_toggle_arnold_viewport)

        cmds.button(label="Create Skydome Light", height=28,
                    command=self._on_create_skydome)

        self.intensity_slider = cmds.floatSliderGrp(
            label="Intensity", field=True, minValue=0.0, maxValue=20.0,
            value=1.0, columnWidth3=(60, 50, 220),
            changeCommand=self._on_intensity_change,
            dragCommand=self._on_intensity_change)

        self.exposure_slider = cmds.floatSliderGrp(
            label="Exposure", field=True, minValue=-5.0, maxValue=10.0,
            value=0.0, columnWidth3=(60, 50, 220),
            changeCommand=self._on_exposure_change,
            dragCommand=self._on_exposure_change)

        self.rotation_slider = cmds.floatSliderGrp(
            label="Rotation Y", field=True, minValue=-180.0, maxValue=180.0,
            value=0.0, columnWidth3=(60, 50, 220),
            changeCommand=self._on_rotation_change,
            dragCommand=self._on_rotation_change)

        self.color_slider = cmds.colorSliderGrp(
            label="Color", rgb=(1.0, 1.0, 1.0), columnWidth3=(60, 50, 220),
            changeCommand=self._on_color_change)

        cmds.setParent("..")  # back to frameLayout
        cmds.setParent("..")  # back to main columnLayout

        cmds.text(label="", height=4)
        self.status_text = cmds.text(label="Ready.", align="left", height=20,
                                      wordWrap=True)

        cmds.showWindow(window)

    # ------------------------------------------------------------------
    # Section 1 callbacks: FBX import + material creation
    # ------------------------------------------------------------------
    def _on_browse_and_import_fbx(self, *_args):
        result = cmds.fileDialog2(fileMode=1, fileFilter=FBX_FILTER,
                                   caption="Select FBX to import")
        if not result:
            return
        path = result[0]
        cmds.textField(self.fbx_path_field, edit=True, text=path)
        base = os.path.splitext(os.path.basename(path))[0]
        cmds.textField(self.material_name_field, edit=True, text="M_" + base)

        if not _ensure_plugin_loaded("fbxmaya"):
            self._set_status("Could not load the FBX plug-in (fbxmaya). "
                              "Check the Plug-in Manager.", warning=True)
            return

        if not os.path.isfile(path):
            self._set_status("Selected FBX path is not a valid file.",
                              warning=True)
            return

        before = set(cmds.ls(dagObjects=True, long=True) or [])
        try:
            cmds.file(path, i=True, type="FBX", ignoreVersion=True,
                      mergeNamespacesOnClash=False, preserveReferences=True)
        except Exception as exc:
            self._set_status("FBX import failed: {}".format(exc), warning=True)
            return

        after = set(cmds.ls(dagObjects=True, long=True) or [])
        new_nodes = after - before
        new_transforms = [
            n for n in new_nodes
            if cmds.objExists(n) and cmds.nodeType(n) == "transform"
            and cmds.listRelatives(n, shapes=True, type="mesh")
        ]
        self.imported_transforms = new_transforms

        if not new_transforms:
            self._set_status("FBX imported, but no mesh transforms were "
                              "detected among the new nodes.", warning=True)
        else:
            self._set_status("Imported {} mesh object(s). Ready to create "
                              "the material.".format(len(new_transforms)))

    def _on_create_material(self, *_args):
        if not _ensure_plugin_loaded("mtoa"):
            self._set_status("Arnold (mtoa) is not available. Load it via "
                              "the Plug-in Manager.", warning=True)
            return

        name = cmds.textField(self.material_name_field, query=True,
                               text=True).strip()
        if not name:
            name = "M_TripoPBR"
        name = name.replace(" ", "_")

        if cmds.objExists(name):
            self._set_status(
                "A node named '{}' already exists -- pick a different "
                "material name.".format(name), warning=True)
            return

        self.material_name = name
        self.shader = cmds.shadingNode("aiStandardSurface", asShader=True,
                                        name=name)
        self.shading_group = cmds.sets(renderable=True, noSurfaceShader=True,
                                        empty=True, name=name + "SG")
        cmds.connectAttr(self.shader + ".outColor",
                          self.shading_group + ".surfaceShader", force=True)

        if self.imported_transforms:
            cmds.sets(self.imported_transforms, edit=True,
                      forceElement=self.shading_group)
            assign_msg = ("Created '{}' and assigned it to the imported "
                          "mesh.".format(name))
        else:
            assign_msg = ("Created '{}'. No imported mesh to assign yet -- "
                          "import an FBX first.".format(name))

        auto_msg = self._auto_load_maps_from_fbx_location()
        if auto_msg:
            self._set_status(assign_msg + " " + auto_msg)
        else:
            self._set_status(assign_msg + " Load your texture maps in "
                              "Section 2.")

    # ------------------------------------------------------------------
    # Section 2 callbacks: texture map loading
    # ------------------------------------------------------------------
    def _on_load_map(self, slot, *_args):
        if not self.shader or not cmds.objExists(self.shader):
            self._set_status("Create the material first (Section 1).",
                              warning=True)
            return

        result = cmds.fileDialog2(
            fileMode=1, fileFilter=IMAGE_FILTER,
            caption="Select {} texture".format(self.MAP_LABELS[slot]))
        if not result:
            return

        self._connect_map(slot, result[0])
        self._set_status("{} loaded and connected.".format(
            self.MAP_LABELS[slot]))

    def _connect_map(self, slot, filepath):
        """Wire filepath into the given map slot on the current shader,
        clearing out any previously connected node for that slot first."""
        self._clear_map_slot(slot)

        color_space = "sRGB" if slot == "base_color" else "Raw"
        file_node, _place2d = self._make_file_node(
            filepath, "{}_{}_file".format(self.material_name, slot),
            color_space)

        if slot == "base_color":
            cmds.connectAttr(file_node + ".outColor",
                              self.shader + ".baseColor", force=True)
        elif slot == "roughness":
            cmds.connectAttr(file_node + ".outColorR",
                              self.shader + ".specularRoughness", force=True)
        elif slot == "metallic":
            cmds.connectAttr(file_node + ".outColorR",
                              self.shader + ".metalness", force=True)
        elif slot == "normal":
            bump = cmds.shadingNode(
                "bump2d", asUtility=True,
                name="{}_normalBump".format(self.material_name))
            cmds.setAttr(bump + ".bumpInterp", 1)  # Tangent Space Normals
            cmds.connectAttr(file_node + ".outAlpha",
                              bump + ".bumpValue", force=True)
            cmds.connectAttr(bump + ".outNormal",
                              self.shader + ".normalCamera", force=True)
            self.bump_node = bump

        self.map_file_nodes[slot] = file_node
        cmds.textField(self.map_path_fields[slot], edit=True,
                        text=os.path.basename(filepath))

    def _find_textures_dir(self, fbx_path):
        """Look for a 'Textures' folder (case-insensitive) next to the
        given FBX file. Returns the full path, or None if not found."""
        fbx_dir = os.path.dirname(fbx_path)
        try:
            entries = os.listdir(fbx_dir)
        except OSError:
            return None

        for entry in entries:
            full_path = os.path.join(fbx_dir, entry)
            if os.path.isdir(full_path) and entry.lower() == "textures":
                return full_path
        return None

    def _auto_load_maps_from_fbx_location(self):
        """Look for a Textures folder next to the imported FBX and, for
        any of the four map types, auto-connect the first file whose name
        ends with a recognized suffix (_Color/_C, _Roughness/_R, etc).
        Returns a human-readable summary string, or None if nothing was
        found/attempted."""
        fbx_path = cmds.textField(self.fbx_path_field, query=True, text=True)
        if not fbx_path or not os.path.isfile(fbx_path):
            return None

        textures_dir = self._find_textures_dir(fbx_path)
        if not textures_dir:
            return None

        try:
            files = [f for f in os.listdir(textures_dir)
                      if f.lower().endswith(IMAGE_EXTENSIONS)]
        except OSError:
            return None

        connected_labels = []
        for slot, suffix_list in AUTO_DETECT_SUFFIXES.items():
            match_path = None
            for suffix in suffix_list:
                for f in files:
                    name_no_ext = os.path.splitext(f)[0]
                    if name_no_ext.endswith(suffix):
                        match_path = os.path.join(textures_dir, f)
                        break
                if match_path:
                    break

            if match_path:
                self._connect_map(slot, match_path)
                connected_labels.append(self.MAP_LABELS[slot])

        if connected_labels:
            return ("Auto-detected textures in '{}': connected {}.".format(
                os.path.basename(textures_dir), ", ".join(connected_labels)))
        return ("Found a Textures folder next to the FBX, but no filenames "
                "matched the expected suffixes (_Color, _Roughness, "
                "_Metallic, _Normal, or their _C/_R/_M/_N short forms).")

    def _clear_map_slot(self, slot):
        existing = self.map_file_nodes.get(slot)
        if existing and cmds.objExists(existing):
            conns = cmds.listConnections(existing, type="place2dTexture") or []
            cmds.delete([existing] + conns)
        if slot == "normal" and self.bump_node and cmds.objExists(self.bump_node):
            cmds.delete(self.bump_node)
            self.bump_node = None

    @staticmethod
    def _make_file_node(filepath, node_name, color_space):
        file_node = cmds.shadingNode("file", asTexture=True,
                                      isColorManaged=True, name=node_name)
        cmds.setAttr(file_node + ".fileTextureName", filepath, type="string")
        try:
            cmds.setAttr(file_node + ".colorSpace", color_space, type="string")
        except Exception:
            cmds.warning("Could not set color space on {}; verify it "
                         "manually in the Attribute Editor.".format(file_node))

        place2d = cmds.shadingNode("place2dTexture", asUtility=True,
                                    name=node_name + "_place2d")
        for src_attr, dst_attr in PLACE2D_ATTR_PAIRS:
            cmds.connectAttr(place2d + "." + src_attr,
                              file_node + "." + dst_attr, force=True)
        cmds.connectAttr(place2d + ".outUV", file_node + ".uvCoord", force=True)
        cmds.connectAttr(place2d + ".outUvFilterSize",
                          file_node + ".uvFilterSize", force=True)

        return file_node, place2d

    # ------------------------------------------------------------------
    # Section 3 callbacks: viewport renderer + skydome light
    # ------------------------------------------------------------------
    @staticmethod
    def _get_active_model_panel():
        panel = cmds.getPanel(withFocus=True)
        if panel and cmds.getPanel(typeOf=panel) == "modelPanel":
            return panel
        panels = cmds.getPanel(type="modelPanel") or []
        return panels[0] if panels else None

    def _on_toggle_arnold_viewport(self, *_args):
        enable = cmds.checkBox(self.arnold_viewport_checkbox, query=True,
                                value=True)
        panel = self._get_active_model_panel()
        if not panel:
            self._set_status("No viewport panel found to switch.",
                              warning=True)
            return

        if enable:
            if not _ensure_plugin_loaded("mtoa"):
                self._set_status("Arnold (mtoa) is not available. Load it "
                                  "via the Plug-in Manager.", warning=True)
                cmds.checkBox(self.arnold_viewport_checkbox, edit=True,
                              value=False)
                return

            overrides = cmds.modelEditor(panel, query=True,
                                          rendererOverrideList=True) or []
            arnold_override = next(
                (o for o in overrides if "arnold" in o.lower()), None)

            if not arnold_override:
                self._set_status(
                    "No Arnold viewport renderer override was found on "
                    "this panel. Make sure Arnold is loaded and try again.",
                    warning=True)
                cmds.checkBox(self.arnold_viewport_checkbox, edit=True,
                              value=False)
                return

            cmds.modelEditor(panel, edit=True,
                              rendererOverrideName=arnold_override)
            self._set_status(
                "Viewport renderer set to Arnold. If it renders black, "
                "create/check the Skydome light below.")
        else:
            cmds.modelEditor(panel, edit=True, rendererOverrideName="")
            self._set_status("Viewport renderer reverted to Viewport 2.0.")

    def _on_create_skydome(self, *_args):
        if not _ensure_plugin_loaded("mtoa"):
            self._set_status("Arnold (mtoa) is not available. Load it via "
                              "the Plug-in Manager.", warning=True)
            return

        if self.skydome_shape and cmds.objExists(self.skydome_shape):
            transform_to_delete = self.skydome_transform
            if transform_to_delete and cmds.objExists(transform_to_delete):
                cmds.delete(transform_to_delete)
            elif cmds.objExists(self.skydome_shape):
                cmds.delete(self.skydome_shape)
            self.skydome_shape = None
            self.skydome_transform = None

        created_node = cmds.shadingNode("aiSkyDomeLight", asLight=True)

        if cmds.nodeType(created_node) == "aiSkyDomeLight":
            # shadingNode returned the light shape; find its transform parent.
            light_shape = created_node
            parents = cmds.listRelatives(light_shape, parent=True,
                                          fullPath=True) or []
            if not parents:
                self._set_status("Skydome created but its transform parent "
                                  "could not be found.", warning=True)
                return
            light_transform = parents[0]
        else:
            # shadingNode returned the transform; find the shape child.
            light_transform = created_node
            shapes = cmds.listRelatives(light_transform, shapes=True,
                                         fullPath=True,
                                         type="aiSkyDomeLight") or []
            if not shapes:
                self._set_status("Skydome created but its shape node could "
                                  "not be found.", warning=True)
                return
            light_shape = shapes[0]

        self.skydome_shape = light_shape
        self.skydome_transform = light_transform

        # Sync the light to whatever the sliders currently show.
        self._on_intensity_change()
        self._on_exposure_change()
        self._on_rotation_change()
        self._on_color_change()

        self._set_status("Skydome light created (any previous one was "
                          "replaced). Use the sliders below to adjust it "
                          "live.")

    def _on_intensity_change(self, *_args):
        if self.skydome_shape and cmds.objExists(self.skydome_shape):
            value = cmds.floatSliderGrp(self.intensity_slider, query=True,
                                         value=True)
            cmds.setAttr(self.skydome_shape + ".intensity", value)

    def _on_exposure_change(self, *_args):
        if self.skydome_shape and cmds.objExists(self.skydome_shape):
            value = cmds.floatSliderGrp(self.exposure_slider, query=True,
                                         value=True)
            cmds.setAttr(self.skydome_shape + ".aiExposure", value)

    def _on_rotation_change(self, *_args):
        if self.skydome_transform and cmds.objExists(self.skydome_transform):
            value = cmds.floatSliderGrp(self.rotation_slider, query=True,
                                         value=True)
            cmds.setAttr(self.skydome_transform + ".rotateY", value)

    def _on_color_change(self, *_args):
        if self.skydome_shape and cmds.objExists(self.skydome_shape):
            r, g, b = cmds.colorSliderGrp(self.color_slider, query=True,
                                           rgbValue=True)
            cmds.setAttr(self.skydome_shape + ".color", r, g, b,
                         type="double3")

    # ------------------------------------------------------------------
    def _set_status(self, message, warning=False):
        if self.status_text and cmds.text(self.status_text, exists=True):
            cmds.text(self.status_text, edit=True, label=message)
        if warning:
            cmds.warning(message)
        else:
            print(message)


def launch():
    """Open the Tripo PBR Material Builder window."""
    if cmds is None:
        raise RuntimeError("This script must be run inside Maya.")
    return TripoPBRBuilder()


if __name__ == "__main__":
    if cmds is not None:
        launch()
