package com.atlassian.bitbucket.mesh.git.hook.script;

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.config.HookScriptsConfig;
import com.atlassian.bitbucket.mesh.git.GitConstants;
import com.atlassian.bitbucket.mesh.git.GitQuarantineRegistry;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.exception.InternalFailureException;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.io.IoConsumer;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.layout.StorageLayout;
import com.atlassian.bitbucket.mesh.process.ProcessCompletionHandler;
import com.atlassian.bitbucket.mesh.process.ProcessCreator;
import com.atlassian.bitbucket.mesh.process.ProcessParameters;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.atlassian.bitbucket.mesh.process.handler.InputStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.nio.file.attribute.FileTime;
import java.time.Duration;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.attribute.PosixFilePermission.*;
import static java.util.Objects.requireNonNull;

/**
 * Manager for persisting and executing hook scripts.
 * <p>
 * Because Mesh requests and downloads the hook script files when needed (see the RpcCallRepositoryHooks message
 * flow), it does not know which hooks are current and which are stale and can be removed. As a result, we have to get
 * a bit creative with garbage collections.
 * <p>
 * On the upside, if a hook script is removed that is still current, it'll just be downloaded again when needed. On the
 * downside, there's a bit of time between checking that we have a hook script and executing it, so we have to be
 * careful not to delete scripts that have recently been checked.
 * <p>
 * To detect what hook scripts are 'active', we use 2 directories; script files are stored in the 'scripts' directory.
 * A second 'keep-alive' directory is used to track which scripts have been used since the last garbage collection run.
 * Whenever a hook script is referenced, we put down a keep-alive marker for that file.
 * <p>
 * Hook scripts are garbage collected when they're older than {@code staleDuration} <em>and</em> they haven't been
 * interacted with since the last garbage collection run, which runs daily. Garbage collection is only required for
 * standalone Mesh nodes. When the application is running as a sidecar it should not be performed. Because Bitbucket
 * and the sidecar both share the home directory, running garbage collection for the sidecar will result in unwanted
 * writes and deletions.
 */
@Profiled
public class DefaultHookScriptManager implements HookScriptManager {

    private static final Pattern PATTERN_NAME = Pattern.compile("hook-scripts/([^/]+)/([^/]+)");

    private static final Logger log = LoggerFactory.getLogger(DefaultHookScriptManager.class);

    private final GitProcessBuilderFactory builderFactory;
    private final HookScriptCommandLineFactory commandLineFactory;
    private final HookScriptsConfig config;
    private final ReadWriteLock garbageCollectionLock;
    private final Path keepAliveDir;
    private final ProcessCreator processCreator;
    private final GitQuarantineRegistry quarantineRegistry;
    private final Path scriptsDir;
    private final boolean sidecarEnabled;
    private final StorageLayout storageLayout;

    public DefaultHookScriptManager(GitProcessBuilderFactory builderFactory,
                                    HookScriptCommandLineFactory commandLineFactory, HookScriptsConfig config,
                                    ProcessCreator processCreator,
                                    GitQuarantineRegistry quarantineRegistry, StorageLayout storageLayout,
                                    boolean sidecarEnabled) {
        this.builderFactory = builderFactory;
        this.commandLineFactory = commandLineFactory;
        this.config = config;
        this.processCreator = processCreator;
        this.quarantineRegistry = quarantineRegistry;
        this.sidecarEnabled = sidecarEnabled;
        this.storageLayout = storageLayout;

        garbageCollectionLock = new ReentrantReadWriteLock();

        //Directory where the hook scripts will be stored
        scriptsDir = storageLayout.getHookScriptsDir();
        //Directory where _markers_ for 'active' scripts will be kept
        keepAliveDir = sidecarEnabled ? null : MoreFiles.mkdir(scriptsDir.resolve("keep-alive"));
    }

    @Override
    public void call(@Nonnull List<String> ids, @Nonnull CallHookScriptParameters parameters,
                     @Nonnull HookScriptCallback callback) {
        requireNonNull(ids, "ids");
        requireNonNull(parameters, "parameters");

        //Do a leading cancellation check before we set up the sandbox so we don't do all that processing only
        //to turn around and not run any scripts
        if (requireNonNull(callback, "callback").isCanceled()) {
            return;
        }

        Path sandbox = null;
        try {
            //Hook scripts are executed in a (shared) clone of the target repository to prevent the scripts from
            //making ref changes in the repository. This is critical because in phase 4 the hook scripts will be
            //executed in a single replica (the one that has the quarantine environment in case of a push). As a
            //result, any changes that are made by the hook scripts would not be visible to the other replicas,
            //resulting in undetected inconsistencies between replicas.
            sandbox = createSandBox(parameters.getRepository());

            byte[] stdin = createStdinBytes(parameters.getRefUpdates());
            for (String id : ids) {
                boolean accepted = callHookScript(id, sandbox, stdin, parameters, callback);
                if (parameters.isAbortOnFirstVeto() && !accepted || callback.isCanceled()) {
                    //If the script vetoed the change and we stop on the first veto, or the callback has canceled
                    //running subsequent scripts, return
                    return;
                }
            }
        } catch (IOException e) {
            throw new InternalFailureException("Failed to create a sandbox for executing hook scripts", e);
        } finally {
            if (sandbox != null) {
                MoreFiles.deleteQuietly(sandbox);
            }
        }
    }

