/*
 * Copyright (c) 2010-2026 Contributors to the openHAB project
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.openhab.automation.jsscripting.internal;

import static org.openhab.core.automation.module.script.ScriptEngineFactory.*;
import static org.openhab.core.automation.module.script.ScriptTransformationService.OPENHAB_TRANSFORMATION_SCRIPT;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.AccessMode;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.attribute.FileAttribute;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Pattern;

import javax.script.ScriptContext;
import javax.script.ScriptException;

import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.HostAccess;
import org.graalvm.polyglot.Source;
import org.graalvm.polyglot.Value;
import org.graalvm.polyglot.io.IOAccess;
import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
import org.openhab.automation.jsscripting.internal.scope.ScriptExtensionModuleProvider;
import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable;
import org.openhab.automation.jsscripting.internal.scriptengine.helper.LifecycleTracker;
import org.openhab.automation.jsscripting.internal.util.Slf4jOutputStream;
import org.openhab.core.OpenHAB;
import org.openhab.core.automation.module.script.LockableScriptEngine;
import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
import org.openhab.core.automation.module.script.internal.handler.AbstractScriptModuleHandler;
import org.openhab.core.automation.module.script.internal.handler.ScriptActionHandler;
import org.openhab.core.automation.module.script.internal.handler.ScriptConditionHandler;
import org.openhab.core.items.Item;
import org.openhab.core.library.types.QuantityType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;

import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;

/**
 * GraalJS ScriptEngine implementation
 *
 * @author Jonathan Gilbert - Initial contribution
 * @author Dan Cunningham - Script injections
 * @author Florian Hotze - Create lock object for multi-thread synchronization; Inject the {@link JSRuntimeFeatures}
 *         into the JS context; Fix memory leak caused by HostObject by making HostAccess reference static; Switch to
 *         {@link Lock} for multi-thread synchronization; globals and openhab-js injection code caching
 */
