﻿// Fuzzy_Alias — 
// Author: J.Fourie, however, many parts of the code/logic were originally developed by Christian Arellano García in Toolbar Pallette.
// Directory Opus JScript

var SCRIPT_NAME    = "Fuzzy_Alias";
var SCRIPT_VERSION = "4.5";

// ---- Polyfill for legacy JScript (no native String.trim) ----
if (typeof String.prototype.trim !== "function") {
    String.prototype.trim = function(){ return this.replace(/^\s+|\s+$/g, ""); };
}

// ---------------- Config (visual only) ----------------
var MAX_SUGGESTIONS = 10;   // number of rows
var PATH_WIDTH      = 56;   // right-column path width (center-ellipsized)
var SHOW_TIERS      = true; // (no longer used for glyphs, but kept for compatibility)

// FAYT bar color
var FAYT_BACKCOL = "#17a34a";   // green background
var FAYT_TEXTCOL = "#ffffff";   // white text

// ---------------- State ----------------
var gAliases         = null;      // [{name:"Docs", path:"C:\\..."}, ...]
var gLastSuggestions = [];        // [{name, path, score, label}]
var ZWSP             = "\u200B";  // zero-width space
var gLastQuery       = "";        // last *typed* query only (not echoed labels)

// Usage-based contextual ranking (in-memory only)
var gUsage   = {};   // name -> { count:int, last:int }
var gUseTick = 0;    // monotonic tick for "recency"

// ---------------- Wiring ----------------
function OnInit(initData){
    initData.name           = SCRIPT_NAME;
    initData.version        = SCRIPT_VERSION;
    initData.copyright      = "J. Fourie";
    initData.group          = "FAYT Scripts";
    initData.desc           = "Fuzzy searching for aliases";
    initData.default_enable = true;
    initData.min_version    = "13.18.2"; // per-FAYT colors require 13.18.x
    Log("INIT ok");
}

function OnAddCommands(addCmdData){
    var cmd    = addCmdData.AddCommand();
    cmd.name   = SCRIPT_NAME;               // "Fuzzy_Alias"
    cmd.method = "On" + SCRIPT_NAME;        // OnFuzzy_Alias
    cmd.desc   = "FAYT alias search";
    cmd.label  = SCRIPT_NAME;

    var f = cmd.fayt;
    f.enable    = true;
    f.key       = "/";                      // default activation key
    f.label     = SCRIPT_NAME;
    f.realtime  = 120;                      // snappier updates
    f.wantempty = true;

    // FAYT colors
    f.backcolor = FAYT_BACKCOL;
    f.textcolor = FAYT_TEXTCOL;

    Log("OnAddCommands complete");
}

function OnFuzzy_Alias(data){
    try{
        if (data && data.fayt){
            return HandleFAYT(data);
        }else{
            Log("OnFuzzy_Alias: normal command run.");
        }
    }catch(e){
        Err("OnFuzzy_Alias: " + e.description);
    }
}

// ------------------------------ FAYT ---------------------------------