    public void collectGarbage() {
        if (sidecarEnabled) {
            return;
        }

        garbageCollectionLock.writeLock().lock();
        try {
            Duration staleDuration = config.getGcPrune();
            try (Stream<Path> staleScripts = Files.list(scriptsDir)
                    .filter(Files::isRegularFile) // ignore directories
                    .filter(path -> !isRecent(staleDuration, path)) // only consider script files that are older than staleDuration
                    .filter(path -> !deleteKeepAliveMarker(path)) // only consider scripts without a keep-alive marker
            ) {
                staleScripts.forEach(path -> {
                    MoreFiles.deleteQuietly(path);
                    log.info("Deleted stale hook script {}", path.getFileName());
                });
            }
        } catch (IOException e) {
            log.warn("Hook scripts gc failed", e);
        } finally {
            garbageCollectionLock.writeLock().unlock();
        }
    }

    @Override
    public boolean exists(@Nonnull String id) {
        Path path = getPath(requireNonNull(id, "id"));
        if (sidecarEnabled) {
            return Files.isRegularFile(path);
        }

        //If garbage collection is running concurrently, wait for it to complete. GC does not run
        //when sidecar is enabled, so GC related operations should not be executed.
        garbageCollectionLock.readLock().lock();
        try {
            if (Files.isRegularFile(path)) {
                createKeepAliveMarker(path);
                return true;
            }
        } finally {
            garbageCollectionLock.readLock().unlock();
        }
        return false;
    }

