/**
 * Copyright (c) André Bargull
 * Alle Rechte vorbehalten / All Rights Reserved.  Use is subject to license terms.
 *
 * <https://github.com/anba/es6draft>
 */
package com.github.anba.es6draft.repl.functions;

import static com.github.anba.es6draft.repl.SourceBuilder.ToSource;
import static com.github.anba.es6draft.repl.functions.SharedFunctions.absolutePath;
import static com.github.anba.es6draft.repl.functions.SharedFunctions.loadScript;
import static com.github.anba.es6draft.repl.functions.SharedFunctions.readFile;
import static com.github.anba.es6draft.repl.functions.SharedFunctions.relativePathToScript;
import static com.github.anba.es6draft.repl.functions.WrapperProxy.CreateWrapProxy;
import static com.github.anba.es6draft.runtime.AbstractOperations.*;
import static com.github.anba.es6draft.runtime.types.Undefined.UNDEFINED;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;

import com.github.anba.es6draft.Executable;
import com.github.anba.es6draft.Script;
import com.github.anba.es6draft.compiler.CompilationException;
import com.github.anba.es6draft.parser.ParserException;
import com.github.anba.es6draft.runtime.ExecutionContext;
import com.github.anba.es6draft.runtime.Realm;
import com.github.anba.es6draft.runtime.internal.DebugInfo;
import com.github.anba.es6draft.runtime.internal.Errors;
import com.github.anba.es6draft.runtime.internal.Properties.Function;
import com.github.anba.es6draft.runtime.internal.Properties.Value;
import com.github.anba.es6draft.runtime.internal.ScriptException;
import com.github.anba.es6draft.runtime.internal.Source;
import com.github.anba.es6draft.runtime.modules.MalformedNameException;
import com.github.anba.es6draft.runtime.objects.FunctionPrototype;
import com.github.anba.es6draft.runtime.objects.GlobalObject;
import com.github.anba.es6draft.runtime.objects.collection.WeakMapObject;
import com.github.anba.es6draft.runtime.types.Callable;
import com.github.anba.es6draft.runtime.types.ScriptObject;
import com.github.anba.es6draft.runtime.types.Type;
import com.github.anba.es6draft.runtime.types.builtins.BoundFunctionObject;
import com.github.anba.es6draft.runtime.types.builtins.BuiltinFunction;
import com.github.anba.es6draft.runtime.types.builtins.FunctionObject;
import com.github.anba.es6draft.runtime.types.builtins.ProxyObject;

/**
 * Built-in functions for the mozilla-shell.
 */
public final class MozShellFunctions {
    private final long startMilli = System.currentTimeMillis();
    private final long startNano = System.nanoTime();

    /**
     * shell-function: {@code loadRelativeToScript(filename)}
     * 
     * @param cx
     *            the execution context
     * @param caller
     *            the caller execution context
     * @param fileName
     *            the file path
     */
    @Function(name = "loadRelativeToScript", arity = 1)
    public void loadRelativeToScript(ExecutionContext cx, ExecutionContext caller, String fileName) {
        Path file = Paths.get(fileName);
        loadScript(cx, file, relativePathToScript(cx, caller, file));
    }

    /**
     * shell-function: {@code evaluate(code, [options])}
     *
     * @param cx
     *            the execution context
     * @param caller
     *            the caller context
     * @param code
     *            the source code to evaluate
     * @param options
     *            additional options object
     * @return the eval result value
     */
    @Function(name = "evaluate", arity = 2)
    public Object evaluate(ExecutionContext cx, ExecutionContext caller, Object code, Object options) {
        if (!(Type.isString(code) && (Type.isUndefined(options) || Type.isObject(options)))) {
            throw Errors.newError(cx, "invalid arguments");
        }

        String sourceCode = Type.stringValue(code).toString();
        String sourceName = "@evaluate";
        int sourceLine = 1;
        boolean noScriptRval = false;
        boolean catchTermination = false;
        Realm realm = cx.getRealm();
        if (Type.isObject(options)) {
            ScriptObject opts = Type.objectValue(options);
            Object fileName = Get(cx, opts, "fileName");
            if (!Type.isUndefined(fileName)) {
                sourceName = Type.isNull(fileName) ? "" : ToFlatString(cx, fileName);
            }
            Object lineNumber = Get(cx, opts, "lineNumber");
            if (!Type.isUndefined(lineNumber)) {
                sourceLine = ToInt32(cx, lineNumber);
            }
            Object g = Get(cx, opts, "global");
            if (!Type.isUndefined(g)) {
                ScriptObject obj = ToObject(cx, g);
                if (!(obj instanceof GlobalObject)) {
                    throw Errors.newError(cx, "invalid global argument");
                }
                realm = ((GlobalObject) obj).getRealm();
            }
            noScriptRval = ToBoolean(Get(cx, opts, "noScriptRval"));
            catchTermination = ToBoolean(Get(cx, opts, "catchTermination"));
        }

        Source source = new Source(caller.sourceInfo(), sourceName, sourceLine);
        try {
            Script script = realm.getScriptLoader().script(source, sourceCode);
            Object result = script.evaluate(realm);
            return (!noScriptRval ? result : UNDEFINED);
        } catch (ParserException | CompilationException e) {
            // Create a script exception from the requested code realm, not from the caller's realm.
            throw e.toScriptException(realm.defaultContext());
        } catch (ScriptException | StackOverflowError e) {
            throw e;
        } catch (Error | Exception e) {
            if (catchTermination) {
                return "terminated";
            }
            throw Errors.newError(cx, Objects.toString(e.getMessage(), ""));
        }
    }