public class OpenhabGraalJSScriptEngine
        extends InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable<GraalJSScriptEngine>
        implements LockableScriptEngine {

    // see private constant GraalJSScriptEngine.ID
    static final String LANGUAGE_ID = "js";

    private static final Source GLOBAL_SOURCE;
    static {
        try {
            GLOBAL_SOURCE = Source.newBuilder(LANGUAGE_ID,
                    getFileAsReader(GraalJSScriptEngineFactory.NODE_DIR + "/@jsscripting-globals.js"),
                    "@jsscripting-globals.js").cached(true).build();
        } catch (IOException e) {
            throw new IllegalStateException("Failed to load @jsscripting-globals.js", e);
        }
    }

    private static final Source OPENHAB_JS_SOURCE;
    static {
        try {
            OPENHAB_JS_SOURCE = Source.newBuilder(LANGUAGE_ID,
                    getFileAsReader(GraalJSScriptEngineFactory.NODE_DIR + "/@openhab-globals.js"),
                    "@openhab-globals.js").cached(true).build();
        } catch (IOException e) {
            throw new IllegalStateException("Failed to load @openhab-globals.js", e);
        }
    }
    private static final String OPENHAB_JS_INJECTION_CODE = "Object.assign(this, require('openhab'));";
    private static final String EVENT_CONVERSION_CODE = "(this.event = (typeof this.rules?._getTriggeredData === 'function') ? rules._getTriggeredData(ctx, true) : this.event);";
    private static final Pattern USE_WRAPPER_DIRECTIVE = Pattern
            .compile("^\\s*([\"'])use wrapper(?:=(?<enabled>true|false))?\\1;?\\s*$");
    /**
     * Pattern to match the header of a JavaScript Immediately Invoked Function Expression (IIFE).
     */
    private static final Pattern IIFE_HEADER = Pattern
            .compile("^\\s*\\(\\s*(?:function\\s*[\\w$]*\\s*\\([^)]*\\)|\\([^)]*\\)\\s*=>)\\s*\\{?.*$");

    private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";

    static {
        File cachePath = Path.of(OpenHAB.getUserDataFolder(), "cache", "org.graalvm.polyglot").toFile();
        System.setProperty("polyglot.engine.userResourceCache", cachePath.getAbsolutePath());
    }
    /** Provides unlimited host access as well as custom translations from JS to Java Objects */
    private static final HostAccess HOST_ACCESS = HostAccess.newBuilder(HostAccess.ALL)
            // Translate JS-Joda ZonedDateTime to java.time.ZonedDateTime
            .targetTypeMapping(Value.class, ZonedDateTime.class, v -> v.hasMember("withFixedOffsetZone"),
                    v -> ZonedDateTime.parse(v.invokeMember("withFixedOffsetZone").invokeMember("toString").asString()),
                    HostAccess.TargetMappingPrecedence.LOW)

            // Translate JS-Joda Duration to java.time.Duration
            .targetTypeMapping(Value.class, Duration.class,
                    // picking two members to check as Duration has many common function names
                    v -> v.hasMember("minusDuration") && v.hasMember("toNanos"),
                    v -> Duration.ofNanos(v.invokeMember("toNanos").asLong()), HostAccess.TargetMappingPrecedence.LOW)

            // Translate JS-Joda Instant to java.time.Instant
            .targetTypeMapping(Value.class, Instant.class,
                    // picking two members to check as Instant has many common function names
                    v -> v.hasMember("toEpochMilli") && v.hasMember("epochSecond"),
                    v -> Instant.ofEpochMilli(v.invokeMember("toEpochMilli").asLong()),
                    HostAccess.TargetMappingPrecedence.LOW)

            // Translate openhab-js Item to org.openhab.core.items.Item
            .targetTypeMapping(Value.class, Item.class, v -> v.hasMember("rawItem"),
                    v -> v.getMember("rawItem").as(Item.class), HostAccess.TargetMappingPrecedence.LOW)

            // Translate openhab-js Quantity to org.openhab.core.library.types.QuantityType
            .targetTypeMapping(Value.class, QuantityType.class, v -> v.hasMember("rawQtyType"),
                    v -> v.getMember("rawQtyType").as(QuantityType.class), HostAccess.TargetMappingPrecedence.LOW)
            .build();

    private final Logger logger = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);

    /** {@link Lock} synchronization of multi-thread access */
    private final Lock lock = new ReentrantLock();
    private final JSRuntimeFeatures jsRuntimeFeatures;
    private final LifecycleTracker lifecycleTracker = new LifecycleTracker();
    private final GraalJSScriptEngineConfiguration configuration;

    // these fields start as null because they are populated on first use
    private @Nullable Consumer<String> scriptDependencyListener;
    private String engineIdentifier = "<uninitialized>";

    private boolean initialized = false;
    private boolean closed = false;

    /**
     * Creates an implementation of ScriptEngine {@code (& Invocable)}, wrapping the contained engine,
     * that tracks the script lifecycle and provides hooks for scripts to do so too.
     */
    public OpenhabGraalJSScriptEngine(GraalJSScriptEngineConfiguration configuration, Engine engine,
            JSScriptServiceUtil jsScriptServiceUtil, JSDependencyTracker jsDependencyTracker) {
        super(null); // delegate depends on fields not yet initialized, so we cannot set it immediately
        this.configuration = configuration;
        this.jsRuntimeFeatures = jsScriptServiceUtil.getJSRuntimeFeatures(lock);

        Logger contextLogger = LoggerFactory
                .getLogger(OpenhabGraalJSScriptEngine.class.getPackageName() + ".org.graalvm.polyglot.Context");
        delegate = GraalJSScriptEngine.create(engine, Context.newBuilder(LANGUAGE_ID) //
                .allowIO(IOAccess.newBuilder() //
                        .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
                            @Override
                            public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options,
                                    FileAttribute<?>... attrs) throws IOException {
                                if (configuration.isDependencyTrackingEnabled()
                                        && path.startsWith(GraalJSScriptEngineFactory.JS_LIB_PATH)) {
                                    Consumer<String> localScriptDependencyListener = scriptDependencyListener;
                                    if (localScriptDependencyListener != null) {
                                        localScriptDependencyListener.accept(path.toString());
                                    }
                                }

                                if (path.toString().endsWith(".js")) {
                                    SeekableByteChannel sbc = null;
                                    if (isRootNodePath(path)) {
                                        InputStream is = getClass().getResourceAsStream(nodeFileToResource(path));
                                        if (is == null) {
                                            throw new IOException("Could not read " + path);
                                        }
                                        sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
                                    } else {
                                        sbc = super.newByteChannel(path, options, attrs);
                                    }
                                    return new PrefixedSeekableByteChannel(
                                            ("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
                                } else {
                                    return super.newByteChannel(path, options, attrs);
                                }
                            }

                            @Override
                            public void checkAccess(Path path, Set<? extends AccessMode> modes,
                                    LinkOption... linkOptions) throws IOException {
                                if (isRootNodePath(path)) {
                                    if (getClass().getResource(nodeFileToResource(path)) == null) {
                                        throw new NoSuchFileException(path.toString());
                                    }
                                } else {
                                    super.checkAccess(path, modes, linkOptions);
                                }
                            }

                            @Override
                            public Map<String, Object> readAttributes(Path path, String attributes,
                                    LinkOption... options) throws IOException {
                                if (isRootNodePath(path)) {
                                    return Map.of("isRegularFile", true);
                                }
                                return super.readAttributes(path, attributes, options);
                            }

                            @Override
                            public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
                                if (isRootNodePath(path)) {
                                    return path;
                                }
                                return super.toRealPath(path, linkOptions);
                            }
                        }).build())
                .allowHostAccess(HOST_ACCESS) //
                // usage of .allowAllAccess(true) includes
                // - allowCreateThread(true)
                // - allowCreateProcess(true)
                // - allowHostClassLoading(true)
                // - allowHostClassLookup(true)
                // - allowPolyglotAccess(PolyglotAccess.ALL)
                // - allowIO(true)
                // - allowEnvironmentAccess(EnvironmentAccess.INHERIT)
                .allowAllAccess(true) //
                // allow class lookup from scripts
                .hostClassLoader(getClass().getClassLoader()) //
                // allow experimental options
                .allowExperimentalOptions(true) //
                // redirect context's out/err streams to SLF4J
                .out(new Slf4jOutputStream(contextLogger, Level.DEBUG)) //
                .err(new Slf4jOutputStream(contextLogger, Level.WARN)) //
                // choose the path to look for CommonJS module (i.e. node_modules)
                .option("js.commonjs-require-cwd", jsDependencyTracker.getLibraryPath().toString()) //
                // enable Nashorn compat mode as openhab-js relies on accessors, see
                // https://github.com/oracle/graaljs/blob/master/docs/user/NashornMigrationGuide.md#accessors
                .option("js.nashorn-compat", "true") //
                // if Nashorn compat mode is enabled, it will enforce ES5 compatibility, we want ECMA2025
                .option("js.ecmascript-version", "2025") //
                // enable CommonJS module support
                .option("js.commonjs-require", "true"));
    }

    @Override
    protected void beforeInvocation() {
        super.beforeInvocation();

        logger.debug("Initializing GraalJS script engine '{}' ...", engineIdentifier);

        lock.lock();
        logger.debug("Lock acquired before invocation for engine '{}'.", engineIdentifier);

        if (initialized) {
            return;
        }

        ScriptContext ctx = delegate.getContext();
        if (ctx == null) {
            throw new IllegalStateException("Failed to retrieve script context");
        }

        // these are added post-construction, so we need to fetch them late
        String localEngineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
        if (localEngineIdentifier == null) {
            throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
        }
        this.engineIdentifier = localEngineIdentifier;

        ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
                .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
        if (scriptExtensionAccessor == null) {
            throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
        }

        Consumer<String> localScriptDependencyListener = (Consumer<String>) ctx
                .getAttribute(CONTEXT_KEY_DEPENDENCY_LISTENER);
        if (localScriptDependencyListener == null) {
            logger.warn(
                    "Failed to retrieve script dependency listener from engine bindings. Script dependency tracking will be disabled for engine '{}'.",
                    engineIdentifier);
        }
        scriptDependencyListener = localScriptDependencyListener;

        ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
                scriptExtensionAccessor, lock, getLockAcquisitionTimeoutMs(), lifecycleTracker);

        // Wrap the "require" function to also allow loading modules from the ScriptExtensionModuleProvider
        Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
                .locatorFor(delegate.getPolyglotContext(), localEngineIdentifier).locateModule(moduleName)
                .map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
        delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
        delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));

        // Injections into the JS runtime
        jsRuntimeFeatures.getFeatures().forEach((key, obj) -> {
            logger.debug("Injecting {} into the context of engine '{}' ...", key, engineIdentifier);
            delegate.put(key, obj);
        });

        initialized = true;

        if (logger.isDebugEnabled()) {
            logger.debug(
                    "Engine '{}': isScriptFile(): {}, isScriptModule(): {}, isScriptAction(): {}, isScriptCondition(): {}, isTransformation(): {}",
                    engineIdentifier, isScriptFile(), isScriptModule(), isScriptAction(), isScriptCondition(),
                    isTransformation());
        }

        if (!isScriptFile() && !isScriptModule() && !isTransformation()) {
            logger.warn(
                    "Unknown script environment detected for engine '{}': Neither script file, script module nor transformation.",
                    engineIdentifier);
        }

        try {
            logger.debug("Evaluating cached global script for engine '{}' ...", engineIdentifier);
            delegate.getPolyglotContext().eval(GLOBAL_SOURCE);

            if (configuration.isInjectionEnabledForAllScripts()
                    || (isScriptModule() && configuration.isInjectionEnabledForScriptModules())
                    || (isTransformation() && configuration.isInjectionEnabledForTransformations())) {
                if (configuration.isInjectionCachingEnabled()) {
                    logger.debug("Evaluating cached openhab-js injection for engine '{}' ...", engineIdentifier);
                    delegate.getPolyglotContext().eval(OPENHAB_JS_SOURCE);
                } else {
                    logger.debug("Evaluating openhab-js injection from the file system for engine '{}' ...",
                            engineIdentifier);
                    // use delegate::eval instead of this::eval to avoid invocation of beforeInvocation, onScript, etc.
                    delegate.eval(OPENHAB_JS_INJECTION_CODE);
                }
            }
            logger.debug("Successfully initialized GraalJS script engine '{}'.", engineIdentifier);
        } catch (ScriptException e) {
            logger.error("Could not inject global script", e);
        }
    }

    @Override
    protected String onScript(String script) {
        if (!isScriptModule()) {
            return super.onScript(script);
        }

        // keep this extendable for more directives by checking the first n+1 lines (n = number of directives)
        // up to two directives: "use strict" (handled by Graal) and "use wrapper"
        List<String> header = script.lines().limit(3).toList();
        boolean useWrapper = isScriptAction()
                || (isScriptCondition() && configuration.isScriptConditionWrapperEnabled());
        for (String line : header) {
            var matcher = USE_WRAPPER_DIRECTIVE.matcher(line);
            if (!matcher.matches()) {
                continue;
            }
            var enabled = matcher.group("enabled");
            if (enabled == null || enabled.isBlank()) {
                useWrapper = true;
            } else if ("false".equals(enabled)) {
                useWrapper = false;
            } else if ("true".equals(enabled)) {
                useWrapper = true;
            } else {
                logger.warn("Invalid value '{}' for 'use wrapper' directive in script for engine '{}'.", enabled,
                        engineIdentifier);
            }
        }

        String newScript = script;

        if (configuration.isEventConversionEnabled()) {
            int lineNumber = 0;
            // if the script contains an IIFE, make sure to inject the event conversion code inside the IIFE
            for (int i = 0; i < header.size(); i++) {
                if (IIFE_HEADER.matcher(header.get(i)).matches()) {
                    lineNumber = i + 1;
                    break;
                }
            }
            logger.debug("Injecting event conversion code into script for engine '{}' after line {} ...",
                    engineIdentifier, lineNumber);
            // inject event conversion code into the script at the given line number
            List<String> lines = newScript.lines().toList();
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < lines.size(); i++) {
                if (i == lineNumber) {
                    sb.append(EVENT_CONVERSION_CODE);
                }
                sb.append(lines.get(i));
                sb.append(System.lineSeparator());
            }
            newScript = sb.toString();
        }

        if (useWrapper) {
            logger.debug("Wrapping script for engine '{}' ...", engineIdentifier);
            // the line separator after the script is required so the end of the IIFE cannot be "commented out"
            newScript = "(function() {" + newScript + System.lineSeparator() + "})()";
        }
        return super.onScript(newScript);
    }

    @Override
    protected Object afterInvocation(Object obj) {
        lock.unlock();
        logger.debug("Lock released after invocation for engine '{}'.", engineIdentifier);
        return super.afterInvocation(obj);
    }

    @Override
    protected Exception afterThrowsInvocation(Exception e) {
        lock.unlock();
        return super.afterThrowsInvocation(e);
    }

    @Override
    public void close() throws Exception {
        if (closed) {
            logger.debug("Engine '{}' is already disposed and closed.", engineIdentifier);
            return;
        }

        lock.lock();
        try {
            try {
                jsRuntimeFeatures.close();
                this.lifecycleTracker.dispose();
            } finally {
                logger.debug("Engine '{}' disposed.", engineIdentifier);
                super.close();
                logger.debug("Engine '{}' closed.", engineIdentifier);
            }
        } finally {
            closed = true;
            lock.unlock();
        }
    }

    /**
     * Tests if the script is a script file, i.e. it is loaded from a JavaScript file.
     *
     * @return true if the script is loaded from a JavaScript file, false otherwise
     */
    private boolean isScriptFile() {
        ScriptContext ctx = delegate.getContext();
        if (ctx == null) {
            logger.warn("Failed to retrieve script context from engine '{}'.", engineIdentifier);
            return false;
        }
        return ctx.getAttribute("javax.script.filename") != null;
    }

    /**
     * Get the module type id (if any) of the module executing the script.
     *
     * @return the module type id (if any) of the module executing the script, or null if the script is not a module
     */
    private @Nullable String getModuleTypeId() {
        ScriptContext ctx = delegate.getContext();
        if (ctx == null) {
            logger.warn("Failed to retrieve script context from engine '{}'.", engineIdentifier);
            return null;
        }

        Object value = ctx.getAttribute(AbstractScriptModuleHandler.CONTEXT_KEY_MODULE_TYPE_ID);
        if (value instanceof String str) {
            return str;
        }
        return null;
    }

    /**
     * Tests if the script is a script module, i.e. executed by an implementation of
     * {@link AbstractScriptModuleHandler}.
     *
     * @return true if the script is a script module, false otherwise
     */
    private boolean isScriptModule() {
        String moduleTypeId = getModuleTypeId();
        return moduleTypeId != null && moduleTypeId.startsWith("script.");
    }

    /**
     * Tests if a script is a script action, i.e. executed by the ScriptActionHandler.
     *
     * @return true if the script is a script action, false otherwise
     */
    private boolean isScriptAction() {
        return ScriptActionHandler.TYPE_ID.equals(getModuleTypeId());
    }

    /**
     * Tests if the script is a script condition, i.e. executed by the ScriptConditionHandler.
     *
     * @return true if the script is a script condition, false otherwise
     */
    private boolean isScriptCondition() {
        return ScriptConditionHandler.TYPE_ID.equals(getModuleTypeId());
    }

    /**
     * Tests if the script is a transformation script, i.e. created from the script transformation service.
     *
     * @return true if it is a transformation script, false otherwise
     */
    private boolean isTransformation() {
        ScriptContext ctx = delegate.getContext();
        if (ctx == null) {
            logger.warn("Failed to retrieve script context from engine '{}'.", engineIdentifier);
            return false;
        }
        return engineIdentifier.startsWith(OPENHAB_TRANSFORMATION_SCRIPT);
    }

    /**
     * Tests if this is a root node directory, `/node_modules`, `C:\node_modules`, etc...
     *
     * @param path a root path
     * @return whether the given path is a node root directory
     */
    private static boolean isRootNodePath(Path path) {
        return path.startsWith(path.getRoot().resolve(GraalJSScriptEngineFactory.NODE_DIR));
    }

    /**
     * Converts a root node path to a class resource path for loading local modules
     * Ex: C:\node_modules\foo.js -> /node_modules/foo.js
     *
     * @param path a root path, e.g. C:\node_modules\foo.js
     * @return the class resource path for loading local modules
     */
    private static String nodeFileToResource(Path path) {
        return "/" + path.subpath(0, path.getNameCount()).toString().replace('\\', '/');
    }

    /**
     * @param fileName filename relative to the resources folder
     * @return file as {@link InputStreamReader}
     */
    private static Reader getFileAsReader(String fileName) throws IOException {
        InputStream ioStream = OpenhabGraalJSScriptEngine.class.getClassLoader().getResourceAsStream(fileName);

        if (ioStream == null) {
            throw new IOException(fileName + " not found");
        }

        return new InputStreamReader(ioStream);
    }

    @Override
    public @NonNull Lock getLock() {
        return lock;
    }

    @Override
    public long getLockAcquisitionTimeoutMs() {
        return configuration.getLockAcquisitionTimeout();
    }
}