function HandleFAYT(d){
    var key     = S(get(d, "key"));
    var raw     = S(get(d, "cmdline", get(d, "value")));  // may include echoed label
    var typed   = extractRawQuery(raw);                   // what the user actually typed
    var tab     = get(d, "tab");
    var suggest = !!get(d, "suggest");
    var reason  = S(get(d, "reason"));
    if (!gAliases){
        gAliases = LoadAliases();
        Log("[ALIAS] loaded=" + gAliases.length + sampleAliases(gAliases));
    }
    // Rebuild ranked list every tick based on *typed* query
    gLastSuggestions = BuildSuggestions(typed, gAliases, MAX_SUGGESTIONS);
    // ------- Build the visible suggestions map (compact 2-column look) -------
    var m = DOpus.Create().Map();
    var ghost = typed ? (typed + " : " + ZWSP) : "";
    for (var i=0;i<gLastSuggestions.length;i++){
        var it = gLastSuggestions[i];
        // Strongest-match bars around #1
        var leftMark  = (i === 0 ? "▍ " : "  ");
        var rightMark = (i === 0 ? " ▍" : "");
        // Name: padded for all items EXCEPT the top one
        var padded = it.name;
        if (i !== 0) {
            while (padded.length < 22) padded += " ";
        }
        // Build left column (prefix + left bar + name + optional trailing bar)
        var left = ghost + leftMark + padded + rightMark;
        // Right column (rank + path)
        var rank  = "[" + pad2(i+1) + "]";
        var right = rank + "  " + shortenPath(it.path, PATH_WIDTH);
        // Ensure unique map keys
        while (m.Exists(left)) left += ZWSP;
        m.Set(left, right);
        if (i < 3)
            Log("[LABEL] #"+(i+1)+" score="+it.score+" -> " + visualize(left) +
                " | " + right);
    }
    // Replace list ONLY when the *typed* query changed (prevents appending)
    var pushed = "none";
    if (key !== "return" && typed !== gLastQuery){
        try{ PushSuggestions(d, tab, DOpus.Create().Map()); }catch(e){}
        pushed = PushSuggestions(d, tab, m);          // false flag keeps fuzzy order
        gLastQuery = typed;
        Log("[FAYT] typedChanged -> replace (typed='" + typed + "', raw='" + raw + "')");
    }
    if (m.count === 0){
        try{ PushSuggestions(d, tab, DOpus.Create().Map()); }catch(e){}
    }
    Log("[FAYT] pushed=" + (m.count||0) + " via=" + pushed +
        " reason=" + (reason || (suggest ? "suggest" : "none")) +
        " key='" + key + "' typed='" + typed + "' raw='" + raw + "'");
    // -------- Accept/Invoke handling --------
    // A mouse click on a suggestion does not set key==="return".
    // Opus recalls the handler with a "reason" describing the action.
    // Treat those like Enter so the item auto-opens on click.
    var r = (reason||"").toLowerCase();
    var clicked = (
        r === "select" || r === "click" || r === "listclick" ||
        r === "invoke" || r === "chosen" || r === "accept"
    );
    if (key === "return" || clicked){
        var target = ResolveSelection(typed, raw);
        if (target){
            RegisterUsage(target.name);
            OpenPath(tab, target.path, d);
            try{ if (tab && typeof tab.CloseFAYT === "unknown") tab.CloseFAYT(); }catch(e){}
        }else{
            Log("[FAYT] accept: no target for '" + typed + "'");
        }
        return;
    }
    return;
}


// Extract the user's typed query from an echoed label.
// Our labels look like: "<typed> : <ZWSP>…". Return "<typed>".
function extractRawQuery(s) {
    try{
        var sep = " : " + ZWSP;
        var idx = s.indexOf(sep);
        if (idx >= 0) return s.substring(0, idx);
        return s;
    }catch(e){
        return s;
    }
}

// --------------------------- Core logic / scoring ---------------------

function LoadAliases(){
    var out = [];
    try{
        try{
            var e = new Enumerator(DOpus.Aliases);
            for (; !e.atEnd(); e.moveNext()){
                var a = e.item();
                var name = tryProp(a, "name", tryProp(a, "def_value", null));
                var path = tryProp(a, "path", null);
                if (name && path){
                    out.push({name: String(name), path: String(path)});
                }
            }
        }catch(exEnum){
            Err("[LoadAliases API] " + exEnum.description + " — falling back to XML");
        }
        if (out.length) return out;

        var list = LoadAliasesFromXML();
        if (list && list.length) return list;
    }catch(e){
        Err("[LoadAliases] " + e.description);
    }
    return out;
}

function LoadAliasesFromXML(){
    var list = [];
    try{
        var cfg = DOpus.Aliases("dopusdata").path + "\\ConfigFiles\\folderaliases.oxc";
        Log("[LoadAliases XML] " + cfg);
        if (!DOpus.FSUtil().Exists(cfg)) return list;

        try{
            var dom = new ActiveXObject("Msxml2.DOMDocument");
            dom.async = false;
            dom.load(cfg);
            if (dom.parseError.errorCode === 0){
                var nodes = dom.selectNodes("//alias");
                for (var i=0;i<nodes.length;i++){
                    var n = nodes[i];
                    var name = n.getAttribute("name");
                    var path = n.getAttribute("path");
                    if (!name){
                        var nn = n.selectSingleNode("./name");
                        if (nn) name = nn.text;
                    }
                    if (!path){
                        var pn = n.selectSingleNode("./path");
                        if (pn) path = pn.text;
                    }
                    if (name && path) list.push({name:String(name), path:String(path)});
                }
                return list;
            }
        }catch(e){}

        var item = DOpus.FSUtil().GetItem(cfg);
        var f = item.Open();
        if (f.error === 0){
            var xml = DOpus.Create().StringTools().Decode(f.Read(), "utf-8");
            f.Close();
            if (xml){
                var re1 = /<alias[^>]*\bname="([^"]+)"[^>]*\bpath="([^"]+)"[^>]*\/?>/ig;
                var re2 = /<alias[^>]*>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<path>([^<]+)<\/path>[\s\S]*?<\/alias>/ig;
                var m;
                while ((m = re1.exec(xml)) !== null){ list.push({name:m[1], path:m[2]}); }
                while ((m = re2.exec(xml)) !== null){ list.push({name:m[1], path:m[2]}); }
            }
        }
    }catch(e){
        Err("[LoadAliasesFromXML] " + e.description);
    }
    return list;
}

