/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*-
 * vim: set ts=8 sw=4 et tw=78:
 *
 * jorendb - A toy command-line debugger for shell-js programs.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */

/*
 * jorendb is a simple command-line debugger for shell-js programs. It is
 * intended as a demo of the Debugger object (as there are no shell js programs
 * to speak of).
 *
 * To run it: $JS -d path/to/this/file/jorendb.js
 * To run some JS code under it, try:
 *    (jorendb) print load("my-script-to-debug.js")
 * Execution will stop at debugger statements and you'll get a jorendb prompt.
 */

// Debugger state.
var focusedFrame = null;
var topFrame = null;
var debuggeeValues = {};
var nextDebuggeeValueIndex = 1;
var lastExc = null;
var todo = [];
var activeTask;
var options = { 'pretty': true,
                'emacs': !!os.getenv('INSIDE_EMACS') };
var rerun = true;

// Cleanup functions to run when we next re-enter the repl.
var replCleanups = [];

// Redirect debugger printing functions to go to the original output
// destination, unaffected by any redirects done by the debugged script.
var initialOut = os.file.redirect();
var initialErr = os.file.redirectErr();

function wrap(global, name) {
    var orig = global[name];
    global[name] = function(...args) {

        var oldOut = os.file.redirect(initialOut);
        var oldErr = os.file.redirectErr(initialErr);
        try {
            return orig.apply(global, args);
        } finally {
            os.file.redirect(oldOut);
            os.file.redirectErr(oldErr);
        }
    };
}
wrap(this, 'print');
wrap(this, 'printErr');
wrap(this, 'putstr');

// Convert a debuggee value v to a string.
function dvToString(v) {
    return (typeof v !== 'object' || v === null) ? uneval(v) : "[object " + v.class + "]";
}

function summaryObject(dv) {
    var obj = {};
    for (var name of dv.getOwnPropertyNames()) {
        var v = dv.getOwnPropertyDescriptor(name).value;
        if (v instanceof Debugger.Object) {
            v = "(...)";
        }
        obj[name] = v;
    }
    return obj;
}

function debuggeeValueToString(dv, style) {
    var dvrepr = dvToString(dv);
    if (!style.pretty || (typeof dv !== 'object'))
        return [dvrepr, undefined];

    if (dv.class == "Error") {
        let errval = debuggeeGlobalWrapper.executeInGlobalWithBindings("$" + i + ".toString()", debuggeeValues);
        return [dvrepr, errval.return];
    }

    if (style.brief)
        return [dvrepr, JSON.stringify(summaryObject(dv), null, 4)];

    let str = debuggeeGlobalWrapper.executeInGlobalWithBindings("JSON.stringify(v, null, 4)", {v: dv});
    if ('throw' in str) {
        if (style.noerror)
            return [dvrepr, undefined];

        let substyle = {};
        Object.assign(substyle, style);
        substyle.noerror = true;
        return [dvrepr, debuggeeValueToString(str.throw, substyle)];
    }

    return [dvrepr, str.return];
}

// Problem! Used to do [object Object] followed by details. Now just details?

function showDebuggeeValue(dv, style={pretty: options.pretty}) {
    var i = nextDebuggeeValueIndex++;
    debuggeeValues["$" + i] = dv;
    let [brief, full] = debuggeeValueToString(dv, style);
    print("$" + i + " = " + brief);
    if (full !== undefined)
        print(full);
}

Object.defineProperty(Debugger.Frame.prototype, "num", {
    configurable: true,
    enumerable: false,
    get: function () {
            var i = 0;
            for (var f = topFrame; f && f !== this; f = f.older)
                i++;
            return f === null ? undefined : i;
        }
    });

Debugger.Frame.prototype.frameDescription = function frameDescription() {
    if (this.type == "call")
        return ((this.callee.name || '<anonymous>') +
                "(" + this.arguments.map(dvToString).join(", ") + ")");
    else
        return this.type + " code";
}

Debugger.Frame.prototype.positionDescription = function positionDescription() {
    if (this.script) {
        var line = this.script.getOffsetLocation(this.offset).lineNumber;
        if (this.script.url)
            return this.script.url + ":" + line;
        return "line " + line;
    }
    return null;
}

