#target aftereffects

(function ScribbleSFXPanel(thisObj) {
    var SCRIPT_NAME = "Scribble SFX Panel";
    var CONFIG_FILENAME = "ScribbleSFX_Banks_Config.jsxinc";

    var SETTINGS_SECTION = "ScribbleSFXPanel";
    var SETTINGS_KEY_BANK_ID = "selectedBankId";
    var SETTINGS_KEY_MASTER_VOLUME = "masterVolumeDb";

    var DEFAULT_GRID_COLUMNS = 3;
    var PREVIEW_WINDOW_DELAY = 0.15;

    var VOLUME_MIN_DB = -36;
    var VOLUME_MAX_DB = 12;
    var VOLUME_DEFAULT_DB = 0;

    var state = {
        config: null,
        configFile: null,
        dropdown: null,
        gridGroup: null,
        statusText: null,
        detailsText: null,
        volumeSlider: null,
        volumeValueText: null,
        panel: null
    };

    function getScriptFile() {
        return new File($.fileName);
    }

    function getConfigFile() {
        var scriptFile = getScriptFile();
        return new File(scriptFile.parent.fsName + "/" + CONFIG_FILENAME);
    }

    function saveSetting(key, value) {
        app.settings.saveSetting(SETTINGS_SECTION, key, String(value));
    }

    function loadSetting(key, defaultValue) {
        if (app.settings.haveSetting(SETTINGS_SECTION, key)) {
            return app.settings.getSetting(SETTINGS_SECTION, key);
        }
        return defaultValue;
    }

    function showError(message) {
        alert(message, SCRIPT_NAME);
    }

    function trimString(str) {
        return String(str).replace(/^\s+|\s+$/g, "");
    }

    function slugify(str) {
        var s = trimString(str).toLowerCase();
        s = s.replace(/[^a-z0-9]+/g, "-");
        s = s.replace(/^-+|-+$/g, "");
        return s || "bank";
    }

    function isAbsolutePath(pathStr) {
        if (!pathStr) return false;
        if (pathStr.indexOf("/") === 0) return true;      // macOS / Unix
        if (pathStr.indexOf("~/") === 0) return true;     // macOS home shortcut
        if (pathStr.length >= 3) {
            var c0 = pathStr.charAt(0);
            var c1 = pathStr.charAt(1);
            var c2 = pathStr.charAt(2);
            var isDriveLetter = ((c0 >= "A" && c0 <= "Z") || (c0 >= "a" && c0 <= "z"));
            if (isDriveLetter && c1 === ":" && (c2 === "\\" || c2 === "/")) return true; // Windows drive path
        }
        return false;
    }

    function ensureSlash(pathStr) {
        return pathStr.replace(/\\/g, "/");
    }

    function toNumberString(num) {
        return (Math.round(num * 1000) / 1000).toString();
    }

    function roundToOneDecimal(num) {
        return Math.round(num * 10) / 10;
    }

    function clamp(num, min, max) {
        if (num < min) return min;
        if (num > max) return max;
        return num;
    }

    function getSavedMasterVolumeDb() {
        var raw = Number(loadSetting(SETTINGS_KEY_MASTER_VOLUME, VOLUME_DEFAULT_DB));
        if (isNaN(raw)) raw = VOLUME_DEFAULT_DB;
        return clamp(raw, VOLUME_MIN_DB, VOLUME_MAX_DB);
    }

    function getMasterVolumeDb() {
        if (state.volumeSlider) {
            return clamp(Number(state.volumeSlider.value), VOLUME_MIN_DB, VOLUME_MAX_DB);
        }
        return getSavedMasterVolumeDb();
    }

    function formatDbLabel(db) {
        var val = roundToOneDecimal(db);
        if (Math.abs(val - Math.round(val)) < 0.001) {
            val = Math.round(val);
        }
        return val + " dB";
    }

    function updateVolumeUI() {
        if (!state.volumeSlider || !state.volumeValueText) return;

        var db = clamp(Number(state.volumeSlider.value), VOLUME_MIN_DB, VOLUME_MAX_DB);
        state.volumeSlider.value = db;
        state.volumeValueText.text = formatDbLabel(db);
    }


    function getActiveComp() {
        var item = app.project ? app.project.activeItem : null;
        if (item && item instanceof CompItem) {
            return item;
        }
        return null;
    }

    function parseStampSeconds(stamp, fps) {
        var parts = String(stamp).split(":");
        if (parts.length !== 2) {
            throw new Error("Invalid timestamp '" + stamp + "'. Use seconds:frames, e.g. 8:12");
        }

        var secs = parseInt(parts[0], 10);
        var frames = parseInt(parts[1], 10);

        if (isNaN(secs) || isNaN(frames)) {
            throw new Error("Invalid timestamp '" + stamp + "'.");
        }

        return secs + (frames / fps);
    }

    function getBankFPS(bank) {
        if (bank.fps && Number(bank.fps) > 0) {
            return Number(bank.fps);
        }
        if (state.config && state.config.defaultFPS && Number(state.config.defaultFPS) > 0) {
            return Number(state.config.defaultFPS);
        }
        return 30;
    }

    function validateConfig(configObj) {
        if (!configObj) {
            throw new Error("Config file did not define ScribbleSFXBanksConfig.");
        }
        if (!configObj.banks || !configObj.banks.length) {
            throw new Error("Config must contain at least one sound bank in banks: [ ... ].");
        }

        var seenIds = {};
        var i, j, bank, fps, prevTime, marker, markerTime;

        for (i = 0; i < configObj.banks.length; i++) {
            bank = configObj.banks[i];
            if (!bank.name) {
                throw new Error("Bank #" + (i + 1) + " is missing a name.");
            }
            if (!bank.file) {
                throw new Error("Bank '" + bank.name + "' is missing a file path.");
            }
            if (!bank.markers || !bank.markers.length) {
                throw new Error("Bank '" + bank.name + "' must contain at least one marker.");
            }

            if (!bank.id || trimString(bank.id) === "") {
                bank.id = slugify(bank.name);
            }
            if (seenIds[bank.id]) {
                throw new Error("Duplicate bank id '" + bank.id + "'. Each bank id must be unique.");
            }
            seenIds[bank.id] = true;

            fps = getBankFPS(bank);
            prevTime = -1;

            for (j = 0; j < bank.markers.length; j++) {
                marker = bank.markers[j];
                if (!marker.label) {
                    throw new Error("A marker in bank '" + bank.name + "' is missing a label.");
                }
                if (!marker.stamp) {
                    throw new Error("Marker '" + marker.label + "' in bank '" + bank.name + "' is missing a stamp.");
                }

                markerTime = parseStampSeconds(marker.stamp, fps);
                if (markerTime <= prevTime) {
                    throw new Error(
                        "Markers in bank '" + bank.name + "' must be in ascending order. Problem near '" + marker.label + "'."
                    );
                }
                prevTime = markerTime;
            }
        }
    }

    function loadConfig() {
        var cfgFile = getConfigFile();
        if (!cfgFile.exists) {
            throw new Error("Config file not found:\r" + cfgFile.fsName);
        }

        state.configFile = cfgFile;

        try {
            $.global.ScribbleSFXBanksConfig = undefined;
        } catch (e1) {}

        var ok = $.evalFile(cfgFile);
        if (ok === false) {
            throw new Error("Could not evaluate config file:\r" + cfgFile.fsName);
        }

        var cfg = $.global.ScribbleSFXBanksConfig;
        validateConfig(cfg);
        state.config = cfg;
        return cfg;
    }

    function resolveBaseFolder(bank) {
        var basePath = "";

        if (bank.baseFolder && trimString(bank.baseFolder) !== "") {
            basePath = trimString(bank.baseFolder);
        } else if (state.config.audioBaseFolder && trimString(state.config.audioBaseFolder) !== "") {
            basePath = trimString(state.config.audioBaseFolder);
        }

        if (!basePath) {
            return state.configFile.parent;
        }

        if (isAbsolutePath(basePath)) {
            return new Folder(basePath);
        }

        return new Folder(state.configFile.parent.fsName + "/" + basePath);
    }

    function resolveAudioFile(bank) {
        var filePath = trimString(bank.file);
        var fileObj;

        if (isAbsolutePath(filePath)) {
            fileObj = new File(filePath);
        } else {
            fileObj = new File(resolveBaseFolder(bank).fsName + "/" + filePath);
        }

        return fileObj;
    }

    function getBankById(bankId) {
        if (!state.config || !state.config.banks) return null;

        var i;
        for (i = 0; i < state.config.banks.length; i++) {
            if (state.config.banks[i].id === bankId) {
                return state.config.banks[i];
            }
        }
        return null;
    }

    function getSelectedBank() {
        if (!state.dropdown || !state.dropdown.selection) return null;
        var item = state.dropdown.selection;
        return item && item._bank ? item._bank : null;
    }

    function getSavedBankId() {
        return loadSetting(SETTINGS_KEY_BANK_ID, "");
    }

    function setSelectedBankById(bankId) {
        if (!state.dropdown) return;

        var i;
        for (i = 0; i < state.dropdown.items.length; i++) {
            if (state.dropdown.items[i]._bank && state.dropdown.items[i]._bank.id === bankId) {
                state.dropdown.selection = i;
                return;
            }
        }

        if (state.dropdown.items.length > 0) {
            state.dropdown.selection = 0;
        }
    }

    function clearGroup(group) {
        while (group.children.length > 0) {
            group.remove(group.children[0]);
        }
    }

    function updateStatusForBank(bank) {
        if (!bank || !state.statusText || !state.detailsText) return;

        var fileObj = resolveAudioFile(bank);
        var fps = getBankFPS(bank);
        var fileLabel = fileObj.exists ? fileObj.fsName : (fileObj.fsName + "  (missing)");

        state.statusText.text = "Bank: " + bank.name + "   |   Timestamp FPS: " + fps;
        state.detailsText.text = fileLabel;
        state.detailsText.helpTip = fileObj.fsName;
    }

    function refreshBankList() {
        if (!state.dropdown) return;

        clearGroup(state.gridGroup);
        state.dropdown.removeAll();

        var i, item, bank;
        for (i = 0; i < state.config.banks.length; i++) {
            bank = state.config.banks[i];
            item = state.dropdown.add("item", bank.name);
            item._bank = bank;
        }

        setSelectedBankById(getSavedBankId());
        if (!state.dropdown.selection && state.dropdown.items.length > 0) {
            state.dropdown.selection = 0;
        }

        rebuildGrid();
    }

    function findImportedFootageByPath(fileObj) {
        if (!app.project || !fileObj) return null;

        var targetPath = fileObj.fsName;
        var i, item;

        for (i = 1; i <= app.project.numItems; i++) {
            item = app.project.item(i);
            if (item instanceof FootageItem) {
                try {
                    if (item.file && item.file.fsName === targetPath) {
                        return item;
                    }
                } catch (err) {}
            }
        }
        return null;
    }

    function getOrImportAudioFootage(fileObj) {
        var existing = findImportedFootageByPath(fileObj);
        if (existing) {
            return existing;
        }

        var io = new ImportOptions(fileObj);
        var imported = app.project.importFile(io);

        if (!(imported instanceof FootageItem)) {
            throw new Error("Could not import audio file:\r" + fileObj.fsName);
        }

        return imported;
    }

    function getSegmentInfo(bank, markerIndex) {
        var marker = bank.markers[markerIndex];
        if (!marker) {
            throw new Error("Marker index out of range.");
        }

        var fileObj = resolveAudioFile(bank);
        if (!fileObj.exists) {
            throw new Error("Audio file not found:\r" + fileObj.fsName);
        }

        var footage = getOrImportAudioFootage(fileObj);
        var fps = getBankFPS(bank);
        var srcStart = parseStampSeconds(marker.stamp, fps);
        var srcEnd;

        if (markerIndex < bank.markers.length - 1) {
            srcEnd = parseStampSeconds(bank.markers[markerIndex + 1].stamp, fps);
        } else {
            srcEnd = footage.duration;
        }

        if (srcEnd <= srcStart) {
            srcEnd = srcStart + (1 / fps);
        }
        if (srcEnd > footage.duration) {
            srcEnd = footage.duration;
        }

        return {
            bank: bank,
            marker: marker,
            fileObj: fileObj,
            footage: footage,
            srcStart: srcStart,
            srcEnd: srcEnd,
            duration: srcEnd - srcStart,
            fps: fps
        };
    }

    function getAudioLevelsProperty(layer) {
        var audioLevelsProp = null;
        try {
            audioLevelsProp = layer.property("ADBE Audio Group").property("ADBE Audio Levels");
        } catch (e1) {}

        if (!audioLevelsProp) {
            try {
                audioLevelsProp = layer.property("Audio").property("Audio Levels");
            } catch (e2) {}
        }

        if (!audioLevelsProp) {
            throw new Error("Could not access Audio Levels for volume control.");
        }

        return audioLevelsProp;
    }

    function setLayerVolume(layer, volumeDb) {
        var audioLevelsProp = getAudioLevelsProperty(layer);
        audioLevelsProp.setValue([volumeDb, volumeDb]);
    }

    function applyFadeOutToLayer(layer, comp, startVolumeDb) {
        if (!layer || !comp) return;

        var audioLevelsProp = getAudioLevelsProperty(layer);

        var startTime = layer.inPoint;
        var endTime = layer.outPoint;
        var segmentDuration = endTime - startTime;
        if (segmentDuration <= 0) return;

        var fadeStart = startTime + (segmentDuration * 0.5);
        var fadeEnd = endTime - Math.min(comp.frameDuration, segmentDuration * 0.02);

        if (fadeEnd <= fadeStart) {
            fadeEnd = endTime;
        }
        if (fadeStart < startTime) fadeStart = startTime;
        if (fadeStart > endTime) fadeStart = endTime;
        if (fadeEnd < fadeStart) fadeEnd = fadeStart;
        if (fadeEnd > endTime) fadeEnd = endTime;

        audioLevelsProp.setValueAtTime(startTime, [startVolumeDb, startVolumeDb]);
        audioLevelsProp.setValueAtTime(fadeStart, [startVolumeDb, startVolumeDb]);
        audioLevelsProp.setValueAtTime(fadeEnd, [-48, -48]);
    }

    function addMarkerSegmentToComp(bank, markerIndex, applyFadeOut) {
        if (!app.project) {
            showError("Please open a project first.");
            return;
        }

        var comp = getActiveComp();
        if (!comp) {
            showError("Please make a composition active first.");
            return;
        }

        var addedSuccessfully = false;
        var masterVolumeDb = getMasterVolumeDb();

        app.beginUndoGroup(applyFadeOut ? "Add Scribble Sound (Fade)" : "Add Scribble Sound");

        try {
            var info = getSegmentInfo(bank, markerIndex);
            var playhead = comp.time;
            var layer = comp.layers.add(info.footage);

            layer.name = "SFX - " + bank.name + " - " + info.marker.label + (applyFadeOut ? " (fade)" : "");
            layer.startTime = playhead - info.srcStart;
            layer.inPoint = playhead;
            layer.outPoint = Math.min(comp.duration, playhead + info.duration);

            try {
                layer.audioEnabled = true;
            } catch (e1) {}

            try {
                layer.label = 9;
            } catch (e2) {}

            if (applyFadeOut) {
                applyFadeOutToLayer(layer, comp, masterVolumeDb);
            } else {
                setLayerVolume(layer, masterVolumeDb);
            }

            addedSuccessfully = true;
        } catch (err) {
            showError("Error:\r" + err.toString());
        } finally {
            app.endUndoGroup();

            if (addedSuccessfully) {
                focusCompViewer(comp);
            }
        }
    }

    function focusCompViewer(comp) {
        try {
            if (!comp) return false;

            var viewer = null;
            if (typeof comp.openInViewer === "function") {
                viewer = comp.openInViewer();
            }

            if (viewer && typeof viewer.setActive === "function") {
                return viewer.setActive();
            }

            if (app.activeViewer && typeof app.activeViewer.setActive === "function") {
                return app.activeViewer.setActive();
            }
        } catch (err) {}

        return false;
    }

    function shellQuoteMac(str) {
        return "'" + String(str).replace(/'/g, "'\\''") + "'";
    }

    function escapeAppleScriptString(str) {
        return String(str).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
    }

    function previewSegmentMac(fileObj, startTime, duration) {
        var tempFile = new File(Folder.temp.fsName + "/ScribbleSFX_preview.applescript");
        var scriptText = "";

        scriptText += 'set soundFile to POSIX file "' + escapeAppleScriptString(fileObj.fsName) + '" as alias\n';
        scriptText += 'tell application "QuickTime Player"\n';
        scriptText += 'activate\n';
        scriptText += 'open soundFile\n';
        scriptText += 'set docRef to front document\n';
        scriptText += 'delay ' + toNumberString(PREVIEW_WINDOW_DELAY) + '\n';
        scriptText += 'set current time of docRef to ' + toNumberString(startTime) + '\n';
        scriptText += 'play docRef\n';
        scriptText += 'delay ' + toNumberString(duration) + '\n';
        scriptText += 'stop docRef\n';
        scriptText += 'close docRef saving no\n';
        scriptText += 'end tell\n';

        tempFile.encoding = "UTF-8";
        if (!tempFile.open("w")) {
            throw new Error("Could not create temporary AppleScript preview file.");
        }
        tempFile.write(scriptText);
        tempFile.close();

        try {
            system.callSystem("/usr/bin/osascript " + shellQuoteMac(tempFile.fsName));
        } finally {
            try { tempFile.remove(); } catch (e) {}
        }
    }

    function auditionMarker(bank, markerIndex) {
        try {
            var info = getSegmentInfo(bank, markerIndex);

            if (info.duration <= 0) {
                throw new Error("The selected marker has zero duration.");
            }

            if ($.os && $.os.toLowerCase().indexOf("mac") !== -1) {
                previewSegmentMac(info.fileObj, info.srcStart, info.duration);
            } else {
                showError(
                    "Audition is implemented for macOS in this version.\r\r" +
                    "You can still place sounds into the comp normally."
                );
            }
        } catch (err) {
            showError("Audition error:\r" + err.toString());
        }
    }

    function createMarkerCell(parentGroup, bank, markerIndex) {
        var marker = bank.markers[markerIndex];
        var cell = parentGroup.add("panel", undefined, marker.label);
        cell.orientation = "column";
        cell.alignChildren = ["fill", "top"];
        cell.margins = 8;
        cell.spacing = 6;
        cell.preferredSize.width = 220;

        var stampText = cell.add("statictext", undefined, marker.stamp);
        stampText.alignment = ["fill", "top"];

        var buttonRow = cell.add("group");
        buttonRow.orientation = "row";
        buttonRow.alignChildren = ["fill", "fill"];
        buttonRow.spacing = 6;

        var addBtn = buttonRow.add("button", undefined, "Add");
        addBtn.preferredSize = [68, 26];

        var addFadeBtn = buttonRow.add("button", undefined, "Add (fade)");
        addFadeBtn.preferredSize = [82, 26];

        var previewBtn = buttonRow.add("button", undefined, "Audition");
        previewBtn.preferredSize = [68, 26];

        if (markerIndex < bank.markers.length - 1) {
            cell.helpTip = "Source: " + marker.stamp + " → " + bank.markers[markerIndex + 1].stamp;
        } else {
            cell.helpTip = "Source: " + marker.stamp + " → end of file";
        }

        addBtn.onClick = function () {
            addMarkerSegmentToComp(bank, markerIndex, false);
        };

        addFadeBtn.onClick = function () {
            addMarkerSegmentToComp(bank, markerIndex, true);
        };

        previewBtn.onClick = function () {
            auditionMarker(bank, markerIndex);
        };
    }

    function rebuildGrid() {
        if (!state.gridGroup) return;

        clearGroup(state.gridGroup);

        var bank = getSelectedBank();
        if (!bank) {
            state.panel.layout.layout(true);
            return;
        }

        saveSetting(SETTINGS_KEY_BANK_ID, bank.id);
        updateStatusForBank(bank);

        var cols = state.config.gridColumns && Number(state.config.gridColumns) > 0
            ? Number(state.config.gridColumns)
            : DEFAULT_GRID_COLUMNS;

        var i, rowGroup;
        for (i = 0; i < bank.markers.length; i++) {
            if (i % cols === 0) {
                rowGroup = state.gridGroup.add("group");
                rowGroup.orientation = "row";
                rowGroup.alignChildren = ["left", "top"];
                rowGroup.spacing = 8;
            }
            createMarkerCell(rowGroup, bank, i);
        }

        state.panel.layout.layout(true);
        state.panel.layout.resize();
    }

    function reloadConfigAndRefresh() {
        try {
            loadConfig();
            refreshBankList();
        } catch (err) {
            showError("Config error:\r" + err.toString());
        }
    }

    function buildUI(thisObj) {
        var pal = (thisObj instanceof Panel)
            ? thisObj
            : new Window("palette", SCRIPT_NAME, undefined, { resizeable: true });

        if (!pal) return pal;

        state.panel = pal;

        pal.orientation = "column";
        pal.alignChildren = ["fill", "top"];
        pal.spacing = 8;
        pal.margins = 10;

        var topRow = pal.add("group");
        topRow.orientation = "row";
        topRow.alignChildren = ["left", "center"];
        topRow.spacing = 8;

        topRow.add("statictext", undefined, "Choose sound bank:");
        state.dropdown = topRow.add("dropdownlist", undefined, []);
        state.dropdown.minimumSize.width = 240;

        var reloadBtn = topRow.add("button", undefined, "Reload Config");

        state.statusText = pal.add("statictext", undefined, "");
        state.statusText.alignment = ["fill", "top"];

        state.detailsText = pal.add("statictext", undefined, "");
        state.detailsText.alignment = ["fill", "top"];

        var volumeRow = pal.add("group");
        volumeRow.orientation = "row";
        volumeRow.alignChildren = ["left", "center"];
        volumeRow.spacing = 8;

        volumeRow.add("statictext", undefined, "Master volume:");
        state.volumeSlider = volumeRow.add("slider", undefined, getSavedMasterVolumeDb(), VOLUME_MIN_DB, VOLUME_MAX_DB);
        state.volumeSlider.preferredSize.width = 180;
        state.volumeValueText = volumeRow.add("statictext", undefined, "");
        state.volumeValueText.preferredSize.width = 50;

        state.volumeSlider.onChanging = function () {
            updateVolumeUI();
        };

        state.volumeSlider.onChange = function () {
            updateVolumeUI();
            saveSetting(SETTINGS_KEY_MASTER_VOLUME, getMasterVolumeDb());
        };

        updateVolumeUI();

        var helpText = pal.add(
            "statictext",
            undefined,
            "Use Add to place a clip segment in the active comp at the current master volume. Add (fade) starts at the master volume and fades out across the second half of the clip. Audition previews the segment before placement."
        );
        helpText.alignment = ["fill", "top"];

        state.gridGroup = pal.add("group");
        state.gridGroup.orientation = "column";
        state.gridGroup.alignChildren = ["left", "top"];
        state.gridGroup.spacing = 8;

        state.dropdown.onChange = function () {
            rebuildGrid();
        };

        reloadBtn.onClick = function () {
            reloadConfigAndRefresh();
        };

        pal.onResizing = pal.onResize = function () {
            this.layout.resize();
        };

        return pal;
    }

    var myPal = buildUI(thisObj);

    reloadConfigAndRefresh();

    if (myPal instanceof Window) {
        myPal.center();
        myPal.show();
    }
})(this);