function BuildSuggestions(q, aliases, limit){
    var out = [];
    var qs  = S(q).toLowerCase();
    if (!aliases || !aliases.length) return out;

    for (var i=0;i<aliases.length;i++){
        var a  = aliases[i];
        var n  = String(a.name || "");
        var p  = String(a.path || "");
        var nl = n.toLowerCase();
        var pl = p.toLowerCase();

        var score;
        if (!qs){
            score = 1;
        }else{
            // Core fuzzy engine (Steps 1–3)
            var sName = fuzzyScore(qs, nl);
            var sBase = fuzzyScore(qs, baseName(pl));
            var sPath = fuzzyScore(qs, pl);
            score = Math.round(0.7*sName + 0.2*sBase + 0.1*sPath);

            // STEP 4: usage-based contextual ranking (freq + recency)
            var u = gUsage[n];
            if (u){
                // frequency: each use adds 25 points, cap at +300
                var freqBoost = u.count * 25;
                if (freqBoost > 300) freqBoost = 300;

                // recency: aliases used very recently get extra; decay over time
                var age = gUseTick - u.last;
                if (age < 0) age = 0;
                var recencyBoost = 200 - (age * 10);   // after ~20 ticks, recency fades
                if (recencyBoost < 0) recencyBoost = 0;

                score += freqBoost + recencyBoost;
            }
        }

        if (score > 0){
            out.push({
                name: n,
                path: p,
                score: score,
                label: "/" + n + " — " + p
            });
        }
    }

    out.sort(function(a,b){ return b.score - a.score || a.name.localeCompare(b.name); });
    if (out.length > limit) out.length = limit;
    return out;
}

// Resolve using echoed selection; fallback to best
function ResolveSelection(typed, rawCmdline){
    try{
        var s = stripZW(String(rawCmdline||""));
        var ghost = typed ? (typed + " : ") : null;
        if (ghost && s.indexOf(ghost) === 0){
            s = s.substring(ghost.length); // now "▍ Name ▍" etc.
        }
        // Remove right-column parts & indicators
        s = s.replace(/\s*\[\d{2}\].*$/, "");  // strip right-column parts
        s = s.replace(/[▍•►●┊|]/g, "");       // strip left/right indicators
        s = s.replace(/^\s+|\s+$/g, "");       // trim edges
        s = s.replace(/\s+/g, " ");            // collapse padded spacing
        
        // Match by actual alias name
        var cleanSel = s.toLowerCase();
        
        for (var i=0;i<gLastSuggestions.length;i++){
            var it = gLastSuggestions[i];
            if (cleanSel === it.name.toLowerCase()) return it;
        }

        Log("[FAYT] resolve: selection label not found -> fallback");
    }catch(e){
        Err("[ResolveSelection] " + e.description);
    }
    // Secondary heuristics
    for (var j=0;j<gLastSuggestions.length;j++){
        var it2 = gLastSuggestions[j];
        if (it2.label === typed || it2.name === typed || ("/"+it2.name) === typed) return it2;
    }
    return gLastSuggestions.length ? gLastSuggestions[0] : null;
}

function OpenPath(tab, path, faytData){
    try{
        var cmd = DOpus.Create().Command();
        if (tab) cmd.SetSourceTab(tab);
        var qual = QualifiersFromFAYT(faytData); // "shift,ctrl"
        var newtab = (qual.indexOf("ctrl") >= 0) ? " NEWTAB=findexisting" : "";
        cmd.RunCommand('Go "' + path + '"' + newtab);
        Log("[OPEN] " + path + (newtab ? " (NEWTAB)" : ""));
    }catch(e){
        Err("[OPEN ERROR] " + e.description);
    }
}

// Prefer the FAYT-context API; fall back to tab API (keep fuzzy order)
function PushSuggestions(d, tab, map){
    try{
        if (typeof d.UpdateFAYTSuggestions === "unknown"){
            d.UpdateFAYTSuggestions(map, false); // false = keep our order
            return "data";
        }
    }catch(e){}
    try{
        if (tab && typeof tab.UpdateFAYTSuggestions === "unknown"){
            tab.UpdateFAYTSuggestions(map, false);
            return "tab";
        }
    }catch(e){}
    return "none";
}