    @Override
    public void put(@Nonnull String id, @Nonnull IoConsumer<OutputStream> writer) {
        if (sidecarEnabled) {
            return;
        }

        Path path = getPath(requireNonNull(id, "id"));
        Path tempFile = null;

        try {
            tempFile = Files.createTempFile(scriptsDir, "unsaved", ".tmp");

            try (OutputStream outputStream = Files.newOutputStream(tempFile)) {
                writer.accept(outputStream);
            }

            //If the filesystem supports POSIX permissions, mark the temporary file executable. We do this
            //first so that we don't risk ending up with a properly-named hook script that isn't executable.
            try {
                if (MoreFiles.isPosixSupported(tempFile)) {
                    //Group and other shouldn't need any access to the script. Since the script could contain
                    //privileged information, it shouldn't be globally readable
                    Files.setPosixFilePermissions(tempFile, EnumSet.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE));
                }
            } catch (IOException e) {
                log.error("{} could not be made executable", id, e);
                throw new HookScriptWriteFailedException(id, "Failed to make hook script executable", e);
            }

            //Once the file is set executable (if supported), move it. This will retain the executable bit,
            //along with other file attributes, resulting in an executable, properly-named hook script
            try {
                move(tempFile, path);
                createKeepAliveMarker(path);
            } catch (IOException e) {
                log.error("{}: The temp file could not be renamed to {}", id, path.getFileName(), e);
                throw new HookScriptWriteFailedException(id, "Failed to write hook script to disk", e);
            }
        } catch (IOException e) {
            log.error("Failed to write hook script to disk", e);
            throw new HookScriptWriteFailedException(id, "Failed to write hook script to disk", e);
        } finally {
            if (tempFile != null) {
                MoreFiles.deleteQuietly(tempFile);
            }
        }
    }

    @VisibleForTesting
    Path getPath(String id) {
        Matcher matcher = PATTERN_NAME.matcher(id);
        if (matcher.matches()) {
            //This is the file format that Bitbucket Server uses for hook scripts. We reuse this format in Mesh to
            //be able to directly access the hook scripts in the BBS shared home when running as a sidecar.
            return scriptsDir.resolve(matcher.group(1) + "-" + matcher.group(2));
        }
        throw new InvalidHookScriptIdException(id);
    }

    private static byte[] createStdinBytes(List<RefUpdate> refUpdates) throws IOException {
        try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
             Writer writer = new OutputStreamWriter(bos, UTF_8)) {
            for (RefUpdate refUpdate : refUpdates) {
                writer.write(refUpdate.getOldHash());
                writer.write(' ');
                writer.write(refUpdate.getNewHash());
                writer.write(' ');
                writer.write(refUpdate.getRefId());
                writer.write('\n');
            }
            writer.flush();
            return bos.toByteArray();
        }
    }

    private static boolean isRecent(Duration staleDuration, Path path) {
        long staleDurationMillis = staleDuration.toMillis();
        if (staleDurationMillis <= 0L) {
            //Setting staleDuration to Duration.ZERO (or negative) disables isRecent detection
            return false;
        }

        try {
            FileTime fileTime = (FileTime) Files.getAttribute(path, "creationTime");
            if (fileTime.toMillis() == 0L) {
                //creation time is not supported
                return false;
            }
            //Check whether the file was modified less than staleDuration ago. We do this with millisecond precision
            //because depending on the OS, the file time and current time might have different precisions.
            long ageInMillis = System.currentTimeMillis() - fileTime.toMillis();
            return ageInMillis <= staleDurationMillis;
        } catch (IOException e) {
            return false;
        }
    }

    private static void move(@Nonnull Path path, @Nonnull Path destination) throws IOException {
        try {
            Files.move(path, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
        } catch (AtomicMoveNotSupportedException e) {
            //Note: Files.move doesn't require/support StandardCopyOption.COPY_ATTRIBUTES; retaining
            //      attributes on a move is standard behavior
            Files.move(path, destination);
        }
    }

    private boolean callHookScript(String id, Path sandbox, byte[] stdin, CallHookScriptParameters parameters,
                                   HookScriptCallback callback) {
        HookScriptStdioHandler handler = new HookScriptStdioHandler(stdin, callback);
        List<String> commandLine = null;
        try {
            callback.onStartScript(id);

            commandLine = commandLineFactory.create(getPathOrThrow(id).toString());
            Map<String, String> env = new HashMap<>(parameters.getEnvironment());
            quarantineRegistry.get(parameters.getRepository()).ifPresent(quarantineEnv -> {
                //Add the quarantine path to the GIT_ALTERNATES_OBJECTS_DIR environment variable so the hook scripts
                //can access the quarantined objects
                env.compute(GitConstants.ENV_ALTERNATE_OBJECT_DIRS, (key, existing) -> existing == null
                            ? quarantineEnv.getQuarantinePath()
                            : existing + File.pathSeparator + quarantineEnv.getQuarantinePath());
            });
            processCreator.run(new ProcessParameters.Builder<>(handler, handler)
                    .arguments(commandLine)
                    .environment(env)
                    .executionInterval(parameters.getExecutionTimeout())
                    .idleInterval(parameters.getIdleTimeout())
                    .workDir(sandbox)
                    .build());
            return handler.isAccepted();
        } catch (RuntimeException e) {
            String message = "Hook script " + id + (e instanceof HookScriptNotFoundException ? " not found" : " failed");
            log.error(message, e);
            callback.onStderr(ByteBuffer.wrap(message.getBytes(UTF_8)), true);
            String cmdLineString = commandLine == null ? id : Joiner.on(' ').join(commandLine);
            callback.onComplete(new ProcessSummary.Builder(cmdLineString)
                    .exitCode(1)
                    .thrown(e)
                    .build());
            return false;
        }
    }

    private void createKeepAliveMarker(Path scriptFile) {
        try {
            Files.createFile(keepAliveDir.resolve(scriptFile.getFileName()));
        } catch (FileAlreadyExistsException e) {
            //The marker already exists, which is fine
        } catch (IOException e) {
            log.warn("Could not create keep-alive marker for {}", scriptFile, e);
        }
    }

    private Path createSandBox(Repository repository) throws IOException {
        Path sandbox = Files.createTempDirectory(storageLayout.getTempDir(), "script-sandbox");

        if (MoreFiles.isPosixSupported(sandbox)) {
            //Restrict the sandbox directory to the application user
            Files.setPosixFilePermissions(sandbox, EnumSet.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE));
        }

        //Create a shared clone to keep any ref changes that the script makes out of the 'real' repository
        builderFactory.builder()
                .clone()
                .bare()
                .shared(true)
                .origin(repository)
                .directory(sandbox)
                .build()
                .run();

        //Remove the origin to make it a little bit harder for hooks to push ref changes back to the real repository
        builderFactory.builder()
                .remote()
                .rm("origin")
                .workDir(sandbox)
                .build()
                .run();

        return sandbox;
    }

    private boolean deleteKeepAliveMarker(Path scriptFile) {
        try {
            return Files.deleteIfExists(keepAliveDir.resolve(scriptFile.getFileName()));
        } catch (IOException e) {
            log.debug("Failed to check whether the keep-alive marker for {} exists", scriptFile, e);
            return false;
        }
    }

    private Path getPathOrThrow(String id) {
        Path path = getPath(id);
        if (!Files.isRegularFile(path)) {
            throw new HookScriptNotFoundException(id);
        }
        return path;
    }

    public static class HookScriptStdioHandler extends InputStdioHandler implements ProcessCompletionHandler {

        private final HookScriptCallback callback;

        private boolean accepted;

        public HookScriptStdioHandler(byte[] stdin, HookScriptCallback callback) {
            super(stdin);

            this.callback = callback;
            accepted = true;
        }

        @Override
        public void onComplete(@Nonnull ProcessSummary summary) {
            accepted = summary.getExitCode() == 0;
            callback.onComplete(summary);
        }

        @Override
        public void onStderr(@Nonnull ByteBuffer buffer, boolean closed) {
            callback.onStderr(buffer, closed);
        }

        @Override
        public void onStdout(@Nonnull ByteBuffer buffer, boolean closed) {
            callback.onStdout(buffer, closed);
        }

        boolean isAccepted() {
            return accepted;
        }
    }
}
