/**
 * 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.loader;

import static com.github.anba.es6draft.runtime.AbstractOperations.Call;
import static com.github.anba.es6draft.runtime.AbstractOperations.Get;
import static com.github.anba.es6draft.runtime.LexicalEnvironment.newObjectEnvironment;
import static com.github.anba.es6draft.runtime.objects.FunctionConstructor.CreateDynamicFunction;
import static com.github.anba.es6draft.runtime.types.Undefined.UNDEFINED;
import static com.github.anba.es6draft.runtime.types.builtins.OrdinaryObject.ObjectCreate;

import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import com.github.anba.es6draft.compiler.CompilationException;
import com.github.anba.es6draft.compiler.CompiledFunction;
import com.github.anba.es6draft.parser.JSONBuilder;
import com.github.anba.es6draft.parser.JSONParser;
import com.github.anba.es6draft.parser.ParserException;
import com.github.anba.es6draft.runtime.ExecutionContext;
import com.github.anba.es6draft.runtime.LexicalEnvironment;
import com.github.anba.es6draft.runtime.ObjectEnvironmentRecord;
import com.github.anba.es6draft.runtime.Realm;
import com.github.anba.es6draft.runtime.internal.ScriptLoader;
import com.github.anba.es6draft.runtime.internal.Source;
import com.github.anba.es6draft.runtime.modules.ModuleRecord;
import com.github.anba.es6draft.runtime.modules.ModuleSource;
import com.github.anba.es6draft.runtime.modules.ResolvedBinding;
import com.github.anba.es6draft.runtime.modules.SourceIdentifier;
import com.github.anba.es6draft.runtime.objects.FunctionConstructor.SourceKind;
import com.github.anba.es6draft.runtime.types.Callable;
import com.github.anba.es6draft.runtime.types.Intrinsics;
import com.github.anba.es6draft.runtime.types.ScriptObject;
import com.github.anba.es6draft.runtime.types.Type;

/**
 * Module record implementation for standard node modules (CommonJS modules).
 */
public final class NodeModuleRecord implements ModuleRecord {
    private final SourceIdentifier sourceId;
    private final Source source;
    private final CompiledFunction function;

    private Realm realm;
    private ScriptObject moduleObject;
    private LexicalEnvironment<ObjectEnvironmentRecord> environment;
    private ScriptObject namespace;
    private ScriptObject meta;
    private HashSet<String> exportedNames;

    NodeModuleRecord(SourceIdentifier sourceId, Source source, CompiledFunction function) {
        this.sourceId = sourceId;
        this.source = source;
        this.function = function;
    }

    Source getSource() {
        return this.source;
    }

    Object getModuleExports() {
        return Get(realm.defaultContext(), moduleObject, "exports");
    }

    private ScriptObject getModuleExportsOrNull() {
        Object exports = getModuleExports();
        if (Type.isObject(exports)) {
            return Type.objectValue(exports);
        }
        return null;
    }

    private ScriptObject getModuleExportsOrEmpty() {
        Object exports = getModuleExports();
        if (Type.isObject(exports)) {
            return Type.objectValue(exports);
        }
        // Return an empty object as a placeholder when module exports is a primitive.
        return ObjectCreate(realm, (ScriptObject) null);
    }

    private HashSet<String> ownNames(ScriptObject object) {
        return new HashSet<>(object.ownPropertyNames(realm.defaultContext()));
    }

    private Set<String> instantiateAndGetExportedNames() {
        instantiate();
        if (exportedNames == null) {
            // Recursive call - return the own names of the current module exports.
            return ownNames(getModuleExportsOrEmpty());
        }
        return exportedNames;
    }

    @Override
    public SourceIdentifier getSourceCodeId() {
        return sourceId;
    }

    @Override
    public Realm getRealm() {
        return realm;
    }

    public void setRealm(Realm realm, ScriptObject moduleObject) {
        assert this.realm == null : "module already linked";
        this.realm = Objects.requireNonNull(realm);
        this.moduleObject = Objects.requireNonNull(moduleObject);
    }

    @Override
    public LexicalEnvironment<ObjectEnvironmentRecord> getEnvironment() {
        return environment;
    }

    @Override
    public ScriptObject getNamespace() {
        assert realm != null : "module is not linked";
        ScriptObject exports = getModuleExportsOrNull();
        if (exports != null) {
            return exports;
        }
        // Something went wrong, return the current namespace object.
        return namespace;
    }

    @Override
    public void setNamespace(ScriptObject namespace) {
        assert this.namespace == null : "namespace already created";
        this.namespace = Objects.requireNonNull(namespace);
    }

    @Override
    public ScriptObject getMeta() {
        return meta;
    }

    @Override
    public void setMeta(ScriptObject meta) {
        assert this.meta == null : "meta already created";
        this.meta = Objects.requireNonNull(meta);
    }