Debugger.Frame.prototype.location = function () {
    if (this.script) {
        var { lineNumber, columnNumber, isEntryPoint } = this.script.getOffsetLocation(this.offset);
        if (this.script.url)
            return this.script.url + ":" + lineNumber;
        return null;
    }
    return null;
}

Debugger.Frame.prototype.fullDescription = function fullDescription() {
    var fr = this.frameDescription();
    var pos = this.positionDescription();
    if (pos)
        return fr + ", " + pos;
    return fr;
}

Object.defineProperty(Debugger.Frame.prototype, "line", {
        configurable: true,
        enumerable: false,
        get: function() {
            if (this.script)
                return this.script.getOffsetLocation(this.offset).lineNumber;
            else
                return null;
        }
    });

function callDescription(f) {
    return ((f.callee.name || '<anonymous>') +
            "(" + f.arguments.map(dvToString).join(", ") + ")");
}

function showFrame(f, n) {
    if (f === undefined || f === null) {
        f = focusedFrame;
        if (f === null) {
            print("No stack.");
            return;
        }
    }
    if (n === undefined) {
        n = f.num;
        if (n === undefined)
            throw new Error("Internal error: frame not on stack");
    }

    print('#' + n + " " + f.fullDescription());
}

function saveExcursion(fn) {
    var tf = topFrame, ff = focusedFrame;
    try {
        return fn();
    } finally {
        topFrame = tf;
        focusedFrame = ff;
    }
}

function parseArgs(str) {
    return str.split(" ");
}

function describedRv(r, desc) {
    desc = "[" + desc + "] ";
    if (r === undefined) {
        print(desc + "Returning undefined");
    } else if (r === null) {
        print(desc + "Returning null");
    } else if (r.length === undefined) {
        print(desc + "Returning object " + JSON.stringify(r));
    } else {
        print(desc + "Returning length-" + r.length + " list");
        if (r.length > 0) {
            print("  " + r[0]);
        }
    }
    return r;
}

// Rerun the program (reloading it from the file)
function runCommand(args) {
    print("Restarting program");
    if (args)
        activeTask.scriptArgs = parseArgs(args);
    rerun = true;
    for (var f = topFrame; f; f = f.older) {
        if (f.older) {
            f.onPop = () => null;
        } else {
            f.onPop = () => ({ 'return': 0 });
        }
    }
    //return describedRv([{ 'return': 0 }], "runCommand");
    return null;
}

// Evaluate an expression in the Debugger global
function evalCommand(expr) {
    eval(expr);
}

function quitCommand() {
    dbg.enabled = false;
    quit(0);
}

function backtraceCommand() {
    if (topFrame === null)
        print("No stack.");
    for (var i = 0, f = topFrame; f; i++, f = f.older)
        showFrame(f, i);
}

function setCommand(rest) {
    var space = rest.indexOf(' ');
    if (space == -1) {
        print("Invalid set <option> <value> command");
    } else {
        var name = rest.substr(0, space);
        var value = rest.substr(space + 1);

        if (name == 'args') {
            activeTask.scriptArgs = parseArgs(value);
        } else {
            var yes = ["1", "yes", "true", "on"];
            var no = ["0", "no", "false", "off"];

            if (yes.includes(value))
                options[name] = true;
            else if (no.includes(value))
                options[name] = false;
            else
                options[name] = value;
        }
    }
}

function split_print_options(s, style) {
    var m = /^\/(\w+)/.exec(s);
    if (!m)
        return [ s, style ];
    if (m[1].includes("p"))
        style.pretty = true;
    if (m[1].includes("b"))
        style.brief = true;
    return [ s.substr(m[0].length).trimLeft(), style ];
}

function doPrint(expr, style) {
    // This is the real deal.
    var cv = saveExcursion(
        () => focusedFrame == null
              ? debuggeeGlobalWrapper.executeInGlobalWithBindings(expr, debuggeeValues)
              : focusedFrame.evalWithBindings(expr, debuggeeValues));
    if (cv === null) {
        if (!dbg.enabled)
            return [cv];
        print("Debuggee died.");
    } else if ('return' in cv) {
        if (!dbg.enabled)
            return [undefined];
        showDebuggeeValue(cv.return, style);
    } else {
        if (!dbg.enabled)
            return [cv];
        print("Exception caught. (To rethrow it, type 'throw'.)");
        lastExc = cv.throw;
        showDebuggeeValue(lastExc, style);
    }
}

