/**
 * Sync-o-tron - Advanced Audio Synchronization System for Adobe After Effects
 *
 * Synchronizes visual properties to audio amplitude for dynamic animations.
 * Works with Audio Reactor Template for professional audio-reactive motion graphics.
 *
 * @name Sync-o-tron
 * @author IVG Design
 * @version 2.1.4
 * @date 2026-07-04
 * @license MIT
 * @ui UI DIALOG - Interactive property selection and mapping interface
 * 
 * @description Allows you to sync properties to audio in After Effects. 
 * Intended to be used with Audio Reactor Template for creating audio-reactive animations.
 * 
 * @changelog
 * - 2.1.4 (2026-07-04): Really fixed the "sits there, Esc steps through it" stall when
 *   rigging a second property. Adding the easing/min/max effects modifies the layer's
 *   effect stack, and AE recomputes every LIVE expression on the layer after each edit —
 *   so with one property already audio-rigged, building the next one recomputed the audio
 *   graph 3-4 times. The layer's existing expressions are now suspended (expressionEnabled
 *   = false) for the duration of the build and restored once at the end, so the controls
 *   are added with nothing live to recompute.
 * - 2.1.3 (2026-07-04): Removed the stall (and Esc-to-step-through-it behaviour) when
 *   rigging a second property on a layer that already has an audio expression. Type
 *   detection no longer reads the property .value (a value read forces AE to compute
 *   comp state through the live audio graph); it now classifies from propertyValueType
 *   plus the layer 3D toggle only. TuneSync also resolves the property once instead of
 *   re-walking the hierarchy five times.
 * - 2.1.2 (2026-07-04): Fixed "outMin is not defined" when rigging Scale (and any 3D
 *   property without Unify). The 3D expression referenced un-suffixed outMin/outMax while
 *   creating per-axis outMin/Max X/Y/Z sliders; each axis now reads its own slider. Also
 *   replaced the non-existent easeInOut() call with valid linear/easeIn/easeOut/ease and
 *   aligned the easing menu (1 Linear, 2 EaseIn, 3 EaseOut, else Ease). Property type is
 *   now classified by the value's length AND the owning layer's 3D toggle: a Scale/Anchor
 *   on a 2D layer (which AE pads to three components with an unused Z) is treated as 2D, so
 *   the axis chooser and generated controls cover only X and Y — no phantom Z axis.
 * - 2.1.1 (2026-07-04): The reactor picker now finds AUDIO REACTOR comps nested inside
 *   folders (the imported template lands in a "Sync-o-tron.aep" folder) by recursively
 *   walking app.project.rootFolder, and matches the name case-insensitively.
 * - 2.1.0 (2026-07-04): Ships with the Audio Reactor template project. On launch (and
 *   on TuneSync) the script offers to import Sync-o-tron.aep into the current project
 *   when no AUDIO REACTOR comp exists (searched next to the script, in the bundle's
 *   ivg-scripts/projects/ folder, or located manually). Fixed the multi-second hang
 *   when rigging a second property: type detection no longer probes by writing test
 *   expressions (each probe forced a full expression-graph re-evaluation through the
 *   audio chain) and instead reads propertyValueType. The floating palette now closes
 *   after a successful TuneSync. Guards run before the undo group opens, and the
 *   empty-reactor case is handled instead of generating a broken comp("null") expression.
 * - 2.0.2 (2026-07-04): Fixed a crash on every TuneSync click in real After Effects — constructPropertyPath called the non-existent Array.prototype.indexOf; now routed through the module's ES3-safe indexOf helper. Fixed the 2D handler so that checking both X and Y without "Unify" now generates an expression that references the per-axis "_Min/Max Output X/Y" sliders it actually creates (previously it referenced un-created unsuffixed sliders, breaking the expression at evaluation time).
 * - 2.0.1 (2026-06-02): Replaced JSON.stringify with an ECMA 3-safe serializer in PropQuery output.
 * - 2.0.0: Renamed to Sync-o-tron with enhanced documentation
 * - 1.1.1: Fixed handling of properties when looking up using property address (for 1D & 2D properties)
 * - 1.1.0: Improved handling of pseudo effects with names that contain the word "Vertex"
 * - 1.0.0: Initial Release
 */

/**
 * @file
 * @module PropQuery
 * @name PropQuery
 * @exports PropQuery
 * @description A JavaScript module to query properties in After Effects.
 */