// ----------------------------- usage tracking ------------------------

function RegisterUsage(name){
    try{
        name = String(name||"");
        if (!name) return;
        gUseTick++;
        var u = gUsage[name];
        if (!u){
            gUsage[name] = { count: 1, last: gUseTick };
        }else{
            u.count++;
            u.last = gUseTick;
        }
        Log("[USAGE] name=" + name + " count=" + gUsage[name].count + " last=" + gUsage[name].last);
    }catch(e){
        Err("[USAGE ERROR] " + e.description);
    }
}

// ----------------------------- usage tracking ------------------------

function RegisterUsage(name){
    try{
        name = String(name||"");
        if (!name) return;
        gUseTick++;
        var u = gUsage[name];
        if (!u){
            gUsage[name] = { count: 1, last: gUseTick };
        }else{
            u.count++;
            u.last = gUseTick;
        }
        Log("[USAGE] name=" + name + " count=" + gUsage[name].count + " last=" + gUsage[name].last);
    }catch(e){
        Err("[USAGE ERROR] " + e.description);
    }
}

// ----------------------------- utils / scoring helpers ----------------

function get(o, k, alt){ try{ var v = o[k]; return (v===undefined||v===null) ? alt : v; }catch(e){ return alt; } }
function S(x){ try{ return (x===null||x===undefined) ? "" : String(x); }catch(e){ return ""; } }
function tryProp(o, p, alt){ try{ var v = o[p]; return (v===undefined||v===null) ? alt : v; }catch(e){ return alt; } }

function QualifiersFromFAYT(d){ try{ var q = d.qualifiers; return q ? String(q).toLowerCase() : ""; }catch(e){ return ""; } }

function sampleAliases(arr){
    if (!arr || !arr.length) return "";
    var take = Math.min(arr.length, 3);
    var parts = [];
    for (var i=0;i<take;i++) parts.push(arr[i].name + "->" + arr[i].path);
    return " [" + parts.join("; ") + (arr.length>take ? "; …" : "") + "]";
}

function baseName(p){
    try{
        p = String(p||"");
        p = p.replace(/[\\/]+$/,"");
        var m = p.match(/[^\\/]+$/);
        return m ? m[0] : p;
    }catch(e){ return ""; }
}

// =====================================================================
// Modern Fuzzy Engine (Steps 1–3)
// =====================================================================

function fuzzyScore(q, h) {
    if (!q || !h) return 0;

    // Preserve raw case for boundary detection
    var qLower = String(q).toLowerCase();
    var hRaw   = String(h);
    var hLower = hRaw.toLowerCase();

    // --- Exact match always wins
    if (hLower === qLower) return 1000000;

    var score = 0;

    // 1. Strong prefix match
    if (hLower.indexOf(qLower) === 0) score += 600;

    // 2. Acronym (hybrid boundaries)
    var acro = buildHybridAcronym(hRaw);
    var acroHit = subseqScore(qLower, acro);
    score += acroHit * 500;

    // 3. Boundary-aligned subsequence scoring
    score += boundaryAlignedScore(qLower, hLower, hRaw);

    // 4. Pure subsequence
    score += subseqScore(qLower, hLower) * 400;

    // 5. Longest Common Substring
    var lcs = longestCommonSubstring(qLower, hLower);
    score += (lcs / qLower.length) * 300;

    // 6. Levenshtein similarity
    var lev = levenshtein(qLower, hLower);
    var sim = 1 - (lev / Math.max(qLower.length, hLower.length));
    score += sim * 250;

    // 7. Bigram scoring
    var shared = sharedBigrams(qLower, hLower);
    score += shared * 160;

    // 8. Mid-word penalty
    var idx = hLower.indexOf(qLower);
    if (idx > 0) score -= idx * 5;

    // 9. Short-name protection
    if (hLower.length < 5) score *= 0.5;
    else if (hLower.length < 8) score *= 0.8;

    return Math.round(score);
}
// ---- Bigram helpers ----

function bigrams(s) {
    var out = [];
    if (s.length < 2) return out;
    for (var i = 0; i < s.length - 1; i++) {
        out.push(s.substring(i, i+2));
    }
    return out;
}

function sharedBigrams(q, h) {
    var bq = bigrams(q);
    var bh = bigrams(h);
    if (!bq.length || !bh.length) return 0;

    var hits = 0;
    for (var i = 0; i < bq.length; i++) {
        for (var j = 0; j < bh.length; j++) {
            if (bq[i] === bh[j]) hits++;
        }
    }
    return hits;
}