    @Override
    public Set<String> getExportedNames(Set<ModuleRecord> exportStarSet) {
        assert realm != null : "module is not linked";
        return Collections.unmodifiableSet(instantiateAndGetExportedNames());
    }

    @Override
    public ResolvedBinding resolveExport(String exportName, Map<ModuleRecord, Set<String>> resolveSet) {
        assert realm != null : "module is not linked";
        Set<String> resolvedExports = resolveSet.get(this);
        if (resolvedExports == null) {
            resolveSet.put(this, resolvedExports = new HashSet<>());
        } else if (resolvedExports.contains(exportName)) {
            return null;
        }
        resolvedExports.add(exportName);
        if (instantiateAndGetExportedNames().contains(exportName)) {
            return new ResolvedBinding(this, exportName);
        }
        return null;
    }

    @Override
    public void instantiate() {
        assert realm != null : "module is not linked";
        if (environment == null) {
            ScriptObject exports = getModuleExportsOrEmpty();
            environment = newObjectEnvironment(exports, realm.getGlobalEnv());

            // Compile the module.
            ExecutionContext cx = realm.defaultContext();
            Object compile = Get(cx, moduleObject, "compile");
            Callable moduleFn = CreateDynamicFunction(cx, SourceKind.Function, function,
                    cx.getIntrinsic(Intrinsics.FunctionPrototype));
            Callable requireFn = NodeFunctions.createRequireFunction(this);
            Call(cx, compile, moduleObject, moduleFn, requireFn);

            // Create the module bindings.
            ScriptObject currentExports = getModuleExportsOrEmpty();
            if (currentExports != exports) {
                // Module exports property has changed, update environment with new bindings.
                environment = newObjectEnvironment(currentExports, realm.getGlobalEnv());
            }
            exportedNames = ownNames(currentExports);
        }
    }

    @Override
    public Object evaluate() {
        assert realm != null : "module is not linked";
        assert environment != null : "module is not instantiated";
        return UNDEFINED;
    }

    /**
     * ParseModule ( sourceText )
     * 
     * @param scriptLoader
     *            the script loader
     * @param identifier
     *            the source code identifier
     * @param moduleSource
     *            the module source code
     * @return the parsed module record
     * @throws IOException
     *             if there was any I/O error
     * @throws ParserException
     *             if the module source contains any syntax errors
     * @throws CompilationException
     *             if the parsed module source cannot be compiled
     */
    public static NodeModuleRecord ParseModule(ScriptLoader scriptLoader, SourceIdentifier identifier,
            ModuleSource moduleSource) throws IOException, ParserException, CompilationException {
        Source source = moduleSource.toSource();
        String sourceCode = moduleSource.sourceCode();
        if (identifier.toUri().toString().endsWith(".json")) {
            String jsonScript = JSONParser.parse(sourceCode, new ScriptJSONBuilder());
            sourceCode = String.format("module.exports = %s", jsonScript);
        }
        String parameters = "exports, require, module, __filename, __dirname";
        CompiledFunction function = scriptLoader.function(source, parameters, sourceCode);
        return new NodeModuleRecord(identifier, source, function);
    }

    private static final class ScriptJSONBuilder implements JSONBuilder<String, Void, Void, Void> {
        private final StringBuilder sb = new StringBuilder();

        private static void appendJsonString(StringBuilder sb, String s) {
            final int len = s.length();
            int begin = 0;
            for (int i = 0; i < len; ++i) {
                char c = s.charAt(i);
                if (c == '\u2028' || c == '\u2029') {
                    sb.append(s, begin, i);
                    sb.append('\\').append(c);
                    begin = i + 1;
                }
            }
            sb.append(s, begin, len);
        }

        @Override
        public String createDocument(Void value) {
            return sb.toString();
        }

        @Override
        public Void newObject() {
            sb.append('{');
            return null;
        }

        @Override
        public Void finishObject(Void object) {
            sb.append('}');
            return null;
        }

        @Override
        public void newProperty(Void object, String name, String rawName, long index) {
            if (index != 0) {
                sb.append(',');
            }
            appendJsonString(sb, rawName);
            sb.append(':');
        }

        @Override
        public void finishProperty(Void object, String name, String rawName, long index, Void value) {
            // empty
        }

        @Override
        public Void newArray() {
            sb.append('[');
            return null;
        }

        @Override
        public Void finishArray(Void array) {
            sb.append(']');
            return null;
        }

        @Override
        public void newElement(Void array, long index) {
            if (index != 0) {
                sb.append(',');
            }
        }

        @Override
        public void finishElement(Void array, long index, Void value) {
            // empty
        }

        @Override
        public Void newNull() {
            sb.append("null");
            return null;
        }

        @Override
        public Void newBoolean(boolean value) {
            sb.append(value);
            return null;
        }

        @Override
        public Void newNumber(double value, String rawValue) {
            sb.append(rawValue);
            return null;
        }

        @Override
        public Void newString(String value, String rawValue) {
            appendJsonString(sb, rawValue);
            return null;
        }
    }
}
