/*
 * selsync.js - shared selection-poll coordinator for com.adamsacks.* CEP panels.
 *
 * v1 WIRE CONTRACT - FROZEN. Every participating plugin ships a wire-compatible copy of
 * this module (identical at first ship; may diverge later only on non-wire fixes). See
 * SELSYNC-shared-poll-HANDOFF.md.
 *
 * Purpose: when several of these panels are open, only ONE (the "leader") runs the
 * background selection poll (an evalScript on AE's main thread). It broadcasts an opaque
 * change-fingerprint over the CSXS event bus (Chromium-side, never touches ExtendScript).
 * Followers do ZERO background evalScript; each fires its OWN read only when a fingerprint
 * segment it subscribes to changes. Net keystroke-swallow rate: 1x regardless of how many
 * panels are open.
 *
 * Scope: this module owns ONLY the live background sync. Each plugin keeps its own local
 * hover-peek / in-panel behavior. The plugin calls setActive(false) to opt out entirely
 * (hover-peek) and setActive(true) to rejoin (live). Load via <script> BEFORE index.js.
 *
 * Capability-ranked leadership: each heartbeat carries a `cap` integer. Highest cap wins,
 * then lowest id. Capabilities NEST - cap N computes all segments of levels < N plus its
 * own - so a future richer extension (higher cap) can lead the cohort and feed unchanged
 * older plugins the base segments they subscribe to (append-only; unknown trailing
 * segments are ignored).
 */