function printCommand(rest) {
    var [expr, style] = split_print_options(rest, {pretty: options.pretty});
    return doPrint(expr, style);
}

function keysCommand(rest) { return doPrint("Object.keys(" + rest + ")"); }

function detachCommand() {
    dbg.enabled = false;
    return [undefined];
}

function continueCommand(rest) {
    if (focusedFrame === null) {
        print("No stack.");
        return;
    }

    var match = rest.match(/^(\d+)$/);
    if (match) {
        return doStepOrNext({upto:true, stopLine:match[1]});
    }

    return [undefined];
}

function throwCommand(rest) {
    var v;
    if (focusedFrame !== topFrame) {
        print("To throw, you must select the newest frame (use 'frame 0').");
        return;
    } else if (focusedFrame === null) {
        print("No stack.");
        return;
    } else if (rest === '') {
        return [{throw: lastExc}];
    } else {
        var cv = saveExcursion(function () { return focusedFrame.eval(rest); });
        if (cv === null) {
            if (!dbg.enabled)
                return [cv];
            print("Debuggee died while determining what to throw. Stopped.");
        } else if ('return' in cv) {
            return [{throw: cv.return}];
        } else {
            if (!dbg.enabled)
                return [cv];
            print("Exception determining what to throw. Stopped.");
            showDebuggeeValue(cv.throw);
        }
        return;
    }
}

function frameCommand(rest) {
    var n, f;
    if (rest.match(/[0-9]+/)) {
        n = +rest;
        f = topFrame;
        if (f === null) {
            print("No stack.");
            return;
        }
        for (var i = 0; i < n && f; i++) {
            if (!f.older) {
                print("There is no frame " + rest + ".");
                return;
            }
            f.older.younger = f;
            f = f.older;
        }
        focusedFrame = f;
        updateLocation(focusedFrame);
        showFrame(f, n);
    } else if (rest === '') {
        if (topFrame === null) {
            print("No stack.");
        } else {
            updateLocation(focusedFrame);
            showFrame();
        }
    } else {
        print("do what now?");
    }
}

function upCommand() {
    if (focusedFrame === null)
        print("No stack.");
    else if (focusedFrame.older === null)
        print("Initial frame selected; you cannot go up.");
    else {
        focusedFrame.older.younger = focusedFrame;
        focusedFrame = focusedFrame.older;
        updateLocation(focusedFrame);
        showFrame();
    }
}

function downCommand() {
    if (focusedFrame === null)
        print("No stack.");
    else if (!focusedFrame.younger)
        print("Youngest frame selected; you cannot go down.");
    else {
        focusedFrame = focusedFrame.younger;
        updateLocation(focusedFrame);
        showFrame();
    }
}

function forcereturnCommand(rest) {
    var v;
    var f = focusedFrame;
    if (f !== topFrame) {
        print("To forcereturn, you must select the newest frame (use 'frame 0').");
    } else if (f === null) {
        print("Nothing on the stack.");
    } else if (rest === '') {
        return [{return: undefined}];
    } else {
        var cv = saveExcursion(function () { return f.eval(rest); });
        if (cv === null) {
            if (!dbg.enabled)
                return [cv];
            print("Debuggee died while determining what to forcereturn. Stopped.");
        } else if ('return' in cv) {
            return [{return: cv.return}];
        } else {
            if (!dbg.enabled)
                return [cv];
            print("Error determining what to forcereturn. Stopped.");
            showDebuggeeValue(cv.throw);
        }
    }
}

function printPop(f, c) {
    var fdesc = f.fullDescription();
    if (c.return) {
        print("frame returning (still selected): " + fdesc);
        showDebuggeeValue(c.return, {brief: true});
    } else if (c.throw) {
        print("frame threw exception: " + fdesc);
        showDebuggeeValue(c.throw);
        print("(To rethrow it, type 'throw'.)");
        lastExc = c.throw;
    } else {
        print("frame was terminated: " + fdesc);
    }
}