var PropQuery = (function () {
    var module = {};
    //=============== FLAG ARRAY =================//
    /**
     * @typedef {Array} FlagArray
     * @description An array of flags to be used with the PropQuery module.
     */
    var allowedFlags = [
        "useNames",
        "useGroupIndices",
        "useMatchNames",
        "comp",
        "layerName",
        "layerMatchName",
        "layerIndex",
        "hierarchy",
        "propName",
        "propMatchName"
    ];

    function stringifyForExtendScript(value) {
        if (value === null) {
            return "null";
        }

        var valueType = typeof value;
        if (valueType === "number" || valueType === "boolean") {
            return String(value);
        }

        if (valueType === "string") {
            return '"' + value
                .replace(/\\/g, "\\\\")
                .replace(/"/g, '\\"')
                .replace(/\r/g, "\\r")
                .replace(/\n/g, "\\n") + '"';
        }

        if (value instanceof Array) {
            var arrayParts = [];
            for (var i = 0; i < value.length; i++) {
                arrayParts.push(stringifyForExtendScript(value[i]));
            }
            return "[" + arrayParts.join(",") + "]";
        }

        if (valueType === "object") {
            var objectParts = [];
            for (var key in value) {
                if (value.hasOwnProperty(key)) {
                    objectParts.push(stringifyForExtendScript(key) + ":" + stringifyForExtendScript(value[key]));
                }
            }
            return "{" + objectParts.join(",") + "}";
        }

        return "undefined";
    }
    //=============== MODULES =================//

    /**
     * @function
     * @private
     * @name indexOf
     * @description Utility function to find index of element in array.
     * @param {Array} arr - Array to search.
     * @param {*} elem - Element to find.
     * @returns {number} - Index of element, -1 if not found.
     */
    function indexOf(arr, elem) {
        for (var i = 0; i < arr.length; i++) {
            if (arr[i] === elem) {
                return i;
            }
        }
        return -1;
    }
    /** 
     * @function
     * @private
     * @name isArray
     * @description Utility function to check if an object is an array.
     * @param {*} obj - Object to check.
     * @returns {boolean} - True if object is an array, false otherwise.
     */
    function isArray(obj) {
        return Object.prototype.toString.call(obj) === '[object Array]';
    }
    /**
     * @function
     * @memberof PropQuery
     * @name showDeepestSelectedProperty
     * @description - Returns the deepest selected property, if the selected properties are pseudo effects, it will return the last property in the pseudo effect - since the first one is the pseudo effect itself.
     * @param {Object[]} selectedProperties - The selected properties in the After Effects UI.
     * @returns {Object|null} - The deepest selected property.
     * @example 
     * 
     * var selectedProp = PropQuery.showDeepestSelectedProperty(selectedProperties);
     */

    module.showDeepestSelectedProperty = function (selectedProperties) {
        if (selectedProperties.length === 1) {
            return selectedProperties[0]; // Directly return if only one property is selected
        }

        if (selectedProperties.length > 1) {
            var deepestProp = null;
            var deepestPropDepth = 0;

            for (var i = 0; i < selectedProperties.length; i++) {
                var prop = selectedProperties[i];

                if (prop.propertyDepth > deepestPropDepth) {
                    deepestProp = prop;
                    deepestPropDepth = prop.propertyDepth;
                }
            }
            return deepestProp;
        }
    };

    /**
     * @function
     * @memberof PropQuery
     * @name getPropertyType
     * @description - Returns the type of the property, such as "Color", "3D", "2D" or "1D" if the property returns a value and can have an expression, or if it is propertyGroup, it will return "Indexed Group" or "Named Group" with the name and matchName of the group.
     * @param {Object} selectedProperty - should use the result of the showDeepestSelectedProperty function as the argument to make sure it works with pseudo effects.
     * @returns {String} - a string with the type of the selected property, eg( "Color", "3D", "2D" or "1D" or "Indexed Group" or "Named Group" with the name and matchName of the group).
     * @example
     * individual use
     * var propType =  PropQuery.getPropertyType(selectedProperty)
     * module use:
     * var propType = PropQuery(selectedProperty, 'propType')  
     */

    module.getPropertyType = function (selectedProperty) {
        // Check for Indexed Group or Named Group
        if (selectedProperty.propertyType === 6214) {
            return "Indexed Group, Name: " + selectedProperty.name + ", MatchName: " + (selectedProperty.matchName || "N/A");
        } else if (selectedProperty.propertyType === 6213) {
            return "Named Group, Name: " + selectedProperty.name + ", MatchName: " + (selectedProperty.matchName || "N/A");
        }

        // Check if the property can have an expression
        if (!selectedProperty.canSetExpression) {
            return "Non-Expressible, MatchName: " + (selectedProperty.matchName || "N/A");
        }

        // Dimensionality from the property's declared value TYPE plus the owning
        // layer's 3D toggle. Deliberately NOT read from .value: reading a value in
        // a comp that already holds live audio-reactor expressions makes AE compute
        // comp state, which stacks up into the "hold Esc to step through it" stall
        // when rigging a second property. (The even-older approach — probing with
        // four test expressions — was worse still.) Type inspection touches no
        // value and triggers no evaluation.
        var vt = selectedProperty.propertyValueType;
        if (vt === PropertyValueType.OneD) return "1D";
        if (vt === PropertyValueType.COLOR) return "4D";
        if (vt === PropertyValueType.TwoD || vt === PropertyValueType.TwoD_SPATIAL) return "2D";
        if (vt === PropertyValueType.ThreeD || vt === PropertyValueType.ThreeD_SPATIAL) {
            // A 2D layer pads Scale/Anchor Point to three components with an
            // invisible, unused Z. ThreeD_SPATIAL (Position/Anchor) only occurs on
            // a genuine 3D layer, but plain ThreeD (Scale) is reported on 2D layers
            // too — check the layer to keep those 2D so only X/Y controls are made.
            var lyr = null;
            try { lyr = selectedProperty.propertyGroup(selectedProperty.propertyDepth); } catch (eL) { }
            if (lyr && lyr.threeDLayer === false) return "2D";
            return "3D";
        }
        return "unknown";
    };


    /**
     * @function
     * @memberof PropQuery
     * @name collectPropertyHierarchyInfo
     * @description - Returns an array of objects with information about the property hierarchy, such as the name, matchName, propertyDepth, aS WELL as layerIndex and containingComp for the topmost parentProperty - which is the layer
     * @param {Object} selectedProperty - should use the result of the showDeepestSelectedProperty function as the argument to make sure it works with pseudo effects.
     * @param {FlagArray} optionalArg - An array of flags to be used with the PropQuery module - "layer", "layerName", "layerMatchName", "layerIndex", "hierarchy", "propName", "propMatchName". Flags can be combined, such as ["layer", "layerName"],["layer", "layerMatchName"],["layer", "layerIndex"] or ["layer", "hierarchy"].
     * @returns {Object[]} - An array of objects with information about the property hierarchy.
     * @example
     * individual use
     * var propHierarchy =  PropQuery.collectPropertyHierarchyInfo(selectedProperty)
     * module use:
     * var propHierarchy = PropQuery(selectedProperty, 'propInfo')     
     */

    module.collectPropertyHierarchyInfo = function (prop, optionalArg) {

        // Handle optionalArg being passed as an array, so it can be used with the module - this converts the array of flags to an object with a flags properties
        if (isArray(optionalArg)) {
            optionalArg = { flags: optionalArg };
        }

        // Validate flags against allowedFlags
        var flags = optionalArg && optionalArg.flags || [];
        for (var i = 0; i < flags.length; i++) {
            if (indexOf(allowedFlags, flags[i]) === -1) {
                throw new Error("Invalid flag: " + flags[i]);
            }
        }


        var propInfo = []; // Array to store property hierarchy information

        // Loop to collect parent properties
        while (prop && prop.parentProperty) {
            // Create an object with information about the current property
            var info = {
                name: prop.name,
                matchName: prop.matchName,
                propertyDepth: prop.propertyIndex
            };
            propInfo.push(info);

            // Move up to the parent property for the next iteration
            prop = prop.parentProperty;
        }

        // Add consolidated layer info
        if (prop && prop.containingComp) {
            var layerInfo = {
                name: prop.name,
                matchName: prop.matchName,
                layerIndex: prop.index,
                containingComp: prop.containingComp.name
            };
            propInfo.push(layerInfo);
        }
        // Return based on the flag
        if (flags.length > 0) {
            var lastInfo = propInfo[propInfo.length - 1]; // Last usually contains layer info
            var firstInfo = propInfo[0]; // First should contain the selected property info
            var results = '';

            for (var i = 0; i < flags.length; i++) {
                var flag = flags[i];
                switch (flag) {
                    case 'comp':
                        results += lastInfo.containingComp + ',';
                        break;
                    case 'layerName':
                        results += lastInfo.name + ',';
                        break;
                    case 'layerMatchName':
                        results += lastInfo.matchName + ',';
                        break;
                    case 'layerIndex':
                        results += lastInfo.layerIndex + ',';
                        break;
                    case 'hierarchy':
                        results += stringifyForExtendScript(propInfo) + ',';
                        break;
                    case 'propName': // Handle new flag
                        if (firstInfo) {
                            results += firstInfo.name + ',';
                        }
                        break;
                    case 'propMatchName': // Handle new flag
                        if (firstInfo) {
                            results += firstInfo.matchName + ',';
                        }
                        break;
                    default:
                        throw new Error("Invalid flag provided");
                }
            }

            // Remove the trailing comma
            if (results.length > 0) {
                results = results.substring(0, results.length - 1);
            }

            return results;
        } else {
            return propInfo;
        }
    };

    /**
     * @function
     * @memberof PropQuery
     * @name constructPropertyPath
     * @description - Returns a string with the property path, based on the flags provided such as "useNames", "useMatchNames", "useGroupIndices". Flags can be combined, such as ["useNames", "useGroupIndices"],["useMatchNames", "useGroupIndices"],["useNames"] or ["useMatchNames"].
     * @param {Object[]} collectedHierarchy - must use the result of the collectPropertyHierarchyInfo function as this function is designed to work in tandem with it - parsing the object returned from collectPropertyHierarchyInfo.
     * @param {FlagArray} optionalArg - An array of flags to be used with the PropQuery module - "useNames", "useMatchNames", "useGroupIndices". Flags can be combined, such as ["useNames", "useGroupIndices"],["useMatchNames", "useGroupIndices"],["useNames"] or ["useMatchNames"].
     * @returns {String} - A string with the property path based on selected flags.
     * @example
     *  individual use
     * var propPath =  PropQuery.constructPropertyPath(collectedHierarchy, optionalArg)
     * module use:
     * var propPath = PropQuery(selectedProperty, 'propPath', optionalArg) 
     */
    module.constructPropertyPath = function (collectedHierarchy, optionalArg) {

        // Handle optionalArg being passed as an array, so it can be used with the module - this converts the array of flags to an object with a flags properties
        if (isArray(optionalArg)) {
            optionalArg = { flags: optionalArg };
        }

        // Validate flags against allowedFlags
        var flags = optionalArg && optionalArg.flags || [];
        for (var i = 0; i < flags.length; i++) {
            if (indexOf(allowedFlags, flags[i]) === -1) {
                throw new Error("Invalid flag: " + flags[i]);
            }
        }

        var useNames = indexOf(flags, "useNames") !== -1;
        var useMatchNames = indexOf(flags, "useMatchNames") !== -1;
        var useGroupIndices = indexOf(flags, "useGroupIndices") !== -1;
        var propertyPath = '';

        // Process layer information first
        var layerInfo = collectedHierarchy[collectedHierarchy.length - 1];

        if (!layerInfo) {
            throw new Error("Layer information not found in collectedHierarchy.");
        }

        if (useGroupIndices) {
            propertyPath += 'layer(' + layerInfo.layerIndex + ')';
        } else {
            propertyPath += 'layer("' + layerInfo.name + '")';
        }

        // Process remaining property information
        for (var i = collectedHierarchy.length - 2; i >= 0; i--) {
            var info = collectedHierarchy[i];
            var part = '';

            // For the deepest property, use either matchName or name, never index
            if (i === 0) {
                if (useMatchNames) {
                    part = 'property("' + info.matchName + '")';
                } else if (useNames) {
                    part = 'property("' + info.name + '")';
                }
            } else {
                if (useGroupIndices && info.name !== "Contents") {
                    part = 'property(' + info.propertyDepth + ')';
                } else if (useMatchNames) {
                    part = 'property("' + info.matchName + '")';
                } else if (useNames) {
                    part = 'property("' + info.name + '")';
                }
            }

            if (part) {
                propertyPath += '.' + part;
            }
        }

        return propertyPath;
    };
    /**
     * @function    
     * @memberof PropQuery
     * @name mainFunction
     * @description - Returns the result of the selected return type, based on the selected property, return type and optionalArg.
     * @param {Object} selectedProperty - should use the result of the showDeepestSelectedProperty function as the argument to make sure it works with pseudo effects.
     * @param {String} returnType - The type of information to return - "propObject", "propType", "propPath", "propInfo".
     * @param {Array} optionalArg - The optionalArg is an array of flags to be used with the PropQuery module - "useNames", "useGroupIndices", "useMatchNames", "comp", "layerName", "layerMatchName", "layerIndex", "hierarchy", "propName", "propMatchName". Used only for "propPath" and "propInfo" return types. Flags can be combined, such as ["useNames", "useGroupIndices"],["useMatchNames", "useGroupIndices"],["useNames"] or ["useMatchNames"], or ["layerName", "comp"].
     * @returns {String||Object} - The result of the selected return type. selectedProperty object is returned by using returnType "propObject", property type string is returned by using returnType "propType", "propPath", property info object (consisting of the constructed hierarchy) is returned by using returnType "propInfo" without flags.
     * @example
     * var propPath = PropQuery(selectedProperty, 'propPath', ["useNames"])
     * var propInfo = PropQuery(selectedProperty, 'propInfo', ["layerName", "comp"])
     */
    var mainFunction = function (selectedProperty, returnType, optionalArg) {
        // Handle optionalArg being passed as an array, so it can be used with the module - this converts the array of flags to an object with a flags properties
        if (isArray(optionalArg)) {
            optionalArg = { flags: optionalArg };
        }

        // Validate flags against allowedFlags
        var flags = optionalArg && optionalArg.flags || [];
        for (var i = 0; i < flags.length; i++) {
            if (indexOf(allowedFlags, flags[i]) === -1) {
                throw new Error("Invalid flag: " + flags[i]);
            }
        }

        // Get the deepest selected property
        var deepestProp = module.showDeepestSelectedProperty(selectedProperty);

        if (!deepestProp) {
            throw new Error("No deepest property found");
        }

        // Handle each return type accordingly
        switch (returnType) {
            case 'propObject':
                return deepestProp;

            case 'propType':
                return module.getPropertyType(deepestProp);

            case 'propPath':
                var hierarchyInfo = module.collectPropertyHierarchyInfo(deepestProp);
                return module.constructPropertyPath(hierarchyInfo, optionalArg);

            case 'propInfo':
                var info = module.collectPropertyHierarchyInfo(deepestProp, optionalArg);
                if (isArray(info) || typeof info === 'string') {
                    if (isArray(info)) {
                        var firstInfo = info[0]; // First should contain the selected property info
                        var lastInfo = info[info.length - 1]; // Last usually contains layer info
                        var results = '';

                        if (optionalArg && optionalArg.flags) {
                            if (indexOf(optionalArg.flags, "propName") !== -1) results += firstInfo.name + ',';
                            if (indexOf(optionalArg.flags, "propMatchName") !== -1) results += firstInfo.matchName + ',';
                            if (indexOf(optionalArg.flags, "layerName") !== -1) results += lastInfo.name + ',';
                            if (indexOf(optionalArg.flags, "layerMatchName") !== -1) results += lastInfo.matchName + ',';
                            if (indexOf(optionalArg.flags, "layerIndex") !== -1) results += lastInfo.layerIndex + ',';
                            if (indexOf(optionalArg.flags, "comp") !== -1) results += lastInfo.containingComp + ',';

                            // Remove the trailing comma
                            if (results.length > 0) {
                                results = results.substring(0, results.length - 1);
                            }
                        } else {
                            return info;
                        }

                        return results;  // Return the comma-separated string
                    } else {
                        // If it's already a string, just return it
                        return info;
                    }
                }
                throw new Error("Unexpected info structure");
            default:
                throw new Error("Invalid return type");
        }
    };
    //============== EXPOSE MODULES FOR INDIVIDUAL USE ==================//
    mainFunction.showDeepestSelectedProperty = module.showDeepestSelectedProperty;

    mainFunction.getPropertyType = module.getPropertyType;

    mainFunction.collectPropertyHierarchyInfo = module.collectPropertyHierarchyInfo;

    mainFunction.constructPropertyPath = module.constructPropertyPath;

    return mainFunction;

})();

var MainUI = (function () {
    var module = {};

    /**
     * Locate the bundled Audio Reactor template (.aep). Search order:
     *   1. next to this script:            <script folder>/Sync-o-tron.aep
     *   2. IVGD bundle layout:             <script folder>/../projects/Sync-o-tron.aep
     *   3. repo layout:                    <script folder>/../../../../assets/projects/Sync-o-tron.aep
     * Returns a File or null.
     */
    function findReactorTemplate() {
        var candidates = [];
        try {
            var here = new File($.fileName).parent;
            candidates.push(new File(here.fsName + "/Sync-o-tron.aep"));
            candidates.push(new File(here.fsName + "/../projects/Sync-o-tron.aep"));
            candidates.push(new File(here.fsName + "/../../../../../assets/projects/Sync-o-tron.aep"));
        } catch (e) { }
        for (var i = 0; i < candidates.length; i++) {
            if (candidates[i].exists) return candidates[i];
        }
        return null;
    }

    /**
     * Import the Audio Reactor template into the CURRENT project. If the
     * bundled copy cannot be found, asks the user to locate the .aep.
     * @returns {boolean} true when an import happened.
     */
    function importReactorTemplate() {
        var aep = findReactorTemplate();
        if (aep === null) {
            var pick = confirm("Sync-o-tron needs its Audio Reactor template project.\n" +
                "The bundled Sync-o-tron.aep was not found next to the script.\n\nLocate it manually?");
            if (!pick) return false;
            aep = File.openDialog("Locate Sync-o-tron.aep", "After Effects projects:*.aep");
            if (aep === null) return false;
        }
        try {
            app.project.importFile(new ImportOptions(aep));
            return true;
        } catch (eImp) {
            alert("Could not import the Audio Reactor template:\n" + eImp.toString());
            return false;
        }
    }
    // Create a dockable panel
    module.show = function (thisObj) {
        //LAYOUT
        var isPanel = thisObj instanceof Panel;
        var win = isPanel ? thisObj : new Window("palette", "Sync-o-tron", undefined, { closeButton: true, resizeable: true });
        win.preferredSize.width = 300;
        win.orientation = "column";
        // Add a group to hold the label and the dropdown
        var reactorGroup = win.add("group");
        reactorGroup.orientation = "row";

        // Label for the dropdown
        var label = reactorGroup.add("statictext", undefined, "Pick Reactor:");
        label.size = [80, 25];

        // Dropdown for selecting the AUDIO REACTOR
        var dropdown = reactorGroup.add("dropdownlist", undefined, []);
        dropdown.size = [150, 25];

        // Add a group to hold the TuneSync and refresh buttons
        var buttonGroup = win.add("group");
        buttonGroup.orientation = "row";

        // Button for triggering the TuneSync function
        var tuneSyncButton = buttonGroup.add("button", undefined, "TuneSync");
        tuneSyncButton.size = [100, 25];

        // Refresh button to update the dropdown
        var refreshButton = buttonGroup.add("button", undefined, "↺");
        refreshButton.size = [25, 25];

        //DROPDOWN 
        // Populate the dropdown with AUDIO REACTOR compositions
        function isReactorComp(it) {
            return it instanceof CompItem &&
                typeof it.name === 'string' &&
                it.name.toUpperCase().indexOf("AUDIO REACTOR") !== -1;
        }

        // Recursively collect reactor-comp names from a folder and its
        // subfolders. Guarded so it is a no-op where the folder API is absent.
        function walkFolder(folder, seen, out) {
            if (!folder || typeof folder.numItems !== 'number' || typeof folder.item !== 'function') return;
            for (var i = 1; i <= folder.numItems; i++) {
                var it = folder.item(i);
                if (it instanceof FolderItem) {
                    walkFolder(it, seen, out);
                } else if (isReactorComp(it) && !seen[it.name]) {
                    seen[it.name] = true;
                    out.push(it.name);
                }
            }
        }

        function populateDropdown() {
            // Clear current items
            dropdown.removeAll();

            var seen = {};
            var names = [];

            // (a) Flat project scan — in most AE builds app.project.item(i)
            //     already enumerates items nested in folders.
            for (var i = 1; i <= app.project.numItems; i++) {
                var it = app.project.item(i);
                if (isReactorComp(it) && !seen[it.name]) {
                    seen[it.name] = true;
                    names.push(it.name);
                }
            }

            // (b) Recursive folder walk — catches nested comps on builds where
            //     the flat scan does not (the imported template lands inside a
            //     "Sync-o-tron.aep" folder). Deduped against (a) by name.
            walkFolder(app.project.rootFolder, seen, names);

            for (var n = 0; n < names.length; n++) {
                dropdown.add("item", names[n]);
            }

            // Select the first item in the dropdown if available
            if (dropdown.items.length > 0) {
                dropdown.selection = 0;
            }
        }
        populateDropdown();

        // First launch in a project without any AUDIO REACTOR comp: offer to
        // import the bundled template right away.
        if (dropdown.items.length === 0 && app.project !== null) {
            if (confirm("No AUDIO REACTOR composition found in this project.\n\nImport the Sync-o-tron Audio Reactor template now?")) {
                if (importReactorTemplate()) {
                    populateDropdown();
                }
            }
        }
        //ONCLICK FUNCTIONS

        // Refresh button functionality
        refreshButton.onClick = function () {
            populateDropdown();
        };

        // Add functionality to the TuneSync button (this is where you'll add the main logic of your script)
        tuneSyncButton.onClick = function () {
            // All guards run BEFORE the undo group opens, so no alert path can
            // leave an unbalanced beginUndoGroup behind.
            var comp = app.project.activeItem;
            if (!comp || !(comp instanceof CompItem)) {
                alert("No composition is active.");
                return;
            }

            // Get the selected layers
            var layers = comp.selectedLayers;
            if (layers.length === 0) {
                alert("No layer is selected.");
                return;
            }

            // Get the selected layer
            var layer = layers[0];

            // Get the selected properties
            var props = layer.selectedProperties;
            if (props.length === 0) {
                alert("No property is selected.");
                return;
            }

            var dd = dropdown;
            var audioReactorName = getAudioReactorName(dd);
            if (!audioReactorName) {
                // No AUDIO REACTOR comp in the project - offer the bundled template.
                if (importReactorTemplate()) {
                    populateDropdown();
                    audioReactorName = getAudioReactorName(dropdown);
                }
                if (!audioReactorName) {
                    alert("No AUDIO REACTOR composition found. Import the Sync-o-tron template (or add a comp whose name contains \"AUDIO REACTOR\") and hit \u21BA.");
                    return;
                }
            }

            // Resolve the property once, then derive everything from that single
            // object — the old code called PropQuery five times, each re-running
            // showDeepestSelectedProperty and re-walking the whole hierarchy.
            var propObj = PropQuery.showDeepestSelectedProperty(props);
            if (!propObj) {
                alert("Could not resolve the selected property.");
                return;
            }
            var hierarchy = PropQuery.collectPropertyHierarchyInfo(propObj);
            var firstInfo = hierarchy[0] || {};
            var propName = firstInfo.name;
            var propMatchName = firstInfo.matchName;
            var propAddr = PropQuery.constructPropertyPath(hierarchy, ["useNames"]);

            // Determine the property type (1D, 2D, 3D, 4D) - type-code only, no value read.
            var propertyType = PropQuery.getPropertyType(propObj);

            app.beginUndoGroup("Create Controls");

            // Adding the easing/min/max effects modifies the layer's effect
            // stack, and AE re-evaluates every LIVE expression on the layer after
            // each such edit. Once a first property is audio-rigged, that means
            // the audio graph is recomputed 3-4 times while building the second
            // property's controls — the "it sits there, Esc steps through it"
            // stall. Suspend the layer's existing expressions for the duration of
            // the build (cheap; nothing to recompute), then restore them once.
            var suspended = suspendLayerExpressions(layer);
            try {
                switch (propertyType) {
                    case '2D':
                        XYUnifyDialog(layer, propName, audioReactorName, propMatchName, propObj, propAddr);
                        break;
                    case '3D':
                        XYZUnifyDialog(layer, propName, audioReactorName, propMatchName, propObj);
                        break;
                    case '4D':
                        handleColorProperty(layer, propName, audioReactorName, propMatchName, propObj);
                        break;
                    case '1D':
                    default:
                        handle1DProperty(layer, propName, audioReactorName, propMatchName, propObj, propAddr);
                        break;
                }
            } catch (err) {
                alert("Sync-o-tron error: " + err.toString() +
                    (err.line ? "  (line " + err.line + ")" : ""));
            } finally {
                restoreLayerExpressions(suspended);
                app.endUndoGroup();
            }

            // Applied - close the floating palette (a docked Panel cannot close itself).
            if (!isPanel) {
                win.close();
            }
        };

        if (!isPanel) {
            win.show();
        } else {
            win.onResizing = win.onResize = function () {
                this.layout.layout(true);
                this.layout.resize();
            };
        }
        return win;
    };
    //DIALOGS
    //2D DIALOG
    /**
     * @name XYUnifyDialog
     * @discription Creates a dialog box for 2D properties to be animated
     * @param {Layer} layer - The After Effects layer where the property resides.
     * @param {string} propName - name of 2D property add the expression to
     * @param {string} audioReactorName - name of the composition that contains the audio reactor.
     * @param {string} propMatchName - matchName of the property to add the expression to 
     */
    function XYUnifyDialog(layer, propName, audioReactorName, propMatchName, propObj, propAddr) {
        //LAYOUT
        var dialog = new Window('dialog', 'XY Unify');
        var propObj = propObj;
        // Parent group with row alignment
        var parentGroup = dialog.add('group');
        parentGroup.orientation = 'row';
        parentGroup.alignment = 'center';

        // First column for X
        var xGroup = parentGroup.add('group');
        xGroup.orientation = 'column';
        xGroup.alignment = 'center';
        var xCheckBox = xGroup.add('checkbox', undefined, '');
        xGroup.add('statictext', undefined, 'X').justify = 'center';

        // Second column for Y
        var yGroup = parentGroup.add('group');
        yGroup.orientation = 'column';
        yGroup.alignment = 'center';
        var yCheckBox = yGroup.add('checkbox', undefined, '');
        yGroup.add('statictext', undefined, 'Y').justify = 'center';

        // Third column for Unified
        var unifiedGroup = parentGroup.add('group');
        unifiedGroup.orientation = 'column';
        unifiedGroup.alignment = 'center';
        var unifiedCheckBox = unifiedGroup.add('checkbox', undefined, '');
        var unifiedLabel = unifiedGroup.add('statictext', undefined, 'Unify Properties');
        unifiedLabel.justify = 'center';
        unifiedLabel.multiline = true;  // Set to multiline

        // Fourth Line: OK/Cancel buttons
        var buttonGroup = dialog.add('group');
        //ON CLICK FUNCTIONS
        buttonGroup.add('button', undefined, 'OK').onClick = function () {
            handle2DProperty(layer, xCheckBox.value, yCheckBox.value, unifiedCheckBox.value, propName, audioReactorName, propMatchName, propObj, propAddr);
            dialog.close();
        };
        buttonGroup.add('button', undefined, 'Cancel').onClick = function () {
            dialog.close();
        };

        dialog.show();
    }
    //3D DIALOG
    /**
     * @name XYZUnifyDialog
     * @description Creates a dialog box for 3D properties to be animated
     * @param {Layer} layer - The After Effects layer where the property resides.
     * @param {string} propName - name of 3D property add the expression to
     * @param {string} audioReactorName - name of the composition that contains the audio reactor.
     * @param {string} propMatchName - matchName of the property to add the expression to  
     * */
    function XYZUnifyDialog(layer, propName, audioReactorName, propMatchName) {
        //LAYOUT
        var dialog = new Window('dialog', 'XY Unify');

        // Parent group with row alignment
        var parentGroup = dialog.add('group');
        parentGroup.orientation = 'row';
        parentGroup.alignment = 'center';

        // First column for X
        var xGroup = parentGroup.add('group');
        xGroup.orientation = 'column';
        xGroup.alignment = 'center';
        var xCheckBox = xGroup.add('checkbox', undefined, '');
        xGroup.add('statictext', undefined, 'X').justify = 'center';

        // Second column for Y
        var yGroup = parentGroup.add('group');
        yGroup.orientation = 'column';
        yGroup.alignment = 'center';
        var yCheckBox = yGroup.add('checkbox', undefined, '');
        yGroup.add('statictext', undefined, 'Y').justify = 'center';

        // New column for Z
        var zGroup = parentGroup.add('group');
        zGroup.orientation = 'column';
        zGroup.alignment = 'center';
        var zCheckBox = zGroup.add('checkbox', undefined, '');
        zGroup.add('statictext', undefined, 'Z').justify = 'center';

        // Third column for Unified
        var unifiedGroup = parentGroup.add('group');
        unifiedGroup.orientation = 'column';
        unifiedGroup.alignment = 'center';
        var unifiedCheckBox = unifiedGroup.add('checkbox', undefined, '');
        var unifiedLabel = unifiedGroup.add('statictext', undefined, 'Unify Properties');
        unifiedLabel.justify = 'center';
        unifiedLabel.multiline = true;  // Set to multiline

        // Fourth Line: OK/Cancel buttons
        var buttonGroup = dialog.add('group');
        //ON CLICK FUNCTIONS
        buttonGroup.add('button', undefined, 'OK').onClick = function () {
            // Adjust the handle2DProperty function to accommodate the new Z checkbox
            handle3DProperty(layer, xCheckBox.value, yCheckBox.value, zCheckBox.value, unifiedCheckBox.value, propName, audioReactorName, propMatchName);
            dialog.close();
        };
        buttonGroup.add('button', undefined, 'Cancel').onClick = function () {
            dialog.close();
        };

        dialog.show();
    }

    //HANDLERS
    //COLOR HANDLER
    /**
     * @name handleColorProperty
     * @description Configures a color property to react to audio in the audioreactor comp and adds controls for the expression
     * @param {Layer} layer - The After Effects layer where the color property resides 
     * @param {string} propName - name of color property to add the expression to
     * @param {string} audioReactorName - name of the composition that contains the audio reactor.
     * @param {string} propMatchName - matchName of the property to add the expression to
     * @returns {void}  
     */
    function handleColorProperty(layer, propName, audioReactorName, propMatchName, propObj) {

        // Create Easing dropdown
        var parentName = propObj.parentProperty.name;
        var newPropName = parentName + "_" + propName;
        var dropDownParams = ["Linear", "EaseIn", "EaseOut", "EaseInOut"];
        var easingDropdown = layer.Effects.addProperty("ADBE Dropdown Control");
        var setDropDownParams = easingDropdown.property(1).setPropertyParameters(dropDownParams);
        setDropDownParams.propertyGroup(1).name = newPropName + "_Easing Type";

        // Create Start and End Color controls
        var startColorControl = layer.Effects.addProperty("ADBE Color Control");
        startColorControl.name = newPropName + "_Start";
        startColorControl.property("Color").setValue([0, 1, 0, 1]); // Set to Green (RGBA)

        var endColorControl = layer.Effects.addProperty("ADBE Color Control");
        endColorControl.name = newPropName + "_End";
        endColorControl.property("Color").setValue([0, 0, 1, 1]); // Set to Blue (RGBA)

        var propLink = findPropertyByMatchName(layer, propMatchName);
        var expressionString = buildColorExpression(audioReactorName, newPropName); // Replace with your actual function for building color expressions
        propLink.expression = expressionString;

    };

    /** 
     * @name buildColorExpression
     * @description Generates a color expression string based on the parameters provided.   
     * @param {string} audioReactorName - The name of the composition that contains the audio reactor.
     * @param {string} propName - The name of the property being affected (e.g., "Position", "Scale").
     * @returns {string} A string containing the complete expression for After Effects using the parameters provided.
    */
    function buildColorExpression(audioReactorName, propName) {
        // Create the expression
        var expressionString = 'd = comp("' + audioReactorName + '").layer("Select Frequency").effect("Audio Reactor")("Output Power");' + "\n"
            + 'iMin = 0;' + "\n"
            + 'iMax = 100;' + "\n"
            + 'ctrlLayer = thisLayer;' + "\n"
            + 'easeType = ctrlLayer.effect("' + propName + '_Easing Type")("Menu").value;' + "\n"
            + 'startColor = ctrlLayer.effect("' + propName + '_Start")("Color");' + "\n"
            + 'endColor = ctrlLayer.effect("' + propName + '_End")("Color");' + "\n"
            + 'if (easeType == 1) linear(d, iMin, iMax, startColor, endColor);' + "\n"
            + 'else if (easeType == 2) easeIn(d, iMin, iMax, startColor, endColor);' + "\n"
            + 'else if (easeType == 3) easeOut(d, iMin, iMax, startColor, endColor);' + "\n"
            + 'else ease(d, iMin, iMax, startColor, endColor);';
        return expressionString;
    };

    //1D HANDLER
    /**
     * @name handle1DProperty
     * @description Configures a 1D property to react to audio in the audioreactor comp and adds controls for the expression - rotation/opacity
     * @param {Layer} layer - The After Effects layer where the property resides
     * @param {string} propName - name of 1D property to add the expression to
     * @param {string} audioReactorName - name of the composition that contains the audio reactor.
     * @param {string} propMatchName - matchName of the property to add the expression to
     * * @param {Object} propObj - The property object returned by the PropQuery module used to get the parent property name if the property is a pseudo effect.
     * @returns {void}
     */
    function handle1DProperty(layer, propName, audioReactorName, propMatchName, propObj, propAddr) {
        var outputSring = "Property-Vert";
        var referenceString = "Vertex";
        var isCaseSensitive = true;
        var parentName = propObj.parentProperty.name;
        if (propMatchName.indexOf("Pseudo") !== -1) {
            var newPropName = compareAndReplace(parentName, referenceString, outputSring, isCaseSensitive) + " " + propName;
        } else {
            var newPropName = compareAndReplace(propName, referenceString, outputSring, isCaseSensitive);
        }// Create Easing dropdown
        var dropDownParams = ["Linear", "EaseIn", "EaseOut", "EaseInOut"];
        var easingDropdown = layer.Effects.addProperty("ADBE Dropdown Control");
        var setDropDownParams = easingDropdown.property(1).setPropertyParameters(dropDownParams);
        setDropDownParams.propertyGroup(1).name = newPropName + "_Easing Type";

        // Create Min and Max sliders
        var minSlider = layer.Effects.addProperty("ADBE Slider Control");
        minSlider.name = newPropName + "_Min Output";
        var maxSlider = layer.Effects.addProperty("ADBE Slider Control");
        maxSlider.name = newPropName + "_Max Output";
        var propLink = findPropertyByAddress(propAddr);
        if (propLink) {
            var expressionString = build1DExpression(audioReactorName, newPropName);
            propLink.expression = expressionString;
        } else {
            alert("Failed to update the expression. Property address could not be retrieved.");
        }
    }

    /**
     * @name build1DExpression
     * @description Generates a 1D expression string based on the parameters provided.
     * @param {string} audioReactorName - The name of the composition that contains the audio reactor.
     * @param {string} propName - The name of the property being affected (e.g., "Position", "Scale").
     * @returns {string} A string containing the complete expression for After Effects using the parameters provided. 
     */
    function build1DExpression(audioReactorName, propName) {
        // Create the expression
        var expressionString = 'd = comp("' + audioReactorName + '").layer("Select Frequency").effect("Audio Reactor")("Output Power");' + "\n"
            + 'iMin = 0;' + "\n"
            + 'iMax = 100;' + "\n"
            + 'ctrlLayer = thisLayer;' + "\n"
            + 'easeType = ctrlLayer.effect("' + propName + '_Easing Type")("Menu").value;' + "\n"
            + 'outMin = ctrlLayer.effect("' + propName + '_Min Output")("Slider");' + "\n"
            + 'outMax = ctrlLayer.effect("' + propName + '_Max Output")("Slider");' + "\n"
            + 'if (easeType == 1) linear(d, iMin, iMax, outMin, outMax);' + "\n"
            + 'else if (easeType == 2) easeIn(d, iMin, iMax, outMin, outMax);' + "\n"
            + 'else if (easeType == 3) easeOut(d, iMin, iMax, outMin, outMax);' + "\n"
            + 'else ease(d, iMin, iMax, outMin, outMax);';
        return expressionString;
    }
    //2D HANDLER
    /**
     * @name handle2DProperty
     * @description Configures a 2D property and adds relevant expression controls to react to audio in the audioreactor comp and adds controls for the expression - 2D position/2D scale/2D anchor point, based on the options selected in the 2D dialog
     * @param {Layer} layer - The After Effects layer where the property resides    
     * @param {boolean} xSelected - Determines if the x value is affected by the expression - based on the selection in 2D dialog.
     * @param {boolean} ySelected - Determines if the y value is affected by the expression - based on the selection in 2D dialog.
     * @param {boolean} isUnified - Determines if the effect should be applied uniformly to both X & Y axis - based on the selection in 2D dialog.
     * @param {string} propName - name of 2D property to add the expression to  
     * @param {string} audioReactorName - name of the composition that contains the audio reactor.
     * @param {string} propMatchName - matchName of the property to add the expression to
     * @param {Object} propObj - The property object returned by the PropQuery module used to get the parent property name if the property is a pseudo effect.
     * @returns {void}
    */
    function handle2DProperty(layer, xSelected, ySelected, isUnified, propName, audioReactorName, propMatchName, propObj, propAddr) {
        var outputSring = "Property-Vert";
        var referenceString = "Vertex";
        var isCaseSensitive = true;
        var parentName = propObj.parentProperty.name;
        if (propMatchName.indexOf("Pseudo") !== -1) {
            var newPropName = compareAndReplace(parentName, referenceString, outputSring, isCaseSensitive) + " " + propName;
        } else {
            var newPropName = compareAndReplace(propName, referenceString, outputSring, isCaseSensitive);
        }
        // Add Easing dropdown
        var dropDownParams = ["Linear", "EaseIn", "EaseOut", "EaseInOut"];
        var dropdown = layer.Effects.addProperty("ADBE Dropdown Control");
        var setDropDownParams = dropdown.property(1).setPropertyParameters(dropDownParams);
        setDropDownParams.propertyGroup(1).name = newPropName + "_Easing Type";

        if (isUnified) {
            // Unified sliders for Min and Max Output
            var outMinSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMinSlider.name = newPropName + "_Min Output";

            var outMaxSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMaxSlider.name = newPropName + "_Max Output";
        } else {
            if (!xSelected && !ySelected) {
                // Create individual sliders for X and Y Min and Max Output
                var outMinXSlider = layer.Effects.addProperty("ADBE Slider Control");
                outMinXSlider.name = newPropName + "_Min Output X";

                var outMaxXSlider = layer.Effects.addProperty("ADBE Slider Control");
                outMaxXSlider.name = newPropName + "_Max Output X";

                var outMinYSlider = layer.Effects.addProperty("ADBE Slider Control");
                outMinYSlider.name = newPropName + "_Min Output Y";

                var outMaxYSlider = layer.Effects.addProperty("ADBE Slider Control");
                outMaxYSlider.name = newPropName + "_Max Output Y";
            } else {
                // Individual sliders for X and Y Min and Max Output
                if (xSelected) {
                    var outMinXSlider = layer.Effects.addProperty("ADBE Slider Control");
                    outMinXSlider.name = newPropName + "_Min Output X";

                    var outMaxXSlider = layer.Effects.addProperty("ADBE Slider Control");
                    outMaxXSlider.name = newPropName + "_Max Output X";
                }

                if (ySelected) {
                    var outMinYSlider = layer.Effects.addProperty("ADBE Slider Control");
                    outMinYSlider.name = newPropName + "_Min Output Y";

                    var outMaxYSlider = layer.Effects.addProperty("ADBE Slider Control");
                    outMaxYSlider.name = newPropName + "_Max Output Y";
                }
            }
        };

        // Build the expression based on the selections
        var expression = build2DExpression(xSelected, ySelected, isUnified, newPropName, audioReactorName);
        var propLink = findPropertyByAddress(propAddr);
        // Apply the expression to the selected property
        propLink.expression = expression;
    }

    /**
     * @name build2DExpression
     * @description Generates a 2D expression string based on the parameters provided.
     * @param {boolean} xSelected - Determines if the x value is affected by the expression - basedon on the selection in the 2D dialog.
     * @param {boolean} ySelected - Determines if the y value is affected by the expression - basedon on the selection in the 2D dialog.
     * @param {boolean} isUnified - Determines if the effect should be applied uniformly to both X & Y axis - basedon on the selection in the 2D dialog.
     * @param {string} propName - name of 2D property to add the expression to
     * @param {string} audioReactorName - name of the composition that contains the audio reactor.
     * @returns {string} A string containing the complete expression for After Effects using the parameters provided.
    */
    function build2DExpression(xSelected, ySelected, isUnified, newPropName, audioReactorName) {
        var expressionBase = 'd = comp("' + audioReactorName + '").layer("Select Frequency").effect("Audio Reactor")("Output Power");\n' +
            'iMin = 0;\n' +
            'iMax = 100;\n' +
            'originalValue = value;\n';

        var easingExpressionBase = 'ctrlLayer = thisLayer;\n' +
            'easeType = ctrlLayer.effect("' + newPropName + '_Easing Type")("Menu").value;\n';

        var commonEasingLogic = '(easeType == 1 ? linear(d, iMin, iMax, outMin, outMax) : ' +
            '(easeType == 2 ? easeIn(d, iMin, iMax, outMin, outMax) : ' +
            '(easeType == 3 ? easeOut(d, iMin, iMax, outMin, outMax) : ' +
            'ease(d, iMin, iMax, outMin, outMax))))';

        var finalExpression = expressionBase + easingExpressionBase;

        if (isUnified) {
            finalExpression += 'outMin = ctrlLayer.effect("' + newPropName + '_Min Output")("Slider");\n';
            finalExpression += 'outMax = ctrlLayer.effect("' + newPropName + '_Max Output")("Slider");\n';
            finalExpression += 'xResult = yResult = ' + commonEasingLogic + ';\n';
        } else {
            if (!xSelected && !ySelected) {
                finalExpression += 'outMin = ctrlLayer.effect("' + newPropName + '_Min Output X")("Slider");\n';
                finalExpression += 'outMax = ctrlLayer.effect("' + newPropName + '_Max Output X")("Slider");\n';
                finalExpression += 'xResult = ' + commonEasingLogic + ';\n';
                finalExpression += 'outMin = ctrlLayer.effect("' + newPropName + '_Min Output Y")("Slider");\n';
                finalExpression += 'outMax = ctrlLayer.effect("' + newPropName + '_Max Output Y")("Slider");\n';
                finalExpression += 'yResult = ' + commonEasingLogic + ';\n';
            } else {
                // Independent per-axis sliders; each checked axis reads its own
                // _Min/Max Output X or Y slider, unchecked axes keep their original value.
                if (xSelected) {
                    finalExpression += 'outMin = ctrlLayer.effect("' + newPropName + '_Min Output X")("Slider");\n';
                    finalExpression += 'outMax = ctrlLayer.effect("' + newPropName + '_Max Output X")("Slider");\n';
                    finalExpression += 'xResult = ' + commonEasingLogic + ';\n';
                } else {
                    finalExpression += 'xResult = originalValue[0];\n';
                }

                if (ySelected) {
                    finalExpression += 'outMin = ctrlLayer.effect("' + newPropName + '_Min Output Y")("Slider");\n';
                    finalExpression += 'outMax = ctrlLayer.effect("' + newPropName + '_Max Output Y")("Slider");\n';
                    finalExpression += 'yResult = ' + commonEasingLogic + ';\n';
                } else {
                    finalExpression += 'yResult = originalValue[1];\n';
                }
            }
        }

        finalExpression += '[xResult, yResult];';
        return finalExpression;
    }
    //3D HANDLER
    /**
     * @name handle3DProperty
     * @description Configures a 3D property and adds relevant expression controls to react to audio in the audioreactor comp and adds controls for the expression - 3D position/3D scale/3D anchor point, based on the options selected in the 3D dialog 
     * @param {Layer} layer - The After Effects layer where the property resides
     * @param {boolean} xSelected - Determines if the x value is affected by the expression - basedon on the selection in the 3D dialog.
     * @param {boolean} ySelected - Determines if the y value is affected by the expression - basedon on the selection in the 3D dialog.
     * @param {boolean} zSelected - Determines if the z value is affected by the expression - basedon on the selection in the 3D dialog.
     * @param {boolean} isUnified - Determines if the effect should be applied uniformly to all axes - basedon on the selection in the 3D dialog.
     * @param {string} propName - name of 3D property to add the expression to
     * @param {string} audioReactorName - name of the composition that contains the audio reactor.
     * @param {string} propMatchName - matchName of the property to add the expression to
     * @returns {void}
     *  
    */
    function handle3DProperty(layer, xSelected, ySelected, zSelected, isUnified, propName, audioReactorName, propMatchName) {
        // Add Easing dropdown
        var dropDownParams = ["Linear", "EaseIn", "EaseOut", "EaseInOut"];
        var dropdown = layer.Effects.addProperty("ADBE Dropdown Control");
        var setDropDownParams = dropdown.property(1).setPropertyParameters(dropDownParams);
        setDropDownParams.propertyGroup(1).name = propName + "_Easing Type";

        // Create Sliders based on whether it's unified or individual
        if (isUnified) {
            var outMinSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMinSlider.name = propName + "_Min Output";

            var outMaxSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMaxSlider.name = propName + "_Max Output";
        } else {
            var outMinXSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMinXSlider.name = propName + "_Min Output X";

            var outMaxXSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMaxXSlider.name = propName + "_Max Output X";

            var outMinYSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMinYSlider.name = propName + "_Min Output Y";

            var outMaxYSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMaxYSlider.name = propName + "_Max Output Y";

            var outMinZSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMinZSlider.name = propName + "_Min Output Z";

            var outMaxZSlider = layer.Effects.addProperty("ADBE Slider Control");
            outMaxZSlider.name = propName + "_Max Output Z";
        }

        // Build the expression
        var expression = build3DExpression(xSelected, ySelected, zSelected, isUnified, propName, audioReactorName);
        var propLink = findPropertyByMatchName(layer, propMatchName);

        // Apply the expression to the property
        propLink.expression = expression;
    }

    /**
     * @name build3DExpression
     * @description Generates a 3D expression string based on the parameters provided.
     * @param {boolean} xSelected - Determines if the x-axis is affected by the expression.
     * @param {boolean} ySelected - Determines if the y-axis is affected by the expression.
     * @param {boolean} zSelected - Determines if the z-axis is affected by the expression.
     * @param {boolean} isUnified - Determines if the effect should be applied uniformly to all axes.
     * @param {string} propName - The name of the property being affected (e.g., "Position", "Scale").
     * @param {string} audioReactorName - The name of the composition that contains the audio reactor.
     * @returns {string} A string containing the complete expression for After Effects.
     * 
     * 
     */
    function build3DExpression(xSelected, ySelected, zSelected, isUnified, propName, audioReactorName) {
        var expr = 'var d = comp("' + audioReactorName + '").layer("Select Frequency").effect("Audio Reactor")("Output Power");\n' +
            'var iMin = 0;\n' +
            'var iMax = 100;\n' +
            'var ctrlLayer = thisLayer;\n' +
            'var easeType = ctrlLayer.effect("' + propName + '_Easing Type")("Menu").value;\n';

        if (isUnified) {
            expr += 'var outMin = ctrlLayer.effect("' + propName + '_Min Output")("Slider");\n' +
                'var outMax = ctrlLayer.effect("' + propName + '_Max Output")("Slider");\n';
        } else {
            expr += 'var outMinX = ctrlLayer.effect("' + propName + '_Min Output X")("Slider");\n' +
                'var outMaxX = ctrlLayer.effect("' + propName + '_Max Output X")("Slider");\n' +
                'var outMinY = ctrlLayer.effect("' + propName + '_Min Output Y")("Slider");\n' +
                'var outMaxY = ctrlLayer.effect("' + propName + '_Max Output Y")("Slider");\n' +
                'var outMinZ = ctrlLayer.effect("' + propName + '_Min Output Z")("Slider");\n' +
                'var outMaxZ = ctrlLayer.effect("' + propName + '_Max Output Z")("Slider");\n';
        }

        // A 2D scale still has value.length 2, so guard z.
        expr += 'var x = value[0];\n' +
            'var y = value[1];\n' +
            'var z = (value.length > 2) ? value[2] : 0;\n';

        // easeType maps to the dropdown: 1 Linear, 2 EaseIn, 3 EaseOut, else Ease.
        // AE has no easeInOut() function; ease() is the in-out curve.
        function axisBlock(axis, mn, mx) {
            return 'if (easeType == 1) ' + axis + ' = linear(d, iMin, iMax, ' + mn + ', ' + mx + ');\n' +
                'else if (easeType == 2) ' + axis + ' = easeIn(d, iMin, iMax, ' + mn + ', ' + mx + ');\n' +
                'else if (easeType == 3) ' + axis + ' = easeOut(d, iMin, iMax, ' + mn + ', ' + mx + ');\n' +
                'else ' + axis + ' = ease(d, iMin, iMax, ' + mn + ', ' + mx + ');\n';
        }

        var mnX = isUnified ? 'outMin' : 'outMinX';
        var mxX = isUnified ? 'outMax' : 'outMaxX';
        var mnY = isUnified ? 'outMin' : 'outMinY';
        var mxY = isUnified ? 'outMax' : 'outMaxY';
        var mnZ = isUnified ? 'outMin' : 'outMinZ';
        var mxZ = isUnified ? 'outMax' : 'outMaxZ';

        if (xSelected) expr += axisBlock('x', mnX, mxX);
        if (ySelected) expr += axisBlock('y', mnY, mxY);
        if (zSelected) expr += axisBlock('z', mnZ, mxZ);

        // Match the property's dimensionality so a 2D Scale isn't handed a 3-vec.
        expr += '(value.length > 2) ? [x, y, z] : [x, y];';

        return expr;
    }

    //HELPER FUNCTIONS
    /**
     * @name suspendLayerExpressions
     * @description Turns OFF every enabled expression on the layer (recursively)
     * so subsequent effect-stack edits don't force AE to recompute the audio
     * graph after each change. Returns the list of properties that were disabled
     * so they can be restored. Reading .expression / toggling .expressionEnabled
     * does not evaluate anything.
     * @param {Layer} layer
     * @returns {Property[]} properties that were turned off
     */
    function suspendLayerExpressions(layer) {
        var suspended = [];
        function walk(propGroup) {
            for (var i = 1; i <= propGroup.numProperties; i++) {
                var p = propGroup.property(i);
                try {
                    if (p.canSetExpression && p.expressionEnabled &&
                        typeof p.expression === 'string' && p.expression !== "") {
                        p.expressionEnabled = false;
                        suspended.push(p);
                    }
                } catch (e) { }
                if (p.propertyType === PropertyType.INDEXED_GROUP ||
                    p.propertyType === PropertyType.NAMED_GROUP) {
                    walk(p);
                }
            }
        }
        try { walk(layer); } catch (eWalk) { }
        return suspended;
    }

    /**
     * @name restoreLayerExpressions
     * @description Re-enables the expressions suspended by suspendLayerExpressions.
     * @param {Property[]} suspended
     */
    function restoreLayerExpressions(suspended) {
        for (var i = 0; i < suspended.length; i++) {
            try { suspended[i].expressionEnabled = true; } catch (e) { }
        }
    }

    /**
     * @name compareAndReplace
     * @description - compares two strings and replaces the first string with the second string, if a string contains multiple words it will replace only the word that matches the reference string, if the string does not contain the reference string it will return the original string. Using the isCaseSensitive parameter you can determine if the comparison should be case sensitive.
     * @param {string} inputString - The string to be modified.
     * @param {string} referenceString - The string to that you want to compare input string to.
     * @param {string} outputString - The string that you want to replace the input string with.
     * @param {boolean} isCaseSensitive - Determines if the comparison should be case sensitive.
     * @returns {string} if input string was matched to reference string it will return the modified string, if not it will return the original string.
    */
    function compareAndReplace(inputString, referenceString, outputString, isCaseSensitive) {
        var searchStr = isCaseSensitive ? referenceString : referenceString.toLowerCase();
        var sourceStr = isCaseSensitive ? inputString : inputString.toLowerCase();

        if (sourceStr.indexOf(searchStr) !== -1) {
            var regex = new RegExp(referenceString, isCaseSensitive ? "" : "i");
            return inputString.replace(regex, outputString);
        } else {
            return inputString;
        }
    };

    /**
     * @name getAudioReactorName
     * @description - gets the name of the audioreactor selected in the dropdown menu of the main UI.
     * @param {string} dd - The dropdown object that contains the name of the composition that contains the audio reactor.
     * @returns {string|null} returns the name of the selected audioreactor.
    */
    function getAudioReactorName(dd) {
        if (dd && dd.selection) {
            return dd.selection.toString();
        } else {
            return null;
        }
    };

    /**
     * @name findPropertyByMatchName
     * @description - finds a property by matchName and returns the property object.
     * @param {Layer||PropertyGroup} layerOrPropGroup - The layer or property group that contains the property you want to find.
     * @param {string} matchName - The matchName of the property you want to find.
     * @returns {Object} returns the property object that matches the matchName. 
    */
    function findPropertyByMatchName(layerOrPropGroup, matchName) {
        for (var i = 1; i <= layerOrPropGroup.numProperties; i++) {
            var prop = layerOrPropGroup.property(i);
            if (prop.matchName === matchName) {
                return prop;
            }
            if (prop.propertyType === PropertyType.INDEXED_GROUP || prop.propertyType === PropertyType.NAMED_GROUP) {
                var foundProp = findPropertyByMatchName(prop, matchName);
                if (foundProp) {
                    return foundProp;
                }
            }
        }
        return null;
    };

    /**
     * Finds a property in an After Effects composition based on a string address.
     *
     * @param {string} propAddr - The address of the property.
     * @return {Object|null} - The found property object, or null if not found.
     * @example 
     * var propAddr: layer("Shape Layer 1").property("Effects").property("Vertex_3").property("Vertex")
     * var prop = findPropertyByAddress(propAddr);
     */
    function findPropertyByAddress(propAddr) {
        // Split the address string into parts for layer and property hierarchy
        var parts = propAddr.match(/(?:layer|property)\("([^"]+)"\)/g);

        // Reference to your composition, assuming it's the active composition
        var myComp = app.project.activeItem;

        // Validate that we're dealing with a comp
        if (!(myComp instanceof CompItem)) {
            alert('Active item is not a composition.');
            return null;
        }

        // Starting object, initially set to the composition
        var currentObject = myComp;

        for (var i = 0; i < parts.length; i++) {
            // Extract the match without layer(" or property("
            var name = parts[i].match(/(?:layer|property)\("([^"]+)"\)/)[1];

            if (i === 0 && parts[i].indexOf('layer') === 0) {
                // If we're looking for a layer, we use the layer method
                currentObject = myComp.layer(name);
            } else {
                // Otherwise, we're navigating properties
                currentObject = currentObject.property(name);
            }

            // If the property or layer is not found, return null
            if (!currentObject) return null;
        }

        // Return the final property object
        return currentObject;
    }

    return module;
})();

// Run the script
MainUI.show(this);
