"""
GELOS VAT Setup for UEFN
========================
A small window that sets up what the GELOS Hub VAT Baker exported: it imports the static mesh
and the three bone animation textures, applies the texture and mesh settings, creates the
material instances and fills in their values. The mesh is imported without the materials of
the file: the material instances of your VAT material take their place.

How to use it
-------------
1. In UEFN, turn on Python Editor Scripting in the Project Settings (once per project).
2. Run this file: Tools > Execute Python Script. The window opens.
3. Click Browse next to "Values (.json)" and pick the <Name>_VAT.json file from the unzipped
   download. The mesh and the three textures next to it are filled in for you.
4. Select your VAT material in the Content Browser and click "Use selected".
5. Content folder: open the folder you want in the Content Browser and click "Use open folder",
   or pick one of your project's folders from the list.
6. Click "Set up in UEFN" and read the list at the bottom of the window.

The tool only works on your own project and your own files. It does not connect to anything.
Running it again on the same export updates the same assets instead of creating new ones.
"""

import json
import os

import unreal

TOOL_NAME = "GELOS VAT Setup"
TOOL_VERSION = "1.3"

# --------------------------------------------------------------------------- parameter names
# The names of Unreal Engine's AnimToTexture bone animation material. The first name of each
# group that the parent material has is used; names it does not have are reported.

SCALARS = {
    "NumBones": ["NumBones"],
    "BoneWeightsRowsPerFrame": ["BoneWeightsRowsPerFrame", "BoneWeightRowsPerFrame"],
    "RowsPerFrame": ["RowsPerFrame"],
    "SampleRate": ["SampleRate"],
    "NumFrames": ["NumFrames"],
}
VECTORS = {
    "MinBBox": ["MinBBox"],
    "SizeBBox": ["SizeBBox"],
}
TEXTURES = {
    "position": ["BonePositionTexture"],
    "rotation": ["BoneRotationTexture"],
    "weights": ["BoneWeightsTexture", "BoneWeightTexture"],
}
START_FRAME = ["StartFrame"]
END_FRAME = ["EndFrame"]
# Parameters a material may leave out without harm; they are not listed as missing.
OPTIONAL = {"RowsPerFrame", "NumFrames"}

FORMAT = "gelos-vat"
SUFFIX = "_vat.json"  # how the baker names its JSON files, compared in lower case
LOG = "[GELOS VAT] "
TEXTURE_KEYS = ("position", "rotation", "weights")
FILE_KEYS = ("mesh",) + TEXTURE_KEYS
FILE_LABELS = {"mesh": "Mesh", "position": "Position texture", "rotation": "Rotation texture", "weights": "Weights texture"}


class Report:
    """Collects what happened, for the list in the window and the Output Log."""

    def __init__(self):
        self.done = []
        self.todo = []
        self.failed = []

    def ok(self, text):
        self.done.append(text)
        unreal.log(LOG + text)

    def left(self, text):
        self.todo.append(text)
        unreal.log_warning(LOG + text)

    def fail(self, text):
        self.failed.append(text)
        unreal.log_error(LOG + text)

    def summary(self):
        return "%d done, %d left for you, %d failed." % (len(self.done), len(self.todo), len(self.failed))

    def lines(self):
        """Everything, most important first."""
        return ["FAILED: " + t for t in self.failed] + ["TO DO: " + t for t in self.todo] + ["Done: " + t for t in self.done]


# --------------------------------------------------------------------------- reading an export

def script_folder():
    """The folder this script was run from."""
    try:
        return os.path.dirname(os.path.abspath(__file__))
    except NameError:
        return os.getcwd()