    /**
     * shell-function: {@code run(file)}
     *
     * @param cx
     *            the execution context
     * @param fileName
     *            the file to evaluate
     * @return the execution time in milli-seconds
     */
    @Function(name = "run", arity = 1)
    public double run(ExecutionContext cx, String fileName) {
        long start = System.nanoTime();
        Path file = Paths.get(fileName);
        loadScript(cx, file, absolutePath(cx, file));
        long end = System.nanoTime();
        return (double) TimeUnit.NANOSECONDS.toMillis(end - start);
    }

    /**
     * shell-function: {@code printErr(message)}
     *
     * @param cx
     *            the execution context
     * @param message
     *            the message to write
     */
    @Function(name = "printErr", arity = 1)
    public void printErr(ExecutionContext cx, String message) {
        PrintWriter errorWriter = cx.getRuntimeContext().getConsole().errorWriter();
        errorWriter.println(message);
    }

    /**
     * shell-function: {@code putstr(message)}
     *
     * @param cx
     *            the execution context
     * @param message
     *            the message to write
     */
    @Function(name = "putstr", arity = 1)
    public void putstr(ExecutionContext cx, String message) {
        PrintWriter writer = cx.getRuntimeContext().getConsole().writer();
        writer.print(message);
        writer.flush();
    }