// ---- Hybrid boundaries + acronym ----

function isBoundary(h, i) {
    if (i === 0) return true;

    var prev = h.charAt(i-1);
    var curr = h.charAt(i);

    // separators
    if (" _-./\\".indexOf(prev) >= 0) return true;

    // lower → upper
    if (prev >= "a" && prev <= "z" && curr >= "A" && curr <= "Z")
        return true;

    // digit → letter
    if (prev >= "0" && prev <= "9" &&
        ((curr >= "a" && curr <= "z") || (curr >= "A" && curr <= "Z")))
        return true;

    // letter → digit
    if (((prev >= "a" && prev <= "z") || (prev >= "A" && prev <= "Z")) &&
        (curr >= "0" && curr <= "9"))
        return true;

    return false;
}

function buildHybridAcronym(h) {
    var out = "";
    for (var i = 0; i < h.length; i++) {
        if (isBoundary(h, i)) {
            var c = h.charAt(i);
            if (/[a-z0-9]/i.test(c))
                out += c.toLowerCase();   // case-insensitive acronym
        }
    }
    return out;
}

// --- MINIMAL FIX: accept raw string for boundary detection, lowercase for matching
function boundaryAlignedScore(qLower, hLower, hRaw) {
    var bonus = 0;
    var qi = 0;

    for (var i = 0; i < hLower.length && qi < qLower.length; i++) {
        if (hLower.charAt(i) === qLower.charAt(qi)) {
            if (isBoundary(hRaw, i)) {
                bonus += 300;             // strong boundary bonus (Mode B)
                qi++;
            }
        }
    }

    // all chars aligned to boundaries → extra boost
    if (qi === qLower.length) bonus += 150;

    return bonus;
}

// ---- subsequence engine (Step 1) ----

function subseqScore(q, h) {
    var score = 0, qi = 0;

    for (var hi = 0; hi < h.length && qi < q.length; hi++) {
        if (h.charAt(hi) === q.charAt(qi)) {
            score += 1;

            // contiguous match bonus
            if (qi > 0 && h.charAt(hi-1) === q.charAt(qi-1))
                score += 1.5;

            qi++;
        }
    }

    return (qi === q.length) ? score : 0;
}

// ---- LCS ----

function longestCommonSubstring(a, b) {
    var m=a.length,n=b.length,dp=[],maxLen=0;
    for (var i=0;i<=m;i++){ dp[i]=[];
        for (var j=0;j<=n;j++){
            if(i===0||j===0) dp[i][j]=0;
            else if(a.charAt(i-1)===b.charAt(j-1)){
                dp[i][j]=dp[i-1][j-1]+1;
                if(dp[i][j]>maxLen) maxLen=dp[i][j];
            } else dp[i][j]=0;
        }
    }
    return maxLen;
}

// ---- Levenshtein ----

function levenshtein(a,b){
    var m=[],i,j;
    if(a.length===0) return b.length;
    if(b.length===0) return a.length;
    for(i=0;i<=b.length;i++) m[i]=[i];
    for(j=1;j<=a.length;j++) m[0][j]=j;
    for(i=1;i<=b.length;i++){
        for(j=1;j<=a.length;j++){
            m[i][j]=Math.min(
                m[i-1][j]+1,
                m[i][j-1]+1,
                m[i-1][j-1]+(b.charAt(i-1)===a.charAt(j-1)?0:1)
            );
        }
    }
    return m[b.length][a.length];
}

// ---- UI helpers ----
function pad2(n){ n=String(n); return n.length<2 ? "0"+n : n; }

function shortenPath(p, max){
    p = String(p||"");
    if (p.length <= max) return p;
    // center-ellipsis: keep drive and tail
    var drive = /^[A-Za-z]:\\/.test(p) ? p.slice(0,3) : "";
    var tail  = p.replace(/^([A-Za-z]:\\)?/, "");
    var keep  = Math.max(8, Math.floor((max - drive.length - 1) / 2));
    return drive + tail.slice(0, keep) + "…\\" + tail.slice(-keep);
}

function stripZW(s){ try{ return String(s).replace(/[\u200B\u200C\u200D\u2060]/g,""); }catch(e){ return s; } }
function visualize(s){ return String(s).replace(/ /g,"␠"); }

// ---- logging ----
function Log(s){ DOpus.Output(SCRIPT_NAME + ": [INFO] " + s); }
function Err(s){ DOpus.Output(SCRIPT_NAME + ": [ERROR] " + s, true); }
