// ============================================================
// MotionPotion CompDuplicator  —  free After Effects script
// ------------------------------------------------------------
// A real duplicate. After Effects' own Duplicate copies the comp but leaves its
// precomp layers pointing at the SAME nested comps, so editing the copy edits the
// original. This walks the whole tree and duplicates every nested comp with it.
//
// Shared stays shared: a nested comp used three times inside the tree is
// duplicated ONCE and all three layers point at that one copy, so the copy has
// the same internal structure as the original — not three drifting clones.
//
// Footage, solids and audio are NOT duplicated (they are read-only sources).
//
// Expressions that reach across comps — comp("Something") — are repointed at the
// duplicated comp, which is the part that silently rots if you do this by hand.
//
// Select the comp(s) in the Project panel (or just have one open) and click.
//
// Install (either works):
//   • Dockable panel: put this .jsx in After Effects'  ScriptUI Panels  folder,
//     then open it from  Window > MotionPotion-CompDuplicator.jsx
//   • One-off:  File > Scripts > Run Script File…  and pick this file.
//
// Part of MotionPotion — motionpotion.co
// ============================================================

(function (thisObj) {

  var MAX_DEPTH = 50;
  var PREFS = "MotionPotion CompDuplicator";   // AE settings, no file access needed

  function loadPref(key, dflt) {
    try { if (app.settings.haveSetting(PREFS, key)) return app.settings.getSetting(PREFS, key) === "1"; } catch (e) {}
    return dflt;
  }
  function savePref(key, value) {
    try { app.settings.saveSetting(PREFS, key, value ? "1" : "0"); } catch (e) {}
  }

  // ---------- tree ----------
  function sourceComp(layer) {
    var s = null;
    try { s = layer.source; } catch (e) { return null; }
    return (s instanceof CompItem) ? s : null;
  }

  // Is `needle` reachable from `root`? Used to drop selected comps that another
  // selected comp already contains — they come along with the parent anyway.
  function contains(root, needle, depth) {
    if (depth > MAX_DEPTH) return false;
    for (var i = 1; i <= root.numLayers; i++) {
      var s = sourceComp(root.layer(i));
      if (!s) continue;
      if (s.id === needle.id) return true;
      if (contains(s, needle, depth + 1)) return true;
    }
    return false;
  }

  // ---------- the duplicate ----------
  // `map` is keyed by the ORIGINAL comp's id, which is what keeps sharing intact
  // and makes a diamond (one comp used twice) copy once.
  function deepDuplicate(comp, map, order, stats, depth) {
    if (map[comp.id]) return map[comp.id];
    if (depth > MAX_DEPTH) { stats.tooDeep++; return comp; }

    var dup = comp.duplicate();
    map[comp.id] = dup;
    order.push({ oldName: comp.name, dup: dup });
    if (depth > 0) stats.nested++;

    for (var i = 1; i <= dup.numLayers; i++) {
      var layer = dup.layer(i);
      var src = sourceComp(layer);
      if (!src) continue;
      var wasLocked = layer.locked;
      if (wasLocked) layer.locked = false;
      try {
        layer.replaceSource(deepDuplicate(src, map, order, stats, depth + 1), false);
      } catch (e) {
        stats.errors.push(dup.name + " / " + layer.name + ": " + (e && e.message ? e.message : e));
      }
      if (wasLocked) layer.locked = true;
    }
    return dup;
  }

  // ---------- expressions ----------
  function walkProperties(group, fn, depth) {
    if (depth > MAX_DEPTH) return;
    var n = 0;
    try { n = group.numProperties; } catch (e) { return; }
    for (var i = 1; i <= n; i++) {
      var p;
      try { p = group.property(i); } catch (e2) { continue; }
      if (!p) continue;
      try {
        if (p.propertyType === PropertyType.PROPERTY) fn(p);
        else walkProperties(p, fn, depth + 1);
      } catch (e3) {}
    }
  }

  function replaceAll(text, find, into) {
    var out = "", from = 0, at;
    while ((at = text.indexOf(find, from)) !== -1) {
      out += text.substring(from, at) + into;
      from = at + find.length;
    }
    return out + text.substring(from);
  }

  // Rewrite comp("old") / comp('old') inside the copies so cross-comp expressions
  // follow the duplicate instead of reaching back into the original tree.
  function repointExpressions(order, stats) {
    var renames = [];
    for (var i = 0; i < order.length; i++) {
      if (order[i].oldName !== order[i].dup.name) renames.push(order[i]);
    }
    if (renames.length === 0) return;

    for (var c = 0; c < order.length; c++) {
      var comp = order[c].dup;
      for (var l = 1; l <= comp.numLayers; l++) {
        var layer = comp.layer(l);
        var wasLocked = layer.locked;
        if (wasLocked) layer.locked = false;
        walkProperties(layer, function (prop) {
          var src;
          try {
            if (!prop.canSetExpression || !prop.expressionEnabled) return;
            src = prop.expression;
          } catch (e) { return; }
          if (!src || src.indexOf("comp(") === -1) return;

          var out = src;
          for (var r = 0; r < renames.length; r++) {
            var o = renames[r].oldName, n = renames[r].dup.name;
            out = replaceAll(out, 'comp("' + o + '")', 'comp("' + n + '")');
            out = replaceAll(out, "comp('" + o + "')", "comp('" + n + "')");
          }
          if (out === src) return;
          try { prop.expression = out; stats.expressions++; }
          catch (e2) { stats.errors.push(comp.name + " / " + layer.name + ": expression not writable"); }
        }, 0);
        if (wasLocked) layer.locked = true;
      }
    }
  }

  // ---------- run ----------
  function pickComps() {
    var picked = [];
    var sel = app.project.selection;
    for (var i = 0; i < sel.length; i++) {
      if (sel[i] instanceof CompItem) picked.push(sel[i]);
    }
    if (picked.length === 0) {
      var active = app.project.activeItem;
      if (active && active instanceof CompItem) picked.push(active);
      return picked;
    }
    // drop anything another pick already contains
    var out = [];
    for (var a = 0; a < picked.length; a++) {
      var swallowed = false;
      for (var b = 0; b < picked.length; b++) {
        if (a !== b && contains(picked[b], picked[a], 0)) { swallowed = true; break; }
      }
      if (!swallowed) out.push(picked[a]);
    }
    return out;
  }

  // Collect a copy and all of its nested copies in one folder, next to the
  // original — otherwise a 20-comp tree scatters 20 new items across the project.
  function fileIntoFolder(root, dup, order, stats) {
    try {
      var folder = app.project.items.addFolder(dup.name);
      try { folder.parentFolder = root.parentFolder; } catch (e) {}
      for (var i = 0; i < order.length; i++) {
        try { order[i].dup.parentFolder = folder; } catch (e2) {}
      }
      return folder.name;
    } catch (e3) {
      stats.errors.push("could not create a folder: " + (e3 && e3.message ? e3.message : e3));
      return null;
    }
  }

  function run(intoFolder) {
    var roots = pickComps();
    if (roots.length === 0) { alert("Select a composition in the Project panel first."); return; }

    var stats = { nested: 0, expressions: 0, tooDeep: 0, errors: [] };
    var made = [], folders = [];

    app.beginUndoGroup("MotionPotion CompDuplicator");
    try {
      for (var i = 0; i < roots.length; i++) {
        var map = {}, order = [];       // per root: two roots do not share copies
        var dup = deepDuplicate(roots[i], map, order, stats, 0);
        repointExpressions(order, stats);
        if (intoFolder) {
          var fname = fileIntoFolder(roots[i], dup, order, stats);
          if (fname) folders.push(fname);
        }
        made.push(dup);
      }
      for (var s = 0; s < made.length; s++) { try { made[s].selected = true; } catch (e) {} }
    } finally {
      app.endUndoGroup();
    }

    var msg = made.length + " comp(s) duplicated";
    if (stats.nested)      msg += ", " + stats.nested + " nested comp(s) copied with them";
    else                   msg += " (no nested comps)";
    if (folders.length === 1)     msg += "\nFiled into the folder \"" + folders[0] + "\".";
    else if (folders.length > 1)  msg += "\nFiled into " + folders.length + " new folders.";
    if (stats.expressions) msg += "\n" + stats.expressions + " expression(s) repointed at the copies.";
    if (stats.tooDeep)     msg += "\nNesting deeper than " + MAX_DEPTH + " was left pointing at the originals.";
    if (stats.errors.length) msg += "\n\nProblems:\n• " + stats.errors.join("\n• ");
    alert(msg);
  }

  // ---------- UI ----------
  function buildUI(host) {
    var win = (host instanceof Panel) ? host : new Window("palette", "CompDuplicator", undefined, { resizeable: true });
    win.orientation = "column";
    win.alignChildren = ["fill", "top"];
    win.spacing = 3;
    win.margins = 4;

    var cb = win.add("checkbox", undefined, "Into a new folder");
    cb.value = loadPref("folder", true);
    cb.helpTip = "Collect the copy and every nested copy in one new folder, next to the original.";
    cb.onClick = function () { savePref("folder", cb.value); };

    var btn = win.add("button", undefined, "Duplicate");
    btn.preferredSize.height = 22;
    btn.helpTip = "Duplicate the selected comp(s) together with every nested comp.";
    btn.onClick = function () {
      try { run(cb.value); } catch (e) { alert("MotionPotion CompDuplicator\n" + (e && e.message ? e.message : e)); }
    };

    win.layout.layout(true);
    win.layout.resize();
    win.onResizing = win.onResize = function () { this.layout.resize(); };
    return win;
  }

  var ui = buildUI(thisObj);
  if (ui instanceof Window) { ui.center(); ui.show(); }

})(this);