    /**
     * shell-function: {@code dateNow()}
     *
     * @return the current date in micro-seconds resolution
     */
    @Function(name = "dateNow", arity = 0)
    public double dateNow() {
        long elapsed = TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - startNano);
        double date = startMilli + TimeUnit.MICROSECONDS.toMillis(elapsed);
        double subdate = (elapsed % 1000) / 1000d;
        return date + subdate;
    }

    /**
     * shell-function: {@code assertEq()}
     *
     * @param cx
     *            the execution context
     * @param actual
     *            the actual value
     * @param expected
     *            the expected value
     * @param message
     *            the optional error message
     */
    @Function(name = "assertEq", arity = 2)
    public void assertEq(ExecutionContext cx, Object actual, Object expected, Object message) {
        if (!SameValue(actual, expected)) {
            StringBuilder msg = new StringBuilder();
            msg.append(String.format("Assertion failed: got %s, expected %s", ToSource(cx, actual),
                    ToSource(cx, expected)));
            if (!Type.isUndefined(message)) {
                msg.append(": ").append(ToFlatString(cx, message));
            }
            throw Errors.newError(cx, msg.toString());
        }
    }

    /**
     * shell-function: {@code throwError()}
     * 
     * @param cx
     *            the execution context
     */
    @Function(name = "throwError", arity = 0)
    public void throwError(ExecutionContext cx) {
        throw Errors.newError(cx, "This is an error");
    }

    /**
     * shell-function: {@code evalcx(s, [o])}
     *
     * @param cx
     *            the execution context
     * @param caller
     *            the caller context
     * @param sourceCode
     *            the source to evaluate
     * @param o
     *            the global object
     * @return the eval result value
     */
    @Function(name = "evalcx", arity = 1)
    public Object evalcx(ExecutionContext cx, ExecutionContext caller, String sourceCode, Object o) {
        ScriptObject global;
        if (Type.isUndefinedOrNull(o)) {
            global = newGlobal(cx);
        } else {
            global = ToObject(cx, o);
        }
        if (sourceCode.isEmpty() || "lazy".equals(sourceCode)) {
            return global;
        }
        if (!(global instanceof GlobalObject)) {
            throw Errors.newError(cx, "invalid global argument");
        }
        Source source = new Source(caller.sourceInfo(), "evalcx", 1);
        Realm realm = ((GlobalObject) global).getRealm();
        try {
            Script script = realm.getScriptLoader().script(source, sourceCode);
            return script.evaluate(realm);
        } catch (ParserException | CompilationException e) {
            // Create a script exception from the requested code realm, not from the caller's realm.
            throw e.toScriptException(realm.defaultContext());
        }
    }

    /**
     * shell-function: {@code sleep(dt)}
     *
     * @param cx
     *            the execution context
     * @param dt
     *            the number of seconds to pause the application
     */
    @Function(name = "sleep", arity = 1)
    public void sleep(ExecutionContext cx, double dt) {
        try {
            TimeUnit.SECONDS.sleep(ToUint32(dt));
        } catch (InterruptedException e) {
            throw Errors.newError(cx, Objects.toString(e.getMessage(), ""));
        }
    }

    /**
     * shell-function: {@code snarf(filename)}
     *
     * @param cx
     *            the execution context
     * @param fileName
     *            the file path
     * @return the file content
     */
    @Function(name = "snarf", arity = 1)
    public String snarf(ExecutionContext cx, String fileName) {
        Path file = Paths.get(fileName);
        return readFile(cx, file, absolutePath(cx, file));
    }

    /**
     * shell-function: {@code readRelativeToScript(filename)}
     *
     * @param cx
     *            the execution context
     * @param caller
     *            the caller context
     * @param fileName
     *            the file path
     * @return the file content
     */
    @Function(name = "readRelativeToScript", arity = 1)
    public String readRelativeToScript(ExecutionContext cx, ExecutionContext caller, String fileName) {
        Path file = Paths.get(fileName);
        return readFile(cx, file, relativePathToScript(cx, caller, file));
    }

    /**
     * shell-function: {@code elapsed()}
     * 
     * @return the micro-seconds elapsed since application start-up
     */
    @Function(name = "elapsed", arity = 0)
    public double elapsed() {
        return (double) TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - startNano);
    }

    /**
     * shell-function: {@code decompileFunction(function)}
     *
     * @param cx
     *            the execution context
     * @param function
     *            the function object
     * @return the function source string
     */
    @Function(name = "decompileFunction", arity = 1)
    public Object decompileFunction(ExecutionContext cx, Object function) {
        if (!(function instanceof FunctionObject || function instanceof BuiltinFunction
                || function instanceof BoundFunctionObject)) {
            return UNDEFINED;
        }
        return ((Callable) function).toSource(cx);
    }

    /**
     * shell-function: {@code wrapWithProto(obj, proto)}
     *
     * @param cx
     *            the execution context
     * @param obj
     *            the proxy target object
     * @param proto
     *            the proxy prototype object
     * @return the new proxy object
     */
    @Function(name = "wrapWithProto", arity = 2)
    public WrapperProxy wrapWithProto(ExecutionContext cx, Object obj, Object proto) {
        return CreateWrapProxy(cx, obj, proto);
    }

    /**
     * shell-function: {@code newGlobal()}
     *
     * @param cx
     *            the execution context
     * @return a new global object instance
     */
    @Function(name = "newGlobal", arity = 0)
    public ScriptObject newGlobal(ExecutionContext cx) {
        Realm realm;
        try {
            realm = Realm.InitializeHostDefinedRealm(cx.getRealm().getWorld());
        } catch (IOException e) {
            throw Errors.newError(cx, Objects.toString(e.getMessage(), ""));
        }
        return realm.getGlobalThis();
    }

    /**
     * shell-function: {@code getMaxArgs()}
     *
     * @return the maximum number of allowed function arguments
     */
    @Function(name = "getMaxArgs", arity = 0)
    public int getMaxArgs() {
        return FunctionPrototype.getMaxArguments();
    }

    /**
     * shell-function: {@code isProxy(p)}
     * 
     * @param p
     *            the proxy object
     * @return {@code true} if <var>p</var> is a proxy object
     */
    @Function(name = "isProxy", arity = 1)
    public boolean isProxy(Object p) {
        return (p instanceof ProxyObject || p instanceof WrapperProxy);
    }

    /**
     * shell-function: {@code parse(source)}
     * 
     * @param cx
     *            the execution context
     * @param sourceCode
     *            the source string to parse
     */
    @Function(name = "parse", arity = 1)
    public void parse(ExecutionContext cx, String sourceCode) {
        Source source = new Source("<script>", 1);
        cx.getRealm().getScriptLoader().parseScript(source, sourceCode);
    }

    /**
     * shell-function: {@code thisFilename()}
     * 
     * @param cx
     *            the execution context
     * @param caller
     *            the caller context
     * @return the current file name
     */
    @Function(name = "thisFilename", arity = 0)
    public String thisFilename(ExecutionContext cx, ExecutionContext caller) {
        Source source = caller.sourceInfo();
        if (source == null) {
            return "";
        }
        if (source.getFile() != null) {
            return source.getFile().toString();
        }
        return source.getName();
    }

    /**
     * shell-property: {@code scriptArgs}
     * 
     * @param cx
     *            the execution context
     * @return the array of arguments passed to the script
     */
    @Value(name = "scriptArgs")
    public Object scriptArgs(ExecutionContext cx) {
        return Get(cx, cx.getGlobalObject(), "arguments");
    }

    /**
     * shell-function: {@code objectAddress(object)}
     * 
     * @param cx
     *            the execution context
     * @param object
     *            the script object
     * @return the object's address
     */
    @Function(name = "objectAddress", arity = 1)
    public String objectAddress(ExecutionContext cx, ScriptObject object) {
        return Integer.toString(System.identityHashCode(object), 16);
    }

    /**
     * shell-function: {@code nondeterministicGetWeakMapKeys(weakMap)}
     * 
     * @param cx
     *            the execution context
     * @param weakMap
     *            the weak map
     * @return the object's address
     */
    @Function(name = "nondeterministicGetWeakMapKeys", arity = 1)
    public ScriptObject nondeterministicGetWeakMapKeys(ExecutionContext cx, WeakMapObject weakMap) {
        return CreateArrayFromList(cx, weakMap.getWeakMapData().keySet());
    }

    /**
     * shell-function: {@code dis([function])}
     * 
     * @param cx
     *            the execution context
     * @param caller
     *            the caller context
     * @param args
     *            the arguments
     * @throws IOException
     *             if there was any I/O error
     * @throws MalformedNameException
     *             if the module name cannot be normalized
     */
    @Function(name = "dis", arity = 1)
    public void dis(ExecutionContext cx, ExecutionContext caller, Object... args)
            throws IOException, MalformedNameException {
        DebugInfo debugInfo = debugInfo(caller, args);
        if (debugInfo != null) {
            PrintWriter writer = cx.getRuntimeContext().getConsole().writer();
            for (DebugInfo.Method method : debugInfo.getMethods()) {
                writer.println(method.disassemble());
            }
        }
    }

    /**
     * shell-function: {@code disassemble([function])}
     * 
     * @param cx
     *            the execution context
     * @param caller
     *            the caller context
     * @param args
     *            the arguments
     * @return the disassembled byte code
     * @throws IOException
     *             if there was any I/O error
     * @throws MalformedNameException
     *             if the module name cannot be normalized
     */
    @Function(name = "disassemble", arity = 1)
    public String disassemble(ExecutionContext cx, ExecutionContext caller, Object... args)
            throws IOException, MalformedNameException {
        DebugInfo debugInfo = debugInfo(caller, args);
        if (debugInfo != null) {
            StringBuilder sb = new StringBuilder();
            for (DebugInfo.Method method : debugInfo.getMethods()) {
                sb.append(method.disassemble()).append('\n');
            }
            return sb.toString();
        }
        return "";
    }

    private static DebugInfo debugInfo(ExecutionContext caller, Object... args) {
        if (args.length == 0) {
            FunctionObject currentFunction = caller.getCurrentFunction();
            Executable currentExec = caller.getCurrentExecutable();
            if (currentFunction != null && currentFunction.getExecutable() == currentExec) {
                return currentFunction.getCode().debugInfo();
            }
            return currentExec.getRuntimeObject().debugInfo();
        } else if (args[0] instanceof FunctionObject) {
            return ((FunctionObject) args[0]).getCode().debugInfo();
        }
        return null;
    }

    /**
     * shell-function: {@code getTimeZone()}
     *
     * @param cx
     *            the execution context
     * @return the current time zone
     */
    @Function(name = "getTimeZone", arity = 0)
    public String getTimeZone(ExecutionContext cx) {
        return cx.getRuntimeContext().getTimeZone().getID();
    }

    /**
     * shell-function: {@code setTimeZone(timeZoneId)}
     *
     * @param cx
     *            the execution context
     * @param timeZoneId
     *            the time zone id
     */
    @Function(name = "setTimeZone", arity = 1)
    public void setTimeZone(ExecutionContext cx, String timeZoneId) {
        cx.getRuntimeContext().setTimeZone(TimeZone.getTimeZone(timeZoneId));
    }

    /**
     * shell-function: {@code drainJobQueue()}
     * 
     * @param cx
     *            the execution context
     */
    @Function(name = "drainJobQueue", arity = 0)
    public void drainJobQueue(ExecutionContext cx) {
        cx.getRealm().getWorld().runEventLoop();
    }
}