(function (global) {
    'use strict';

    var EVENT_TYPE     = 'com.adamsacks.selsync.v1';
    var WIRE_VERSION   = 1;
    var TICK_INTERVAL  = 500;    // heartbeat + fingerprint-poll cadence (ms) - universal heartbeat
    var LEADER_TIMEOUT = 1500;   // promote after this long with no heartbeat (3 ticks at 500ms).
                                 // Wall-clock, not tick-scaled. Shortened from 3000 to cut hard-close
                                 // failover lag: a closed leader's resign does not survive page teardown
                                 // (dispatchEvent on unload never reaches other panels), so survivors must
                                 // time out. Trade-off is more false promotions from a live-but-briefly-
                                 // silent leader (CEF throttle / GC / jank); those self-heal on next heartbeat.
    var SEG_INDEX = { comp: 1, ids: 2, frame: 3 };   // fingerprint segment positions (index 0 = "v1")

    // ── Per-panel identity / config (set in register) ──
    var cs       = null;
    var myId     = null;
    var myCap    = 0;
    var mySubs   = [];      // subscribed segment names, e.g. ['comp','ids','frame']
    var onChange = null;    // plugin callback: "a subscribed segment changed - do your read + render"

    // ── Runtime state ──
    var optedOut     = true;    // true until setActive(true); hover-peek = opted out
    var role         = 'follower';
    var leaderId     = null;
    var leaderCap    = -1;
    var lastHeartbeat = 0;      // ms timestamp of the last valid heartbeat heard
    var lastFp       = '';      // last fingerprint whose subscribed segments we acted on
    var lastGoodFp   = 'v1|';   // last successfully computed fp (leader re-broadcasts it as keep-alive)
    var seq          = 0;       // per-leader broadcast counter (reserved; resets on becoming leader)
    var fpBusy       = false;   // guards the leader's fingerprint-compute evalScript

    // ── Debug trace (gated on the extension's .debug file; inert in a packaged ZXP, which
    //    has no .debug). Every open panel appends to ONE shared log so the whole cohort's
    //    election is visible in one place: /tmp/selsync_debug.txt. Uses Node fs (all three
    //    manifests enable node), never evalScript, so it never touches AE's main thread. ──
    var LOG_PATH = '/tmp/selsync_debug.txt';
    var _fs = null, _debug = false;
    var _req = (typeof require === 'function') ? require
             : (typeof window !== 'undefined' && typeof window.require === 'function') ? window.require : null;

    function dbgInit() {
        try {
            if (!_req) return;
            _fs = _req('fs');
            _debug = _fs.existsSync(cs.getSystemPath(SystemPath.EXTENSION) + '/.debug');
        } catch (e) { _debug = false; }
    }
    function dbg(msg) {
        if (!_debug || !_fs) return;
        try {
            var d = new Date(), p = function (n, w) { return ('000' + n).slice(-(w || 2)); };
            var t = p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) + '.' + p(d.getMilliseconds(), 3);
            _fs.appendFileSync(LOG_PATH, t + '  ' + (myId ? myId.replace('com.adamsacks.', '') : '?') + '  ' + msg + '\n');
        } catch (e) {}
    }

    // Reload safety: tear down a prior instance (CEP window globals persist across in-place reload).
    if (global.__selsyncTeardown) { try { global.__selsyncTeardown(); } catch (e) {} }

    // ─────────────────────────────────────────────────────────────────────
    // Fingerprint compute - the ONLY per-tick evalScript, run on the leader only.
    // Single source of the compute: every plugin ships this identical string, so two
    // implementations cannot drift. FROZEN derivation:
    //   compId = app.project.activeItem.id  (only when instanceof CompItem)
    //   ids    = selected layer.id, numeric-ascending, each followed by ","
    //   frame  = round(comp.time / comp.frameDuration)   (appended only for cap >= 1)
    // Reserved chars (% | ,) percent-encoded in every segment (base is numeric so it never
    // fires today, but the rule is frozen so the first future string segment needs no v2).
    // Empty forms: no comp = "v1|"; comp with empty selection = "v1|<compId>|".
    // ─────────────────────────────────────────────────────────────────────
    function fpScript(cap) {
        return '(function(){try{' +
            'var c=app.project.activeItem;' +
            'if(!c||!(c instanceof CompItem))return "v1|";' +
            'var e=function(s){return String(s).replace(/%/g,"%25").replace(/\\|/g,"%7C").replace(/,/g,"%2C");};' +
            'var f="v1|"+e(c.id)+"|";' +
            'var s=c.selectedLayers,a=[];' +
            'for(var i=0;i<s.length;i++)a.push(s[i].id);' +
            'a.sort(function(x,y){return x-y;});' +
            'for(var j=0;j<a.length;j++)f+=e(a[j])+",";' +
            (cap >= 1 ? 'f+="|"+e(Math.round(c.time/c.frameDuration));' : '') +
            'return f;' +
        '}catch(err){return "v1|";}})()';
    }

    // Per-subscribed-segment compare. Append-only: unknown trailing segments are ignored,
    // and only the segments this plugin subscribes to are compared (so a longer superset
    // broadcast from a higher-cap leader never causes a spurious re-read).
    function subsChanged(fpA, fpB) {
        var pa = fpA.split('|'), pb = fpB.split('|');
        for (var i = 0; i < mySubs.length; i++) {
            var k = SEG_INDEX[mySubs[i]];
            if ((pa[k] || '') !== (pb[k] || '')) return true;
        }
        return false;
    }

    function consumeFp(fp) {
        if (typeof fp !== 'string') return;
        var changed = subsChanged(fp, lastFp);
        lastFp = fp;
        if (changed) { dbg('onChange  fp=' + fp); scheduleOnChange(); }
    }

    // Jitter the change-triggered read by tens of ms so a multi-follower burst de-syncs.
    function scheduleOnChange() {
        var delay = 5 + Math.floor(Math.random() * 40);
        setTimeout(function () { if (!optedOut && onChange) onChange(); }, delay);
    }

    // Broadcast a heartbeat (which also carries the fingerprint). Leader-only.
    function broadcast(fp, resign) {
        seq++;
        var evt = new CSEvent(EVENT_TYPE, 'APPLICATION');
        evt.data = JSON.stringify({
            v: WIRE_VERSION, cap: myCap, leader: myId,
            fp: (typeof fp === 'string' ? fp : 'v1|'), seq: seq, resign: !!resign
        });
        cs.dispatchEvent(evt);
    }

    // A outranks B: higher cap wins, then lower id.
    function outranks(capA, idA, capB, idB) {
        if (capA !== capB) return capA > capB;
        return idA < idB;
    }

    // Timer tick: leader broadcasts + refreshes the fp; a follower checks for leader death.
    function tick() {
        if (optedOut) return;
        if (role === 'leader') {
            // Heartbeat EVERY tick, decoupled from the read (B11): re-broadcast the last good
            // fp so a slow read under a heavy comp can't skip heartbeats and false-timeout us.
            broadcast(lastGoodFp, false);
            if (fpBusy) return;
            fpBusy = true;
            cs.evalScript(fpScript(myCap), function (fp) {
                fpBusy = false;
                if (!fp || fp === 'EvalScript error.') return;   // modal open etc. - keep last good
                var changed = (fp !== lastGoodFp);
                lastGoodFp = fp;
                if (changed) { dbg('leader fp -> ' + fp); broadcast(fp, false); }   // push the fresh fp immediately
                consumeFp(fp);                        // leader renders its own change too
            });
        } else if (Date.now() - lastHeartbeat > LEADER_TIMEOUT) {
            dbg('leader timed out -> promoting');
            promote();
        }
    }

    function promote() {
        role = 'leader';
        seq = 0;
        dbg('-> LEADER');
        tick();   // assert presence immediately
    }

    // Incoming heartbeat from another participant.
    function onEvent(event) {
        if (optedOut) return;
        // CEP may deliver event.data either as the raw JSON string we sent, or already
        // deserialized into an object (observed: APPLICATION-scope cross-extension events
        // arrive pre-parsed). Accept both; JSON.parse on an object would coerce to
        // "[object Object]" and throw, silently dropping every heartbeat.
        var d = event && event.data;
        var msg = null;
        if (typeof d === 'string') {
            try { msg = JSON.parse(d); } catch (e) {}
        } else if (d && typeof d === 'object') {
            msg = d;
        }
        if (!msg || msg.v !== WIRE_VERSION) return;   // unparseable or unknown wire version -> ignore, self-poll fallback
        if (msg.leader === myId) return;              // our own broadcast

        if (msg.resign) {
            // Sender is stepping down; if it was our leader, hold an immediate election.
            if (role === 'follower' && msg.leader === leaderId) {
                dbg('resign RECV from ' + msg.leader + ' -> promoting');
                promote();
            }
            return;
        }

        // Only a heartbeat from our OWN leader (or one we adopt below) resets the death timer.
        // A heartbeat from a panel that does NOT outrank our current leader must NOT reset it:
        // otherwise, when our leader is closed and a lower-cap panel promotes, that panel's
        // heartbeats would pin our timeout forever, stranding us behind the dead leader while we
        // also refuse to adopt the lower-cap sender. (This was the "TR stuck / not updating" bug.)
        if (role === 'leader') {
            if (outranks(msg.cap, msg.leader, myCap, myId)) {
                dbg('stepped down -> follower of ' + msg.leader + ' (cap' + msg.cap + ')');
                role = 'follower';
                leaderId = msg.leader; leaderCap = msg.cap;
                lastHeartbeat = Date.now();
                consumeFp(msg.fp);
            }
            // else: we outrank the sender; ignore (it steps down when it hears us).
        } else {
            if (leaderId === null || outranks(msg.cap, msg.leader, leaderCap, leaderId)) {
                if (leaderId !== msg.leader) dbg('following ' + msg.leader + ' (cap' + msg.cap + ')');
                leaderId = msg.leader; leaderCap = msg.cap;
            }
            if (msg.leader === leaderId) {
                lastHeartbeat = Date.now();   // only our leader's heartbeat resets the death timer
                consumeFp(msg.fp);
            }
        }
    }

    function startTick() {
        stopTick();
        tick();
        global.__selsyncTimer = setInterval(tick, TICK_INTERVAL);
    }
    function stopTick() {
        if (global.__selsyncTimer) { clearInterval(global.__selsyncTimer); global.__selsyncTimer = null; }
    }

    // ─────────────────────────────────────────────────────────────────────
    // Public API
    // ─────────────────────────────────────────────────────────────────────
    // register - one-time setup. config = { csInterface, bundleId, cap, subscribes, onChange }.
    //   subscribes: array of segment names this plugin cares about, e.g. ['comp','ids','frame'].
    //   onChange:   fired (jittered) when a subscribed segment changes - do the plugin's read + render here.
    function register(config) {
        cs       = config.csInterface;
        myCap    = config.cap;
        mySubs   = config.subscribes || [];
        onChange = config.onChange;
        myId     = config.bundleId + '#' + Math.random().toString(36).slice(2, 10);

        dbgInit();
        dbg('loaded  cap=' + myCap + '  subs=[' + mySubs.join(',') + ']');

        // Reload safety: drop any prior listener before registering ours.
        if (global.__selsyncHandler) {
            try { cs.removeEventListener(EVENT_TYPE, global.__selsyncHandler); } catch (e) {}
        }
        global.__selsyncHandler = onEvent;
        cs.addEventListener(EVENT_TYPE, onEvent);
    }

    // setActive(true)  - live mode: join the election (provisional leader; demotes if it
    //                    hears a stronger incumbent).
    // setActive(false) - hover-peek: resign if leader, then opt out entirely (never leads,
    //                    follows, or heartbeats). Maps 1:1 to the plugin's poll toggle.
    function setActive(active) {
        if (active) {
            if (!optedOut) return;
            optedOut = false;
            role = 'leader';        // provisional
            leaderId = null; leaderCap = -1;
            seq = 0;
            lastHeartbeat = Date.now();
            dbg('setActive(true) -> provisional leader');
            startTick();
        } else {
            if (optedOut) return;
            if (role === 'leader') { dbg('resign SENT'); broadcast(lastGoodFp, true); }   // resign -> immediate election elsewhere
            optedOut = true;
            role = 'follower';
            dbg('setActive(false) -> opted out (hover-peek)');
            stopTick();
        }
    }

    global.__selsyncTeardown = function () {
        stopTick();
        if (cs && global.__selsyncHandler) {
            try { cs.removeEventListener(EVENT_TYPE, global.__selsyncHandler); } catch (e) {}
            global.__selsyncHandler = null;
        }
    };

    global.selsync = { register: register, setActive: setActive };

})(window);