// Set |prop| on |obj| to |value|, but then restore its current value
// when we next enter the repl.
function setUntilRepl(obj, prop, value) {
    var saved = obj[prop];
    obj[prop] = value;
    replCleanups.push(function () { obj[prop] = saved; });
}

function updateLocation(frame) {
    if (options.emacs) {
        var loc = frame.location();
        if (loc)
            print("\032\032" + loc + ":1");
    }
}

function doStepOrNext(kind) {
    var startFrame = topFrame;
    var startLine = startFrame.line;
    // print("stepping in:   " + startFrame.fullDescription());
    // print("starting line: " + uneval(startLine));

    function stepPopped(completion) {
        // Note that we're popping this frame; we need to watch for
        // subsequent step events on its caller.
        this.reportedPop = true;
        printPop(this, completion);
        topFrame = focusedFrame = this;
        if (kind.finish) {
            // We want to continue, but this frame is going to be invalid as
            // soon as this function returns, which will make the replCleanups
            // assert when it tries to access the dead frame's 'onPop'
            // property. So clear it out now while the frame is still valid,
            // and trade it for an 'onStep' callback on the frame we're popping to.
            preReplCleanups();
            setUntilRepl(this.older, 'onStep', stepStepped);
            return undefined;
        }
        updateLocation(this);
        return repl();
    }

    function stepEntered(newFrame) {
        print("entered frame: " + newFrame.fullDescription());
        updateLocation(newFrame);
        topFrame = focusedFrame = newFrame;
        return repl();
    }

    function stepStepped() {
        // print("stepStepped: " + this.fullDescription());
        updateLocation(this);
        var stop = false;

        if (kind.finish) {
            // 'finish' set a one-time onStep for stopping at the frame it
            // wants to return to
            stop = true;
        } else if (kind.upto) {
            // running until a given line is reached
            if (this.line == kind.stopLine)
                stop = true;
        } else {
            // regular step; stop whenever the line number changes
            if ((this.line != startLine) || (this != startFrame))
                stop = true;
        }

        if (stop) {
            topFrame = focusedFrame = this;
            if (focusedFrame != startFrame)
                print(focusedFrame.fullDescription());
            return repl();
        }

        // Otherwise, let execution continue.
        return undefined;
    }

    if (kind.step)
        setUntilRepl(dbg, 'onEnterFrame', stepEntered);

    // If we're stepping after an onPop, watch for steps and pops in the
    // next-older frame; this one is done.
    var stepFrame = startFrame.reportedPop ? startFrame.older : startFrame;
    if (!stepFrame || !stepFrame.script)
        stepFrame = null;
    if (stepFrame) {
        if (!kind.finish)
            setUntilRepl(stepFrame, 'onStep', stepStepped);
        setUntilRepl(stepFrame, 'onPop',  stepPopped);
    }

    // Let the program continue!
    return [undefined];
}

function stepCommand() { return doStepOrNext({step:true}); }
function nextCommand() { return doStepOrNext({next:true}); }
function finishCommand() { return doStepOrNext({finish:true}); }

// FIXME: DOES NOT WORK YET
function breakpointCommand(where) {
    print("Sorry, breakpoints don't work yet.");
    var script = focusedFrame.script;
    var offsets = script.getLineOffsets(Number(where));
    if (offsets.length == 0) {
        print("Unable to break at line " + where);
        return;
    }
    for (var offset of offsets) {
        script.setBreakpoint(offset, { hit: handleBreakpoint });
    }
    print("Set breakpoint in " + script.url + ":" + script.startLine + " at line " + where + ", " + offsets.length);
}

// Build the table of commands.
var commands = {};
var commandArray = [
    backtraceCommand, "bt", "where",
    breakpointCommand, "b", "break",
    continueCommand, "c",
    detachCommand,
    downCommand, "d",
    evalCommand, "!",
    forcereturnCommand,
    frameCommand, "f",
    finishCommand, "fin",
    nextCommand, "n",
    printCommand, "p",
    keysCommand, "k",
    quitCommand, "q",
    runCommand, "run",
    stepCommand, "s",
    setCommand,
    throwCommand, "t",
    upCommand, "u",
    helpCommand, "h",
];
var currentCmd = null;
for (var i = 0; i < commandArray.length; i++) {
    var cmd = commandArray[i];
    if (typeof cmd === "string")
        commands[cmd] = currentCmd;
    else
        currentCmd = commands[cmd.name.replace(/Command$/, '')] = cmd;
}