def read_export(json_path):
    """The contents of a VAT Baker .json file. Raises ValueError with a readable message."""
    try:
        with open(json_path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except OSError as error:
        raise ValueError("Could not open %s: %s" % (json_path, error))
    except ValueError:
        raise ValueError("%s is not a valid .json file." % os.path.basename(json_path))
    if not isinstance(data, dict) or data.get("format") != FORMAT:
        raise ValueError("%s was not made by the GELOS VAT Baker." % os.path.basename(json_path))
    return data


def files_of_export(json_path, data):
    """Where the mesh and the three textures of an export are: next to its .json file."""
    folder = os.path.dirname(os.path.abspath(json_path))
    names = {"mesh": data.get("mesh", {}).get("file", "")}
    for key in TEXTURE_KEYS:
        names[key] = data.get("textures", {}).get(key, {}).get("file", "")
    return dict((key, os.path.join(folder, name) if name else "") for key, name in names.items())


def find_exports(folder):
    """The VAT Baker JSON files in the folder and in the folders directly below it."""
    found = []
    for root, dirs, files in os.walk(folder):
        if os.path.abspath(root) != os.path.abspath(folder):
            dirs[:] = []  # one level down is enough; a Downloads folder can be huge
        for name in sorted(files):
            if not name.lower().endswith(SUFFIX):
                continue
            path = os.path.join(root, name)
            try:
                found.append((path, read_export(path)))
            except ValueError:
                continue
    return found


def describe(data):
    """One line about an export, for the list in the window."""
    values = data.get("material", {})
    mesh = data.get("mesh", {})
    return "%s: %s bones, %s frames at %s fps, %d animations, %d material slot(s)" % (
        data.get("name", "Mesh"), values.get("NumBones", "?"), values.get("NumFrames", "?"), values.get("SampleRate", "?"),
        len(data.get("animations", [])), len(mesh.get("materialSlots") or ["Material"]))


# --------------------------------------------------------------------------- the project

def editor_world():
    """The level open in the editor, or None."""
    for get in (lambda: unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world(),
                lambda: unreal.EditorLevelLibrary.get_editor_world()):
        try:
            world = get()
            if world:
                return world
        except Exception:
            continue
    return None


def project_root():
    """
    The content root of the open project, such as "/MyProject", or "" when it cannot be told.
    UEFN runs every project inside Fortnite, so the project file it reports is Fortnite's own.
    The open level is what lives in the project: its path starts with the project's root.
    """
    world = editor_world()
    if world is not None:
        parts = world.get_path_name().split("/")
        if len(parts) > 2 and parts[1] and parts[1] not in ("Temp", "Engine", "Script"):
            return "/" + parts[1]
    return ""


def root_of(path):
    """ "/MyProject" for "/MyProject/Materials/M_VAT". """
    parts = (path or "").split("/")
    return "/" + parts[1] if len(parts) > 2 and parts[1] else ""


def inside(path, root):
    return bool(root) and (path == root or path.startswith(root + "/"))


def clean_folder(path):
    """A content folder as typed or reported: forward slashes, no slash at the end."""
    parts = [part.strip() for part in (path or "").replace("\\", "/").split("/")]
    parts = [part for part in parts if part]
    return "/" + "/".join(parts) if parts else ""


def project_folders(root):
    """The folders of the project, for the list in the window."""
    if not root:
        return []
    try:
        paths = unreal.AssetRegistryHelpers.get_asset_registry().get_sub_paths(root, True)
    except Exception:
        return []
    # "__ExternalActors__" and "__ExternalObjects__" are the editor's own storage, thousands of folders
    return sorted(set(str(path) for path in paths if "/__" not in str(path)), key=lambda path: path.lower())


def browser_folder(root=""):
    """The folder open in the Content Browser, as a content path such as "/MyProject/Pets". "" if there is none."""
    library = unreal.EditorUtilityLibrary
    found = []
    try:
        current = library.get_current_content_browser_path()
        if isinstance(current, (tuple, list)):
            current = next((item for item in current if isinstance(item, str)), "")
        if current:
            found.append(str(current))
    except Exception:
        pass
    for name in ("get_selected_path_view_folder_paths", "get_selected_folder_paths"):
        try:
            found += [str(path) for path in getattr(library, name)()]
        except Exception:
            continue
    for path in found:
        path = clean_folder(path)
        # the folder tree reports "/All/..." style paths: the part from the project's root on is the content path
        if root and not inside(path, root):
            at = (path + "/").find(root + "/")
            path = path[at:] if at >= 0 else ""
        if path and (not root or inside(path, root)):
            return path
    return ""


def content_path(asset):
    """ "/MyProject/Materials/M_VAT" for an asset. """
    return asset.get_path_name().split(".")[0]


def selected_material():
    """The first material or material instance selected in the Content Browser."""
    try:
        for asset in unreal.EditorUtilityLibrary.get_selected_assets():
            if isinstance(asset, unreal.MaterialInterface):
                return asset
    except Exception:
        pass
    return None


def default_folder(parent_path=""):
    """ "<project>/VAT". Without an open level to tell the project by, the project the parent material is in. """
    return "%s/VAT" % (project_root() or root_of(parent_path) or "/Game")


def default_destination(data, parent_path=""):
    """ "<project>/VAT/<mesh name>". """
    return "%s/%s" % (default_folder(parent_path), data.get("name", "Mesh"))


def load(path):
    try:
        if path and unreal.EditorAssetLibrary.does_asset_exist(path):
            return unreal.EditorAssetLibrary.load_asset(path)
    except Exception:
        pass
    return None


def load_material(path):
    """The material or material instance at a content path, or None."""
    asset = load(path.strip().split(".")[0]) if path else None
    return asset if isinstance(asset, unreal.MaterialInterface) else None


# --------------------------------------------------------------------------- the work

def find_imported(folder, name, kind):
    """The asset an import created: by its expected name, or the only one of its kind in the folder."""
    asset = load(folder + "/" + name)
    if isinstance(asset, kind):
        return asset
    try:
        paths = unreal.EditorAssetLibrary.list_assets(folder, recursive=True, include_folder=False)
    except Exception:
        paths = []
    matches = []
    for path in paths:
        candidate = load(str(path).split(".")[0])
        if isinstance(candidate, kind):
            if candidate.get_name() == name:
                return candidate
            matches.append(candidate)
    return matches[0] if len(matches) == 1 else None


def without_materials(pipeline):
    """Turns off materials and textures on one of the editor's import settings. False if it has no such setting."""
    try:
        materials = pipeline.get_editor_property("material_pipeline")
        materials.set_editor_property("import_materials", False)
    except Exception:
        return False
    try:
        materials.get_editor_property("texture_pipeline").set_editor_property("import_textures", False)
    except Exception:
        pass  # the baker's mesh files carry no textures; the materials are what matters
    return True


def mesh_import_options(path):
    """
    Import options that keep the importer from making materials for the mesh: the VAT material instances
    take their place. They are the editor's own import settings for this file with only materials and
    textures turned off, so the mesh itself comes in as before.
    Returns (options, objects to keep until the import is done). The options are None when this editor
    does not let a script do that; run_setup then removes the materials the import made.
    """
    try:
        source = unreal.InterchangeManager.create_source_data(path)
        current = unreal.InterchangeManager.get_interchange_manager_scripted().can_translate_source_data(source)
    except Exception:
        return None, []
    if current:
        try:
            pipelines = list(unreal.InterchangeProjectSettingsScript.get_pipeline_stack_from_source_data(False, source))
            if not any([without_materials(pipeline) for pipeline in pipelines]):
                return None, []
            options = unreal.InterchangePipelineStackOverride()
            for pipeline in pipelines:
                options.add_pipeline(pipeline)
            return options, pipelines
        except Exception:
            return None, []
    try:  # the older FBX importer
        options = unreal.FbxImportUI()
        options.set_editor_property("import_materials", False)
        options.set_editor_property("import_textures", False)
        return options, []
    except Exception:
        return None, []


def asset_paths(folder):
    """The content paths of the assets in a folder and below it."""
    try:
        return set(str(path).split(".")[0] for path in unreal.EditorAssetLibrary.list_assets(folder, recursive=True, include_folder=False))
    except Exception:
        return set()


def materials_made_by_import(folder, before):
    """The materials in the content folder that were not there before the import: the ones the mesh file brought."""
    return [path for path in sorted(asset_paths(folder) - before) if isinstance(load(path), unreal.MaterialInterface)]


def remove_materials(paths, report):
    """Deletes the materials the mesh import made, where this editor made them in spite of the import options."""
    removed = []
    for path in paths:
        name = path.split("/")[-1]
        try:
            deleted = unreal.EditorAssetLibrary.delete_asset(path)
        except Exception:
            deleted = False
        if deleted:
            removed.append(name)
        else:
            report.left("Delete %s by hand: the import made it and nothing needs it." % name)
    if removed:
        report.ok("Removed the materials the import made: " + ", ".join(removed))


def import_files(files, folder, report):
    """Imports files into a content folder. `files` is a list of (file path, asset name, is it the mesh)."""
    tasks = []
    keep = []  # the import settings of the mesh, held until the import is done
    for path, name, is_mesh in files:
        if not path or not os.path.isfile(path):
            report.fail("File not found: " + (path or "(no file chosen)"))
            continue
        task = unreal.AssetImportTask()
        task.set_editor_property("filename", path)
        task.set_editor_property("destination_path", folder)
        task.set_editor_property("destination_name", name)
        task.set_editor_property("replace_existing", True)
        task.set_editor_property("automated", True)
        task.set_editor_property("save", False)
        if is_mesh:
            options, held = mesh_import_options(path)
            if options is not None:
                try:
                    task.set_editor_property("options", options)
                    keep += [options] + held
                except Exception:
                    pass  # imported with the editor's own settings; run_setup removes the materials
        tasks.append(task)
    if tasks:
        unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks(tasks)


def set_properties(asset, values, label, report):
    """Sets editor properties one by one, so one that this editor refuses does not stop the rest."""
    refused = []
    for name, value in values:
        try:
            asset.set_editor_property(name, value)
        except Exception as error:
            refused.append("%s (%s)" % (name, error))
    if refused:
        report.left("%s: set these by hand: %s" % (label, "; ".join(refused)))
    return not refused


def setup_texture(texture, report):
    """The settings bone animation textures need: the pixels are numbers, not colours."""
    label = texture.get_name()
    ok = set_properties(texture, [
        ("compression_settings", unreal.TextureCompressionSettings.TC_HDR),
        ("srgb", False),
        ("filter", unreal.TextureFilter.TF_NEAREST),
        ("mip_gen_settings", unreal.TextureMipGenSettings.TMGS_NO_MIPMAPS),
        ("lod_group", unreal.TextureGroup.TEXTUREGROUP_UI),
        ("never_stream", True),
    ], label, report)
    if ok:
        report.ok("%s: HDR, Nearest, NoMipmaps, UI group, Never Stream" % label)


def setup_mesh(mesh, info, report):
    """Full precision UVs, no lightmap UVs over the VAT channel, bounds that cover the animation."""
    label = mesh.get_name()
    uv_channel = int(info.get("uvChannel", 1))
    try:
        subsystem = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
        settings = subsystem.get_lod_build_settings(mesh, 0)
        settings.set_editor_property("use_full_precision_u_vs", True)
        if settings.get_editor_property("dst_lightmap_index") == uv_channel:
            settings.set_editor_property("generate_lightmap_u_vs", False)
        subsystem.set_lod_build_settings(mesh, 0, settings)
        report.ok("%s: full precision UVs on, UV channel %d kept for the weights texture" % (label, uv_channel))
        try:
            channels = subsystem.get_num_uv_channels(mesh, 0)
            if channels <= uv_channel:
                report.fail("%s has %d UV channel(s); channel %d with the weights UVs is missing after import." % (label, channels, uv_channel))
        except Exception:
            pass
    except Exception as error:
        report.left("%s: turn on Use Full Precision UVs and turn off Generate Lightmap UVs by hand (%s)" % (label, error))

    positive = info.get("positiveBoundsExtension", [0, 0, 0])
    negative = info.get("negativeBoundsExtension", [0, 0, 0])
    if set_properties(mesh, [
        ("positive_bounds_extension", unreal.Vector(positive[0], positive[1], positive[2])),
        ("negative_bounds_extension", unreal.Vector(negative[0], negative[1], negative[2])),
    ], label + " bounds extension (positive %s, negative %s)" % (positive, negative), report):
        report.ok("%s: bounds extension positive %s, negative %s" % (label, positive, negative))


def parameter_names(material):
    """The parameter names a material has, by kind."""
    library = unreal.MaterialEditingLibrary
    names = {}
    for kind, getter in (("scalar", library.get_scalar_parameter_names),
                         ("vector", library.get_vector_parameter_names),
                         ("texture", library.get_texture_parameter_names),
                         ("switch", library.get_static_switch_parameter_names)):
        try:
            names[kind] = set(str(name) for name in getter(material))
        except Exception:
            names[kind] = None  # this editor cannot list them; set them and see
    return names


def pick(candidates, available):
    """The first candidate the material has. Without a list of names, the first candidate."""
    if available is None:
        return candidates[0]
    for name in candidates:
        if name in available:
            return name
    return None


def get_instance(folder, name, report):
    """An existing material instance of that name, or a new one."""
    existing = load(folder + "/" + name)
    if isinstance(existing, unreal.MaterialInstanceConstant):
        return existing
    try:
        tools = unreal.AssetToolsHelpers.get_asset_tools()
        return tools.create_asset(name, folder, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew())
    except Exception as error:
        report.fail("Could not create the material instance %s: %s" % (name, error))
        return None


def fill_instance(instance, parent, data, textures, start_animation, report):
    """Sets parent, values, textures and switches on one material instance."""
    library = unreal.MaterialEditingLibrary
    label = instance.get_name()
    library.set_material_instance_parent(instance, parent)
    names = parameter_names(parent)
    values = data.get("material", {})
    missing = []

    for key, candidates in SCALARS.items():
        if key not in values:
            continue
        name = pick(candidates, names["scalar"])
        if name:
            library.set_material_instance_scalar_parameter_value(instance, name, float(values[key]))
        elif key not in OPTIONAL:
            missing.append(candidates[0])

    for key, candidates in VECTORS.items():
        if key not in values:
            continue
        name = pick(candidates, names["vector"])
        if name:
            x, y, z = values[key]
            library.set_material_instance_vector_parameter_value(instance, name, unreal.LinearColor(x, y, z, 1.0))
        else:
            missing.append(candidates[0])

    for key, candidates in TEXTURES.items():
        texture = textures.get(key)
        name = pick(candidates, names["texture"])
        if name and texture:
            library.set_material_instance_texture_parameter_value(instance, name, texture)
        elif not name:
            missing.append(candidates[0])

    animations = data.get("animations", [])
    if animations:
        animation = animations[min(max(int(start_animation), 0), len(animations) - 1)]
        for candidates, value in ((START_FRAME, animation["startFrame"]), (END_FRAME, animation["endFrame"])):
            name = pick(candidates, names["scalar"])
            if name:
                library.set_material_instance_scalar_parameter_value(instance, name, float(value))
            else:
                missing.append(candidates[0])

    # static switches: which UV channel holds the weights UVs, and four bones per vertex
    uv_channel = int(data.get("mesh", {}).get("uvChannel", 1))
    influences = int(values.get("BoneInfluences", 4))
    switches = [("UseUV%d" % channel, channel == uv_channel) for channel in range(4)]
    switches += [("UseTwoInfluences", influences == 2), ("UseFourInfluences", influences == 4), ("AutoPlay", True)]
    for name, value in switches:
        if names["switch"] is None or name in names["switch"]:
            try:
                library.set_material_instance_static_switch_parameter_value(instance, name, value)
            except Exception:
                if value:
                    missing.append(name)
        elif value and name != "AutoPlay":
            missing.append(name)

    library.update_material_instance(instance)
    if missing:
        report.left("%s: the parent material has no parameter named %s. Set what they stand for by hand." % (label, ", ".join(sorted(set(missing)))))
    else:
        report.ok("%s: values, textures and switches set" % label)


def save_assets(assets, report):
    for asset in assets:
        try:
            unreal.EditorAssetLibrary.save_loaded_asset(asset, False)
        except Exception as error:
            report.left("Save %s by hand (%s)" % (asset.get_name(), error))


def run_setup(data, files, parent, destination, report, start_animation=0, assign=True, save=True):
    """
    Imports and sets up one export.
    `files` maps "mesh", "position", "rotation" and "weights" to file paths.
    `parent` is the VAT material or material instance, or None to skip the material instances.
    """
    name = data.get("name", "Mesh")
    mesh_info = data.get("mesh", {})
    texture_info = data.get("textures", {})
    destination = (destination or default_destination(data, content_path(parent) if parent else "")).rstrip("/")
    report.ok("%s -> %s" % (name, destination))

    def asset_name(key):
        info = mesh_info if key == "mesh" else texture_info.get(key, {})
        fallback = os.path.splitext(os.path.basename(files.get(key, "")))[0]
        return info.get("assetName") or fallback

    before = asset_paths(destination)
    import_files([(files.get(key, ""), asset_name(key), key == "mesh") for key in FILE_KEYS], destination, report)
    unwanted = materials_made_by_import(destination, before)

    changed = []
    textures = {}
    for key in TEXTURE_KEYS:
        texture = find_imported(destination, asset_name(key), unreal.Texture)
        if texture is None:
            report.fail("The %s texture was not imported." % key)
            continue
        textures[key] = texture
        setup_texture(texture, report)
        changed.append(texture)

    mesh = find_imported(destination, asset_name("mesh"), unreal.StaticMesh)
    if mesh is None:
        report.fail("The mesh %s was not imported as a static mesh." % os.path.basename(files.get("mesh", "")))
    else:
        setup_mesh(mesh, mesh_info, report)
        changed.append(mesh)

    if parent is None:
        report.left("No VAT material was chosen, so no material instances were made for %s." % name)
    else:
        slots = mesh_info.get("materialSlots") or ["Material"]
        for index, slot in enumerate(slots):
            instance_name = "MI_%s" % name if len(slots) == 1 else "MI_%s_%s" % (name, slot)
            instance = get_instance(destination, instance_name, report)
            if instance is None:
                continue
            fill_instance(instance, parent, data, textures, start_animation, report)
            changed.append(instance)
            if assign and mesh is not None:
                try:
                    mesh.set_material(index, instance)
                except Exception as error:
                    report.left("Put %s on material slot %d of %s by hand (%s)" % (instance_name, index, mesh.get_name(), error))
        if len(slots) > 1:
            report.left("%s has %d material slots. Each instance got the animation values; give each one its own colour textures." % (name, len(slots)))

    # after the instances are on the mesh, so nothing still points at what is deleted
    remove_materials(unwanted, report)

    animations = data.get("animations", [])
    if animations:
        report.ok("Animations of %s (name: StartFrame - EndFrame): %s" % (name, "; ".join("%s: %s - %s" % (a["name"], a["startFrame"], a["endFrame"]) for a in animations)))

    if save:
        save_assets(changed, report)
    return report


# --------------------------------------------------------------------------- remembered settings

def settings_file():
    return os.path.join(script_folder(), "gelos_vat_setup_settings.json")


def read_settings():
    try:
        with open(settings_file(), "r", encoding="utf-8") as handle:
            data = json.load(handle)
        return data if isinstance(data, dict) else {}
    except (OSError, ValueError):
        return {}


def write_settings(values):
    try:
        with open(settings_file(), "w", encoding="utf-8") as handle:
            json.dump(values, handle, indent=2)
    except OSError:
        pass  # a read-only folder: the tool works without remembering


# --------------------------------------------------------------------------- the window

UI_SCALES = ["1x", "1.25x", "1.5x", "1.75x", "2x"]
COLORS = {
    "window": "#151515",
    "panel": "#202020",
    "input": "#0f0f0f",
    "input_border": "#3a3a3a",
    "text": "#d7d7d7",
    "muted": "#a8a8a8",
    "button": "#262626",
    "button_hover": "#333333",
    "accent": "#2f6fed",
    "accent_hover": "#4a84f5",
    "button_text": "#f0f0f0",
    "ok": "#8fd18f",
    "todo": "#e6c36a",
    "fail": "#f08a8a",
}


class VatSetupWindow:
    """The tool's window. UEFN keeps it alive by updating it once per editor frame."""

    def __init__(self):
        import tkinter as tk
        from tkinter import filedialog, messagebox, ttk

        self.tk = tk
        self.ttk = ttk
        self.filedialog = filedialog
        self.messagebox = messagebox
        self.settings = read_settings()
        self.data = None
        self._tick_handle = None

        self.root = tk.Tk()
        self.root.title("%s %s" % (TOOL_NAME, TOOL_VERSION))
        self.root.attributes("-topmost", True)
        self.root.configure(bg=COLORS["window"])
        self.scale = self._scale_from(self.settings.get("ui_scale", "")) or self._detect_scale()
        try:
            self.root.tk.call("tk", "scaling", (96.0 / 72.0) * self.scale)
        except Exception:
            pass
        self.root.option_add("*Font", self._font(9))
        for option, colour in (("background", "input"), ("foreground", "text"), ("selectBackground", "accent"), ("selectForeground", "button_text")):
            self.root.option_add("*TCombobox*Listbox." + option, COLORS[colour])
        self._style()

        self.vars = dict((key, tk.StringVar(self.root)) for key in ("json",) + FILE_KEYS + ("material", "destination", "animation", "ui_scale"))
        self.vars["material"].set(self.settings.get("material", ""))
        self.project = project_root()
        remembered = clean_folder(self.settings.get("destination", ""))
        self.vars["destination"].set(remembered if inside(remembered, self.project) else default_folder(self.vars["material"].get()))
        self.subfolder = tk.BooleanVar(self.root, value=bool(self.settings.get("subfolder", True)))
        self.vars["ui_scale"].set(self._scale_label(self.scale))
        self.assign = tk.BooleanVar(self.root, value=bool(self.settings.get("assign", True)))
        self.save = tk.BooleanVar(self.root, value=bool(self.settings.get("save", True)))

        pad = self._px(8)
        row = 0
        tk.Label(self.root, text="Pick the .json file from your VAT Baker download. The mesh and textures next to it are filled in.",
                 bg=COLORS["window"], fg=COLORS["muted"], anchor="w", justify="left", wraplength=self._px(600)).grid(row=row, column=0, columnspan=3, padx=pad, pady=(pad, self._px(4)), sticky="ew")

        row += 1
        self._file_row(row, "Values (.json)", "json", self.browse_json)
        for key in FILE_KEYS:
            row += 1
            self._file_row(row, FILE_LABELS[key], key, lambda k=key: self.browse_file(k))

        row += 1
        self._label(row, "VAT material")
        self._entry(row, "material")
        self._button(row, "Use selected", self.use_selected)

        row += 1
        self._label(row, "Content folder")
        self.folder_box = ttk.Combobox(self.root, textvariable=self.vars["destination"], values=[], style="VAT.TCombobox", postcommand=self.list_folders)
        self.folder_box.grid(row=row, column=1, padx=(0, pad), pady=self._px(3), sticky="ew")
        self._button(row, "Use open folder", self.use_open_folder)

        row += 1
        folder_checks = tk.Frame(self.root, bg=COLORS["window"])
        folder_checks.grid(row=row, column=1, columnspan=2, padx=(0, pad), pady=(0, self._px(3)), sticky="w")
        self._check(folder_checks, "Make a subfolder named after the mesh", self.subfolder)

        row += 1
        self._label(row, "Start animation")
        self.animation_box = ttk.Combobox(self.root, textvariable=self.vars["animation"], state="readonly", values=[], style="VAT.TCombobox")
        self.animation_box.grid(row=row, column=1, padx=(0, pad), pady=self._px(3), sticky="ew")

        row += 1
        checks = tk.Frame(self.root, bg=COLORS["window"])
        checks.grid(row=row, column=1, columnspan=2, padx=(0, pad), pady=self._px(3), sticky="w")
        self._check(checks, "Put the material instances on the mesh", self.assign)
        self._check(checks, "Save the assets", self.save)

        row += 1
        self.run_button = tk.Button(self.root, text="Set up in UEFN", command=self.run, bg=COLORS["accent"], fg=COLORS["button_text"],
                                    activebackground=COLORS["accent_hover"], activeforeground=COLORS["button_text"], relief="flat", bd=0,
                                    padx=self._px(18), pady=self._px(7), font=self._font(10, bold=True), cursor="hand2")
        self.run_button.grid(row=row, column=0, columnspan=3, padx=pad, pady=pad, sticky="ew")

        row += 1
        log_frame = tk.Frame(self.root, bg=COLORS["input_border"])
        log_frame.grid(row=row, column=0, columnspan=3, padx=pad, pady=(0, pad), sticky="nsew")
        self.log = tk.Text(log_frame, height=11, width=80, bg=COLORS["input"], fg=COLORS["text"], relief="flat", bd=0, wrap="word",
                           state="disabled", font=self._font(9), padx=self._px(6), pady=self._px(6))
        scroll = tk.Scrollbar(log_frame, command=self.log.yview)
        self.log.configure(yscrollcommand=scroll.set)
        self.log.pack(side="left", fill="both", expand=True, padx=1, pady=1)
        scroll.pack(side="right", fill="y")
        for tag, colour in (("ok", COLORS["ok"]), ("todo", COLORS["todo"]), ("fail", COLORS["fail"]), ("info", COLORS["muted"])):
            self.log.tag_configure(tag, foreground=colour)
        self.root.rowconfigure(row, weight=1)

        row += 1
        footer = tk.Frame(self.root, bg=COLORS["window"])
        footer.grid(row=row, column=0, columnspan=3, padx=pad, pady=(0, pad), sticky="ew")
        tk.Label(footer, text="UI scale", bg=COLORS["window"], fg=COLORS["muted"]).pack(side="left")
        ttk.Combobox(footer, textvariable=self.vars["ui_scale"], state="readonly", values=UI_SCALES, width=6, style="VAT.TCombobox").pack(side="left", padx=self._px(8))
        tk.Button(footer, text="Apply scale", command=self.apply_scale, bg=COLORS["button"], fg=COLORS["button_text"], activebackground=COLORS["button_hover"],
                  activeforeground=COLORS["button_text"], relief="flat", bd=0, padx=self._px(10), pady=self._px(3)).pack(side="left")

        self.root.columnconfigure(1, weight=1)
        self.root.minsize(self._px(620), self._px(520))
        self.root.protocol("WM_DELETE_WINDOW", self.close)
        self.write([("info", "Project: %s" % (self.project or "not found; type the content folder by hand")),
                    ("info", "Ready. Select your VAT material in the Content Browser and click Use selected.")])

    # ---- look
    def _px(self, value):
        return int(round(value * self.scale))

    def _font(self, size, bold=False):
        return ("Segoe UI", size, "bold") if bold else ("Segoe UI", size)

    @staticmethod
    def _scale_from(label):
        try:
            value = float(str(label).lower().replace("x", ""))
            return value if 0.5 <= value <= 4 else None
        except ValueError:
            return None

    @staticmethod
    def _scale_label(value):
        text = ("%.2f" % value).rstrip("0").rstrip(".")
        return text + "x"

    def _detect_scale(self):
        """Windows' display scale, so the window is readable on high-resolution screens."""
        try:
            import ctypes
            factor = float(ctypes.windll.shcore.GetScaleFactorForDevice(0)) / 100.0
            if 1.0 <= factor <= 4.0:
                return factor
        except Exception:
            pass
        return 1.0

    def _style(self):
        style = self.ttk.Style(self.root)
        try:
            style.theme_use("clam")
        except Exception:
            pass
        style.configure("VAT.TCombobox", fieldbackground=COLORS["input"], background=COLORS["button"], foreground=COLORS["text"],
                        arrowcolor=COLORS["muted"], insertcolor=COLORS["text"], bordercolor=COLORS["input_border"], lightcolor=COLORS["input_border"],
                        darkcolor=COLORS["input_border"], padding=self._px(2))
        style.map("VAT.TCombobox", fieldbackground=[("readonly", COLORS["input"])], foreground=[("readonly", COLORS["text"])],
                  selectbackground=[("readonly", COLORS["input"])], selectforeground=[("readonly", COLORS["text"])])

    def _label(self, row, text):
        self.tk.Label(self.root, text=text, bg=COLORS["window"], fg=COLORS["muted"], anchor="w").grid(
            row=row, column=0, padx=self._px(8), pady=self._px(3), sticky="w")

    def _entry(self, row, key):
        entry = self.tk.Entry(self.root, textvariable=self.vars[key], bg=COLORS["input"], fg=COLORS["text"], insertbackground=COLORS["text"],
                              relief="flat", bd=0, highlightthickness=1, highlightbackground=COLORS["input_border"], highlightcolor=COLORS["accent"])
        entry.grid(row=row, column=1, padx=(0, self._px(8)), pady=self._px(3), ipady=self._px(3), sticky="ew")
        return entry

    def _button(self, row, text, command):
        button = self.tk.Button(self.root, text=text, command=command, bg=COLORS["button"], fg=COLORS["button_text"],
                                activebackground=COLORS["button_hover"], activeforeground=COLORS["button_text"], relief="flat", bd=0,
                                padx=self._px(10), pady=self._px(3), width=15)
        button.grid(row=row, column=2, padx=(0, self._px(8)), pady=self._px(3), sticky="ew")
        return button

    def _file_row(self, row, text, key, command):
        self._label(row, text)
        self._entry(row, key)
        self._button(row, "Browse...", command)

    def _check(self, parent, text, variable):
        self.tk.Checkbutton(parent, text=text, variable=variable, bg=COLORS["window"], fg=COLORS["text"], selectcolor=COLORS["input"],
                            activebackground=COLORS["window"], activeforeground=COLORS["text"], bd=0, highlightthickness=0).pack(side="left", padx=(0, self._px(16)))

    def write(self, lines, clear=False):
        """Shows lines in the list at the bottom. Each line is (tag, text)."""
        self.log.configure(state="normal")
        if clear:
            self.log.delete("1.0", "end")
        for tag, text in lines:
            self.log.insert("end", text + "\n", tag)
        self.log.configure(state="disabled")
        self.log.see("end")

    # ---- choosing files
    def _start_folder(self):
        for key in ("json",) + FILE_KEYS:
            path = self.vars[key].get().strip()
            if path and os.path.isdir(os.path.dirname(path)):
                return os.path.dirname(path)
        folder = self.settings.get("folder", "")
        return folder if os.path.isdir(folder) else os.path.expanduser("~")

    def browse_json(self):
        path = self.filedialog.askopenfilename(parent=self.root, title="Pick the <Name>_VAT.json file", initialdir=self._start_folder(),
                                               filetypes=[("VAT Baker values", "*.json"), ("All files", "*.*")])
        if path:
            self.load_json(path)

    def load_json(self, path):
        """Reads an export and fills in everything that follows from it."""
        try:
            data = read_export(path)
        except ValueError as error:
            self.messagebox.showerror(TOOL_NAME, str(error), parent=self.root)
            return False
        self.data = data
        self.vars["json"].set(os.path.normpath(path))
        missing = []
        for key, file_path in files_of_export(path, data).items():
            if file_path and os.path.isfile(file_path):
                self.vars[key].set(os.path.normpath(file_path))
            else:
                self.vars[key].set("")
                missing.append(FILE_LABELS[key])
        names = ["%d: %s (%s - %s)" % (i, a["name"], a["startFrame"], a["endFrame"]) for i, a in enumerate(data.get("animations", []))]
        self.animation_box.configure(values=names)
        self.vars["animation"].set(names[0] if names else "")
        self.settings["folder"] = os.path.dirname(os.path.abspath(path))
        lines = [("info", "Loaded " + describe(data)), ("info", "It goes into " + self.destination())]
        if missing:
            lines.append(("todo", "Not found next to the .json, pick by hand: " + ", ".join(missing)))
        self.write(lines, clear=True)
        return True

    def browse_file(self, key):
        kinds = [("Mesh", "*.fbx *.glb"), ("All files", "*.*")] if key == "mesh" else [("Texture", "*.png"), ("All files", "*.*")]
        path = self.filedialog.askopenfilename(parent=self.root, title="Pick the " + FILE_LABELS[key].lower(), initialdir=self._start_folder(), filetypes=kinds)
        if path:
            self.vars[key].set(os.path.normpath(path))

    def use_selected(self):
        material = selected_material()
        if material is None:
            self.messagebox.showinfo(TOOL_NAME, "Select your VAT material, or a material instance of it, in the Content Browser first.", parent=self.root)
            return
        self.vars["material"].set(content_path(material))

    def list_folders(self):
        """Fills the list of the content folder box, each time it is opened: the default first, then the project's folders."""
        first = default_folder(self.vars["material"].get().strip())
        folders = [first] + [path for path in [self.project] + project_folders(self.project) if path and path != first]
        self.folder_box.configure(values=folders)

    def use_open_folder(self):
        folder = browser_folder(self.project)
        if not folder:
            self.messagebox.showinfo(TOOL_NAME, "Open a folder of your project in the Content Browser first.", parent=self.root)
            return
        self.vars["destination"].set(folder)
        if self.data is not None:
            self.write([("info", "It goes into " + self.destination())])

    def destination(self):
        """The content folder everything is imported into."""
        folder = clean_folder(self.vars["destination"].get()) or default_folder(self.vars["material"].get().strip())
        if self.subfolder.get() and self.data is not None:
            folder = "%s/%s" % (folder.rstrip("/"), self.data.get("name", "Mesh"))
        return folder

    def apply_scale(self):
        """Remembers the chosen scale and opens the window again at that size."""
        self.settings["ui_scale"] = self.vars["ui_scale"].get()
        self.remember()
        open_window()

    def remember(self):
        self.settings["material"] = self.vars["material"].get().strip()
        self.settings["destination"] = clean_folder(self.vars["destination"].get())
        self.settings["subfolder"] = bool(self.subfolder.get())
        self.settings["assign"] = bool(self.assign.get())
        self.settings["save"] = bool(self.save.get())
        write_settings(self.settings)

    # ---- the button
    def run(self):
        if self.data is None:
            self.messagebox.showinfo(TOOL_NAME, "Pick the <Name>_VAT.json file from your VAT Baker download first.", parent=self.root)
            return
        files = dict((key, self.vars[key].get().strip()) for key in FILE_KEYS)
        absent = [FILE_LABELS[key] for key in FILE_KEYS if not os.path.isfile(files[key])]
        if absent:
            self.messagebox.showerror(TOOL_NAME, "These files are missing: %s." % ", ".join(absent), parent=self.root)
            return
        material_path = self.vars["material"].get().strip()
        parent = load_material(material_path)
        if material_path and parent is None:
            self.messagebox.showerror(TOOL_NAME, "There is no material at %s. Select it in the Content Browser and click Use selected." % material_path, parent=self.root)
            return
        destination = self.destination()
        if len(destination.split("/")) < 3 and destination != self.project:
            self.messagebox.showerror(TOOL_NAME, "The content folder must be a folder of your project, such as %s." % default_folder(material_path), parent=self.root)
            return
        if self.project and not inside(destination, self.project):
            self.messagebox.showerror(TOOL_NAME, "%s is not in your project. The content folder must start with %s." % (destination, self.project), parent=self.root)
            return
        if parent is None and not self.messagebox.askyesno(TOOL_NAME, "No VAT material is chosen.\n\nImport the mesh and the textures and apply their settings only, without material instances?", parent=self.root):
            return

        animation = self.vars["animation"].get().split(":")[0]
        start = int(animation) if animation.isdigit() else 0
        self.remember()
        report = Report()
        self.write([("info", "Working...")], clear=True)
        self.root.update_idletasks()
        self.root.attributes("-topmost", False)  # let UEFN's own windows come to the front while it imports
        try:
            run_setup(self.data, files, parent, destination, report, start, bool(self.assign.get()), bool(self.save.get()))
        except Exception as error:
            report.fail("Stopped with an error: %s" % error)
        finally:
            self.root.attributes("-topmost", True)
        self.show_report(report)

    def show_report(self, report):
        lines = [("fail" if report.failed else "todo" if report.todo else "ok", report.summary())]
        lines += [("fail", "FAILED: " + t) for t in report.failed]
        lines += [("todo", "TO DO: " + t) for t in report.todo]
        lines += [("ok", "Done: " + t) for t in report.done]
        self.write(lines, clear=True)
        self.log.see("1.0")
        unreal.log(LOG + "Summary: " + report.summary())

    # ---- keeping the window alive inside UEFN
    def show(self):
        if hasattr(unreal, "register_slate_post_tick_callback"):
            self._tick_handle = unreal.register_slate_post_tick_callback(self._tick)
        else:
            self.root.mainloop()

    def _tick(self, _delta_seconds):
        try:
            if self.root.winfo_exists():
                self.root.update()
            else:
                self.close()
        except Exception:
            self.close()

    def close(self):
        if self._tick_handle is not None and hasattr(unreal, "unregister_slate_post_tick_callback"):
            unreal.unregister_slate_post_tick_callback(self._tick_handle)
        self._tick_handle = None
        try:
            self.root.destroy()
        except Exception:
            pass


def open_window():
    """Opens the tool's window, closing an earlier one. The window is kept on the `unreal` module so a second run finds it."""
    previous = getattr(unreal, "_gelos_vat_window", None)
    if previous is not None:
        try:
            previous.close()
        except Exception:
            pass
    try:
        window = VatSetupWindow()
        unreal._gelos_vat_window = window
        window.show()
        return window
    except Exception as error:
        unreal._gelos_vat_window = None
        unreal.log_error(LOG + "Could not open the window: %s" % error)
        unreal.log_warning(LOG + "Working without a window instead: exports next to this script are set up with the material selected in the Content Browser.")
        run_without_window()
        return None


def run_without_window():
    """For an editor where the window cannot open: sets up every export found next to this script."""
    report = Report()
    exports = find_exports(script_folder())
    if not exports:
        report.fail("No <Name>_VAT.json file was found next to this script (%s). Put the script into the unzipped folder of your VAT Baker download." % script_folder())
    parent = selected_material() if exports else None
    for json_path, data in exports:
        try:
            run_setup(data, files_of_export(json_path, data), parent, "", report)
        except Exception as error:
            report.fail("%s: stopped with an error: %s" % (os.path.basename(json_path), error))
    unreal.log(LOG + "Summary: " + report.summary() + " | " + " | ".join(report.lines()[:len(report.failed) + len(report.todo)]))
    return report


if __name__ == "__main__":
    open_window()
