/**
 * ZDDC Classifier — "Classify & Copy" state model.
 *
 * The non-destructive workflow: the source directory is read-only; the user
 * maps each source file onto two orthogonal target trees, and a later copy
 * step writes renamed copies into a separate output directory.
 *
 *   - Tracking tab (→ filename), POSITIONAL:
 *       tracking number   = the file's ancestor folder names joined with '-'
 *       revision (+status) = its immediate parent folder, named "REV (STATUS)"
 *       title             = derived from the original filename
 *     → TRACKING_REV (STATUS) - TITLE.ext
 *   - Transmittal tab (→ output path):
 *       <party>/{issued,received}/<YYYY-MM-DD_TN (STATUS) - TITLE>/
 *
 * This module is the single source of truth: placements live in `assignments`
 * keyed by source-relative path (so they survive a re-pick); the trees define
 * structure only. All target values are DERIVED, never stored.
 */
(function () {
    'use strict';

    // ── unique ids ───────────────────────────────────────────────────────────
    var _idSeq = 0;
    function uid() {
        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
            try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ }
        }
        return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36);
    }

    // ── state ────────────────────────────────────────────────────────────────
    var state = {
        enabled: false,           // classify mode on/off
        assignments: {},          // srcKey -> { trackingNodeId, transmittalNodeId, excluded, titleOverride }
        trackingTree: [],         // [ { id, name, children:[] } ]   (leaf = no children)
        transmittalTree: [],      // [ { id, kind:'party', name, children:[ slot ] } ]
        outputName: null,         // remembered output directory display name
    };

    // id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
    var nodeIndex = {};

    // ── pub/sub ──────────────────────────────────────────────────────────────
    var listeners = [];
    function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; }
    var notifyScheduled = false;
    function notify() {
        // Coalesce bursts (a group-drop touches many keys) into one render.
        // Listeners include the target/source re-renders AND the workspace
        // autosave (workspace.js subscribes) — persistence is not this
        // module's concern.
        if (notifyScheduled) return;
        notifyScheduled = true;
        Promise.resolve().then(function () {
            notifyScheduled = false;
            for (var i = 0; i < listeners.length; i++) {
                try { listeners[i](); } catch (e) { console.error('classify listener', e); }
            }
        });
    }

    // ── source keys + title derivation ───────────────────────────────────────
    function stripRoot(p) {
        var i = (p || '').indexOf('/');
        return i < 0 ? '' : p.slice(i + 1);
    }
    // Stable key for a file: its path relative to the picked root (root segment
    // dropped), so re-picking the same directory re-attaches the same map.
    function srcKeyForFile(file) {
        var rel = stripRoot(file.folderPath || '');
        var fn = zddc.joinExtension(file.originalFilename, file.extension);
        return rel ? rel + '/' + fn : fn;
    }
    // Default title: if the original name already parses as ZDDC, reuse its
    // title; otherwise the cleaned stem (originalFilename is the stem already).
    function defaultTitle(file) {
        var full = zddc.joinExtension(file.originalFilename, file.extension);
        var parsed = zddc.parseFilename(full);
        if (parsed && parsed.valid && parsed.title) return parsed.title;
        return (file.originalFilename || '').trim();
    }

    // Parse a leaf folder label "A (IFR)" → { revision, status }. No parens →
    // the whole label is the revision and status is blank.
    var LEAF_RE = /^(.*?)\s*\(([^)]+)\)\s*$/;
    function parseLeafLabel(name) {
        var m = (name || '').match(LEAF_RE);
        if (m) return { revision: m[1].trim(), status: m[2].trim() };
        return { revision: (name || '').trim(), status: '' };
    }

    // ── assignments ──────────────────────────────────────────────────────────
    function assignmentFor(key) {
        var a = state.assignments[key];
        if (!a) {
            a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null };
            state.assignments[key] = a;
        }
        return a;
    }
    // Read-only: returns the existing entry or null (no side effects).
    function getAssignment(key) { return state.assignments[key] || null; }
    function cleanAssignment(key) {
        var a = state.assignments[key];
        if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
            delete state.assignments[key];
        }
    }

    // Place keys onto a node along one axis ('tracking' | 'transmittal').
    // nodeId null clears that axis.
    function place(keys, nodeId, axis) {
        var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
        keys.forEach(function (k) {
            var a = assignmentFor(k);
            a[field] = nodeId || null;
            a.excluded = false;          // placing un-excludes
            cleanAssignment(k);
        });
        notify();
    }
    function setExcluded(keys, excluded) {
        keys.forEach(function (k) {
            var a = assignmentFor(k);
            a.excluded = !!excluded;
            if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
            cleanAssignment(k);
        });
        notify();
    }
    // Forget any assignment for these source keys (e.g. when a .zip flips
    // between single-file and folder mode and the old keys cease to exist).
    function dropAssignments(keys) {
        var changed = false;
        (keys || []).forEach(function (k) {
            if (state.assignments[k]) { delete state.assignments[k]; changed = true; }
        });
        if (changed) notify();
    }
    function setTitleOverride(key, title) {
        var a = assignmentFor(key);
        a.titleOverride = title && title.trim() ? title.trim() : null;
        cleanAssignment(key);
        notify();
    }

    // ── node index ───────────────────────────────────────────────────────────
    function rebuildIndex() {
        nodeIndex = {};
        (function walkTracking(nodes, parent) {
            (nodes || []).forEach(function (n) {
                nodeIndex[n.id] = { node: n, kind: 'tracking', parent: parent };
                walkTracking(n.children, n);
            });
        })(state.trackingTree, null);
        (state.transmittalTree || []).forEach(function (party) {
            nodeIndex[party.id] = { node: party, kind: 'party', parent: null };
            (party.children || []).forEach(function (slot) {
                nodeIndex[slot.id] = { node: slot, kind: 'slot', parent: party };
                (slot.children || []).forEach(function (bin) {
                    nodeIndex[bin.id] = { node: bin, kind: 'transmittal', parent: slot };
                });
            });
        });
    }
    function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
    function infoFor(id) { return nodeIndex[id] || null; }

    // Ancestor name chain for a tracking node (root → node inclusive).
    function trackingChain(info) {
        var names = [];
        var cur = info;
        while (cur && cur.kind === 'tracking') {
            names.unshift(cur.node.name);
            cur = cur.parent ? infoFor(cur.parent.id) : null;
        }
        return names;
    }

    // ── tracking tree ops ────────────────────────────────────────────────────
    function addTrackingNode(parentId, name) {
        var node = { id: uid(), name: (name || 'new').trim() || 'new', children: [] };
        if (parentId) {
            var info = infoFor(parentId);
            if (!info || info.kind !== 'tracking') return null;
            info.node.children.push(node);
        } else {
            state.trackingTree.push(node);
        }
        rebuildIndex();
        notify();
        return node.id;
    }

    // ── transmittal tree ops ─────────────────────────────────────────────────
    function addParty(name) {
        var party = { id: uid(), kind: 'party', name: (name || 'Party').trim() || 'Party', children: [] };
        state.transmittalTree.push(party);
        rebuildIndex();
        notify();
        return party.id;
    }
    function ensureSlot(party, slot) {
        var existing = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
        if (existing) return existing;
        var node = { id: uid(), kind: 'slot', slot: slot, name: slot, children: [] };
        party.children.push(node);
        return node;
    }
    // Create a transmittal bin. meta = { date, type:'TRN'|'SUB', seq, status?, title? }.
    // The folder name follows the folder grammar; party node name doubles as the
    // transmittal-number prefix (so its tracking is "<party>-<type>-<seq>").
    function addTransmittalBin(partyId, slot, meta) {
        var info = infoFor(partyId);
        if (!info || info.kind !== 'party') return null;
        var slotNode = ensureSlot(info.node, slot);
        var bin = { id: uid(), kind: 'transmittal', name: transmittalFolderName(info.node.name, meta), meta: meta };
        slotNode.children.push(bin);
        rebuildIndex();
        notify();
        return bin.id;
    }
    function transmittalFolderName(partyName, meta) {
        var tn = [partyName, meta.type, meta.seq].filter(Boolean).join('-');
        var status = meta.status && zddc.isValidStatus(meta.status) ? meta.status : '---';
        var title = (meta.title && meta.title.trim()) || (meta.type === 'SUB' ? 'Submittal' : 'Transmittal');
        return zddc.formatFolder({ date: meta.date, trackingNumber: tn, status: status, title: title });
    }

    // ── shared node ops ──────────────────────────────────────────────────────
    function renameNode(id, name) {
        var info = infoFor(id);
        if (!info) return;
        if (info.kind === 'slot') return; // slots are fixed
        info.node.name = (name || '').trim() || info.node.name;
        if (info.kind === 'party') {
            // Party rename re-derives child transmittal folder names (prefix).
            (info.node.children || []).forEach(function (slot) {
                (slot.children || []).forEach(function (bin) {
                    bin.name = transmittalFolderName(info.node.name, bin.meta);
                });
            });
        }
        rebuildIndex();
        notify();
    }
    // Delete a node (and descendants). Any placement referencing a removed node
    // is cleared so no file points at a ghost.
    function deleteNode(id) {
        var info = infoFor(id);
        if (!info) return;
        var removed = {};
        (function collect(n) {
            removed[n.id] = true;
            (n.children || []).forEach(collect);
        })(info.node);

        if (info.kind === 'tracking') {
            removeFrom(info.parent ? info.parent.children : state.trackingTree, id);
        } else if (info.kind === 'party') {
            removeFrom(state.transmittalTree, id);
        } else if (info.kind === 'transmittal') {
            removeFrom(info.parent.children, id);   // info.parent is the slot node
        }
        // Clear dangling placements.
        Object.keys(state.assignments).forEach(function (k) {
            var a = state.assignments[k];
            if (a.trackingNodeId && removed[a.trackingNodeId]) a.trackingNodeId = null;
            if (a.transmittalNodeId && removed[a.transmittalNodeId]) a.transmittalNodeId = null;
            cleanAssignment(k);
        });
        rebuildIndex();
        notify();
    }
    function removeFrom(arr, id) {
        for (var i = 0; i < arr.length; i++) { if (arr[i].id === id) { arr.splice(i, 1); return; } }
    }

    // ── derive target ────────────────────────────────────────────────────────
    // Compute the full target for a file from its placements. Pure; returns
    // { tracking, revision, status, title, extension, filename, outPath,
    //   party, slot, transmittalFolder, complete, excluded, errors:[] }.
    function deriveTarget(file) {
        var key = srcKeyForFile(file);
        var a = state.assignments[key] || {};
        var out = {
            key: key,
            tracking: '', revision: '', status: '',
            title: (a.titleOverride && a.titleOverride.trim()) || defaultTitle(file),
            extension: file.extension || '',
            filename: '', outPath: '',
            party: '', slot: '', transmittalFolder: '',
            trackingLeaf: false, excluded: !!a.excluded, errors: [],
        };
        if (out.excluded) return out;

        // Axis 1 — tracking.
        if (a.trackingNodeId) {
            var ti = infoFor(a.trackingNodeId);
            if (ti && ti.kind === 'tracking') {
                var chain = trackingChain(ti);             // [root … node]
                out.tracking = chain.slice(0, -1).join('-'); // ancestors only
                var leaf = parseLeafLabel(ti.node.name);
                out.revision = leaf.revision;
                out.status = leaf.status;
                out.trackingLeaf = (ti.node.children || []).length === 0;
                if (!out.tracking) out.errors.push('tracking number is empty — the file needs at least one ancestor folder');
                if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
                if (!out.status) out.errors.push('parent folder has no "(STATUS)" — name it like "A (IFR)"');
                if (!out.trackingLeaf) out.errors.push('not in a leaf folder yet');
            }
        } else {
            out.errors.push('no tracking number assigned');
        }

        // Axis 2 — transmittal → output path.
        if (a.transmittalNodeId) {
            var xi = infoFor(a.transmittalNodeId);
            if (xi && xi.kind === 'transmittal') {
                // bin → slot → party (nodeIndex stores parent as a NODE)
                var slotInfo = xi.parent ? infoFor(xi.parent.id) : null;
                out.slot = slotInfo ? slotInfo.node.slot : '';
                out.party = slotInfo && slotInfo.parent ? slotInfo.parent.name : '';
                out.transmittalFolder = xi.node.name;
                if (out.party && out.slot && out.transmittalFolder) {
                    out.outPath = out.party + '/' + out.slot + '/' + out.transmittalFolder;
                }
            }
        } else {
            out.errors.push('not placed in a transmittal');
        }

        out.filename = zddc.formatFilename({
            trackingNumber: out.tracking, revision: out.revision,
            status: out.status, title: out.title, extension: out.extension,
        });
        if (!out.filename && out.errors.length === 0) out.errors.push('incomplete name');
        out.complete = !!(out.filename && out.outPath && out.errors.length === 0);
        return out;
    }

    // Files currently placed in a node (reverse lookup over all source files).
    function filesInNode(nodeId, axis, allFiles) {
        var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
        return (allFiles || []).filter(function (f) {
            var a = state.assignments[srcKeyForFile(f)];
            return a && a[field] === nodeId;
        });
    }

    // Per-file classification state for the left-tree markers.
    //   'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
    function fileState(file) {
        var a = state.assignments[srcKeyForFile(file)];
        if (!a) return 'none';
        if (a.excluded) return 'excluded';
        var t = !!a.trackingNodeId, x = !!a.transmittalNodeId;
        if (t && x) {
            var d = deriveTarget(file);
            return d.complete ? 'done' : 'partial';
        }
        if (t) return 'tracking';
        if (x) return 'transmittal';
        return 'none';
    }

    function stats(allFiles) {
        var s = { total: 0, excluded: 0, none: 0, partial: 0, done: 0 };
        (allFiles || []).forEach(function (f) {
            s.total++;
            var st = fileState(f);
            if (st === 'excluded') s.excluded++;
            else if (st === 'done') s.done++;
            else if (st === 'none') s.none++;
            else s.partial++;   // tracking | transmittal | partial
        });
        return s;
    }

    // ── serialize / load ─────────────────────────────────────────────────────
    function serialize() {
        return {
            assignments: state.assignments,
            trackingTree: state.trackingTree,
            transmittalTree: state.transmittalTree,
            outputName: state.outputName,
        };
    }
    function load(obj) {
        if (!obj) return;
        state.assignments = obj.assignments || {};
        state.trackingTree = obj.trackingTree || [];
        state.transmittalTree = obj.transmittalTree || [];
        state.outputName = obj.outputName || null;
        rebuildIndex();
        notify();
    }
    function reset() {
        state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
        state.outputName = null;
        rebuildIndex();
        notify();
    }

    // ── add-folder pattern expansion ─────────────────────────────────────────
    // Brace expansion for the add-folder box. Supports (non-nested) groups:
    //   {a,b,c}      → alternation: a | b | c
    //   {0001-0002}  → numeric range, zero-padded to the operands' width
    //   {0001-0002,0005} → mix ranges and literals in one group
    // Multiple groups expand as a cartesian product, e.g.
    //   "X-{PM,EL}-{0001-0002,0005}_A (IFR)" → 6 names.
    // A pattern with no braces returns itself (one name). Unbalanced braces are
    // treated literally so the user never silently loses input.
    function expandGroup(body) {
        var out = [];
        String(body).split(',').forEach(function (piece) {
            var m = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(piece);
            if (m) {
                var a = m[1], b = m[2];
                var start = parseInt(a, 10), end = parseInt(b, 10);
                // Pad when either operand carries a leading zero (e.g. 0001).
                var width = (a.length > 1 && a[0] === '0') || (b.length > 1 && b[0] === '0')
                    ? Math.max(a.length, b.length) : 0;
                var step = start <= end ? 1 : -1;
                for (var v = start; step > 0 ? v <= end : v >= end; v += step) {
                    out.push(width ? String(v).padStart(width, '0') : String(v));
                }
            } else {
                out.push(piece);
            }
        });
        return out;
    }
    function expandFolderPattern(pattern) {
        var s = String(pattern == null ? '' : pattern);
        var parts = [];                 // each: {lit} or {opts:[...]}
        var i = 0;
        while (i < s.length) {
            var open = s.indexOf('{', i);
            if (open === -1) { parts.push({ lit: s.slice(i) }); break; }
            var close = s.indexOf('}', open);
            if (close === -1) { parts.push({ lit: s.slice(i) }); break; } // unbalanced → literal
            if (open > i) parts.push({ lit: s.slice(i, open) });
            parts.push({ opts: expandGroup(s.slice(open + 1, close)) });
            i = close + 1;
        }
        var results = [''];
        parts.forEach(function (p) {
            var opts = p.lit != null ? [p.lit] : p.opts;
            var next = [];
            results.forEach(function (prefix) {
                opts.forEach(function (o) { next.push(prefix + o); });
            });
            results = next;
        });
        // Trim + drop empties so a stray comma can't create a blank folder.
        return results.map(function (r) { return r.trim(); }).filter(Boolean);
    }

    // Parse one (already brace-expanded) folder name into the nested tracking
    // levels it represents: split on "-" into tracking-number segments, then
    // split the FINAL segment once on "_" to separate the last tracking segment
    // from the "REV (STATUS)" leaf. So "CPO-0001_0 (IFU)" → ["CPO","0001","0 (IFU)"]
    // and "BMB-187023-PM-MOM-0001_A (IFR)" → ["BMB","187023","PM","MOM","0001","A (IFR)"].
    // A name with no "-"/"_" is a single level (e.g. adding a leaf "A (IFR)").
    function parseFolderLevels(name) {
        var s = String(name == null ? '' : name).trim();
        if (!s) return [];
        var segs = s.split('-');
        var last = segs.pop();
        var u = last.indexOf('_');
        if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); }
        else { segs.push(last); }
        return segs.map(function (x) { return x.trim(); }).filter(Boolean);
    }
    // Children array for a tracking node (or the roots for null), or null.
    function trackingChildren(parentId) {
        if (!parentId) return state.trackingTree;
        var info = infoFor(parentId);
        return (info && info.kind === 'tracking') ? info.node.children : null;
    }
    // Ensure a nested chain of tracking folders exists under parentId, reusing
    // an existing child when one already has that name (so sibling leaves share
    // ancestors). Returns the leaf node id.
    function addTrackingPath(parentId, segments) {
        var cur = parentId || null;
        (segments || []).forEach(function (seg) {
            var name = (seg || '').trim();
            if (!name) return;
            var kids = trackingChildren(cur) || [];
            var existing = kids.filter(function (n) { return n.name === name; })[0];
            cur = existing ? existing.id : addTrackingNode(cur, name);
        });
        return cur;
    }

    // A tracking node is a "complete" drop target when it's a leaf whose name
    // carries a valid "(STATUS)" — i.e. a file dropped there yields a full name
    // with no more levels needed. Used to decide whether a drop should prompt.
    function trackingNodeComplete(nodeId) {
        var info = infoFor(nodeId);
        if (!info || info.kind !== 'tracking') return false;
        if ((info.node.children || []).length) return false;
        var leaf = parseLeafLabel(info.node.name);
        return !!(leaf.status && zddc.isValidStatus(leaf.status));
    }
    // Human-readable "root / … / node" path for a tracking node (prompt context).
    function trackingPathLabel(nodeId) {
        var info = infoFor(nodeId);
        if (!info || info.kind !== 'tracking') return '';
        return trackingChain(info).join(' / ');
    }

    // ── filename-based export/import helpers ─────────────────────────────────
    // A flat, AI-friendly transmittal record for a placed file (export side).
    function transmittalRecord(binId) {
        var info = infoFor(binId);
        if (!info || info.kind !== 'transmittal') return null;
        var slot = info.parent ? infoFor(info.parent.id) : null;
        var party = slot && slot.parent ? infoFor(slot.parent.id) : null;
        var m = info.node.meta || {};
        return {
            party: party ? party.node.name : '',
            slot: slot ? slot.node.slot : '',
            date: m.date || '', type: m.type || 'TRN', seq: m.seq || '',
            status: m.status || '', title: m.title || '',
        };
    }
    // Find-or-create a party by name (import side — reuse so shared transmittals
    // don't duplicate the party).
    function findOrAddParty(name) {
        var existing = (state.transmittalTree || []).filter(function (p) { return p.name === name; })[0];
        return existing ? existing.id : addParty(name);
    }
    // Find-or-create a transmittal bin under party/slot matching meta (import).
    function findOrAddTransmittalBin(partyId, slot, meta) {
        var pinfo = infoFor(partyId);
        if (!pinfo || pinfo.kind !== 'party') return null;
        var wantName = transmittalFolderName(pinfo.node.name, meta);
        var slotNode = (pinfo.node.children || []).filter(function (s) { return s.slot === slot; })[0];
        if (slotNode) {
            var existing = (slotNode.children || []).filter(function (b) { return b.name === wantName; })[0];
            if (existing) return existing.id;
        }
        return addTransmittalBin(partyId, slot, meta);
    }

    // ── mode ─────────────────────────────────────────────────────────────────
    function setEnabled(on) { state.enabled = !!on; notify(); }
    function isEnabled() { return state.enabled; }

    window.app.modules.classify = {
        // mode
        setEnabled: setEnabled, isEnabled: isEnabled,
        // pub/sub
        on: on,
        // keys/title
        srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
        // assignments
        assignmentFor: assignmentFor, getAssignment: getAssignment,
        place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
        setTitleOverride: setTitleOverride,
        // trees
        addTrackingNode: addTrackingNode, addParty: addParty,
        addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
        expandFolderPattern: expandFolderPattern,
        parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath,
        trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
        transmittalRecord: transmittalRecord,
        findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
        getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
        getTransmittalTree: function () { return state.transmittalTree; },
        // derive + reverse
        deriveTarget: deriveTarget, filesInNode: filesInNode,
        fileState: fileState, stats: stats,
        // persistence
        serialize: serialize, load: load, reset: reset,
        getOutputName: function () { return state.outputName; },
        setOutputName: function (n) { state.outputName = n || null; notify(); },
    };
})();