function helpCommand(rest) {
    print("Available commands:");
    var printcmd = function(group) {
        print("  " + group.join(", "));
    }

    var group = [];
    for (var cmd of commandArray) {
        if (typeof cmd === "string") {
            group.push(cmd);
        } else {
            if (group.length) printcmd(group);
            group = [ cmd.name.replace(/Command$/, '') ];
        }
    }
    printcmd(group);
}

// Break cmd into two parts: its first word and everything else. If it begins
// with punctuation, treat that as a separate word. The first word is
// terminated with whitespace or the '/' character. So:
//
//   print x         => ['print', 'x']
//   print           => ['print', '']
//   !print x        => ['!', 'print x']
//   ?!wtf!?         => ['?', '!wtf!?']
//   print/b x       => ['print', '/b x']
//
function breakcmd(cmd) {
    cmd = cmd.trimLeft();
    if ("!@#$%^&*_+=/?.,<>:;'\"".includes(cmd.substr(0, 1)))
        return [cmd.substr(0, 1), cmd.substr(1).trimLeft()];
    var m = /\s+|(?=\/)/.exec(cmd);
    if (m === null)
        return [cmd, ''];
    return [cmd.slice(0, m.index), cmd.slice(m.index + m[0].length)];
}

function runcmd(cmd) {
    var pieces = breakcmd(cmd);
    if (pieces[0] === "")
        return undefined;

    var first = pieces[0], rest = pieces[1];
    if (!commands.hasOwnProperty(first)) {
        print("unrecognized command '" + first + "'");
        return undefined;
    }

    var cmd = commands[first];
    if (cmd.length === 0 && rest !== '') {
        print("this command cannot take an argument");
        return undefined;
    }

    return cmd(rest);
}

function preReplCleanups() {
    while (replCleanups.length > 0)
        replCleanups.pop()();
}

var prevcmd = undefined;
function repl() {
    preReplCleanups();

    var cmd;
    for (;;) {
        putstr("\n" + prompt);
        cmd = readline();
        if (cmd === null)
            return null;
        else if (cmd === "")
            cmd = prevcmd;

        try {
            prevcmd = cmd;
            var result = runcmd(cmd);
            if (result === undefined)
                ; // do nothing, return to prompt
            else if (Array.isArray(result))
                return result[0];
            else if (result === null)
                return null;
            else
                throw new Error("Internal error: result of runcmd wasn't array or undefined: " + result);
        } catch (exc) {
            print("*** Internal error: exception in the debugger code.");
            print("    " + exc);
            print(exc.stack);
        }
    }
}

var dbg = new Debugger();
dbg.onDebuggerStatement = function (frame) {
    return saveExcursion(function () {
            topFrame = focusedFrame = frame;
            print("'debugger' statement hit.");
            showFrame();
            updateLocation(focusedFrame);
            backtrace();
            return describedRv(repl(), "debugger.saveExc");
        });
};
dbg.onThrow = function (frame, exc) {
    return saveExcursion(function () {
            topFrame = focusedFrame = frame;
            print("Unwinding due to exception. (Type 'c' to continue unwinding.)");
            showFrame();
            print("Exception value is:");
            showDebuggeeValue(exc);
            return repl();
        });
};

function handleBreakpoint (frame) {
    print("Breakpoint hit!");
    return saveExcursion(() => {
        topFrame = focusedFrame = frame;
        print("breakpoint hit.");
        showFrame();
        updateLocation(focusedFrame);
        return repl();
    });
};

// The depth of jorendb nesting.
var jorendbDepth;
if (typeof jorendbDepth == 'undefined') jorendbDepth = 0;

var debuggeeGlobal = newGlobal("new-compartment");
debuggeeGlobal.jorendbDepth = jorendbDepth + 1;
var debuggeeGlobalWrapper = dbg.addDebuggee(debuggeeGlobal);

print("jorendb version -0.0");
prompt = '(' + Array(jorendbDepth+1).join('meta-') + 'jorendb) ';

var args = scriptArgs.slice(0);
print("INITIAL ARGS: " + args);

// Find the script to run and its arguments. The script may have been given as
// a plain script name, in which case all remaining arguments belong to the
// script. Or there may have been any number of arguments to the JS shell,
// followed by -f scriptName, followed by additional arguments to the JS shell,
// followed by the script arguments. There may be multiple -e or -f options in
// the JS shell arguments, and we want to treat each one as a debuggable
// script.
//
// The difficulty is that the JS shell has a mixture of
//
//   --boolean
//
// and
//
//   --value VAL
//
// parameters, and there's no way to know whether --option takes an argument or
// not. We will assume that VAL will never end in .js, or rather that the first
// argument that does not start with "-" but does end in ".js" is the name of
// the script.
//
// If you need to pass other options and not have them given to the script,
// pass them before the -f jorendb.js argument. Thus, the safe ways to pass
// arguments are:
//
//   js [JS shell options] -f jorendb.js (-e SCRIPT | -f FILE)+ -- [script args]
//   js [JS shell options] -f jorendb.js (-e SCRIPT | -f FILE)* script.js [script args]
//
// Additionally, if you want to run a script that is *NOT* debugged, put it in
// as part of the leading [JS shell options].


// Compute actualScriptArgs by finding the script to be run and grabbing every
// non-script argument. The script may be given by -f scriptname or just plain
// scriptname. In the latter case, it will be in the global variable
// 'scriptPath' (and NOT in scriptArgs.)
var actualScriptArgs = [];
var scriptSeen;

if (scriptPath !== undefined) {
    todo.push({
        'action': 'load',
        'script': scriptPath,
    });
    scriptSeen = true;
}

while(args.length > 0) {
    var arg = args.shift();
    print("arg: " + arg);
    if (arg == '-e') {
        print("  eval");
        todo.push({
            'action': 'eval',
            'code': args.shift()
        });
    } else if (arg == '-f') {
        var script = args.shift();
        print("  load -f " + script);
        scriptSeen = true;
        todo.push({
            'action': 'load',
            'script': script,
        });
    } else if (arg.indexOf("-") == 0) {
        if (arg == '--') {
            print("  pass remaining args to script");
            actualScriptArgs.push(...args);
            break;
        } else if ((args.length > 0) && (args[0].indexOf(".js") + 3 == args[0].length)) {
            // Ends with .js, assume we are looking at --boolean script.js
            print("  load script.js after --boolean");
            todo.push({
                'action': 'load',
                'script': args.shift(),
            });
            scriptSeen = true;
        } else {
            // Does not end with .js, assume we are looking at JS shell arg
            // --value VAL
            print("  ignore");
            args.shift();
        }
    } else {
        if (!scriptSeen) {
            print("  load general");
            actualScriptArgs.push(...args);
            todo.push({
                'action': 'load',
                'script': arg,
            });
            break;
        } else {
            print("  arg " + arg);
            actualScriptArgs.push(arg);
        }
    }
}
print("jorendb: scriptPath = " + scriptPath);
print("jorendb: scriptArgs = " + scriptArgs);
print("jorendb: actualScriptArgs = " + actualScriptArgs);

for (var task of todo) {
    task['scriptArgs'] = actualScriptArgs;
}

// If nothing to run, just drop into a repl
if (todo.length == 0) {
    todo.push({ 'action': 'repl' });
}

while (rerun) {
    print("Top of run loop");
    rerun = false;
    for (var task of todo) {
        activeTask = task;
        if (task.action == 'eval') {
            debuggeeGlobal.eval(task.code);
        } else if (task.action == 'load') {
            debuggeeGlobal['scriptArgs'] = task.scriptArgs;
            debuggeeGlobal['scriptPath'] = task.script;
            print("Loading JavaScript file " + task.script);
            debuggeeGlobal.evaluate(read(task.script), { 'fileName': task.script, 'lineNumber': 1 });
        } else if (task.action == 'repl') {
            repl();
        }
        if (rerun)
            break;
    }
}

quit(0);
