package com.atlassian.bitbucket.mesh.git.transcode;

import com.atlassian.bitbucket.mesh.event.EventPublisher;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.exception.AbstractGitException;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.io.UnderLock;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.TranscodeDiffSettingChangedEvent;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcSetTranscodeEnabledRequest;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcTransaction;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcTransactionCommitFailed;
import com.atlassian.bitbucket.mesh.transaction.*;
import com.google.common.io.LineProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_ATTRIBUTES;
import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_INFO;
import static com.google.common.io.MoreFiles.asCharSource;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static java.util.Objects.requireNonNull;

public class DefaultTranscodeService implements TranscodeService {

    private static final String ATTR_DIFF = "diff=transcode";
    private static final String ATTR_TRANSCODE_ALL = "*\t" + ATTR_DIFF + "\n";
    private static final String ENTRY_TEXTCONV = "textconv";
    private static final String SECTION_DIFF = "diff.transcode";
    private static final String TRANSCODE_SCRIPT = "transcode.pl";

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

    private final GitProcessBuilderFactory builderFactory;
    private final EventPublisher eventPublisher;
    private final TransactionManager transactionManager;

    public DefaultTranscodeService(GitProcessBuilderFactory builderFactory, EventPublisher eventPublisher,
                                   TransactionManager transactionManager) {
        this.builderFactory = builderFactory;
        this.eventPublisher = eventPublisher;
        this.transactionManager = transactionManager;
    }

    @Override
    public boolean isEnabled(@Nonnull Repository repository) {
        //If the diff=transcode attribute is present, transcoding is enabled in the repository.
        //This is done last, so if transcoding was only partially enabled this won't be set
        Path attributes = MoreFiles.resolve(repository.getPath(), PATH_INFO, PATH_ATTRIBUTES);
        try {
            //If the attributes file does not exist, transcoding is definitely not enabled
            return Files.isRegularFile(attributes) &&
                    asCharSource(attributes, StandardCharsets.UTF_8).readLines(new LineProcessor<Boolean>() {

                        private Boolean enabled = Boolean.FALSE;

                        @Override
                        public Boolean getResult() {
                            return enabled;
                        }

                        @Override
                        public boolean processLine(@Nonnull String line) {
                            if (line.contains(ATTR_DIFF)) {
                                enabled = Boolean.TRUE;

                                return false; //No need to read more lines
                            }

                            return true;
                        }
                    });
        } catch (IOException e) {
            log.warn("{}: Could not verify transcoding status; assuming disabled", repository, e);
            return false;
        }
    }

    @Override
    public void setEnabled(@Nonnull Repository repository, boolean enabled) {
        requireNonNull(repository, "repository");

        transactionManager.executeWithoutResult(transaction -> {
            PreparedSetEnabled setEnabled = new PreparedSetEnabled(repository, enabled, transaction);
            setEnabled.prepare();
            if (!transaction.prepared(setEnabled).isSuccess()) {
                throw enabled ? new EnableTranscodeFailedException(null) : new DisableTranscodeFailedException(null);
            }
        });
    }

    private static void delete(Path file) {
        try {
            Files.delete(file);
            log.debug("Successfully deleted {}", file.getFileName());
        } catch (FileNotFoundException | NoSuchFileException ignored) {
            //If the file isn't present, it's not an error to not delete it
        } catch (IOException e) {
            log.warn("{} could not be deleted; will attempt to delete on exit", file.getFileName());
            MoreFiles.deleteOnExit(file);
        }
    }

    private class PreparedSetEnabled extends AbstractPreparedAction {

        private final boolean enabled;
        private final Transaction transaction;

        private UnderLock.LockedFile staged;

        PreparedSetEnabled(@Nonnull Repository repository, boolean enabled, Transaction transaction) {
            super(repository);

            this.enabled = enabled;
            this.transaction = transaction;
        }

        @Override
        public void commit() {
            if (staged != null) {
                try {
                    staged.close();
                    eventPublisher.publish(new TranscodeDiffSettingChangedEvent(repository, enabled));
                } catch (IOException e) {
                    throw new TransactionCommitFailedException("Failed to " + (enabled ? "enable" : "disable") +
                            " transcoding", RpcTransactionCommitFailed.Reason.PUBLISH_FAILED, e);
                }
            }
        }

        @Override
        public void rollBack() {
            if (staged != null) {
                staged.rollBack();
                try {
                    staged.close();
                } catch (IOException e) {
                    log.warn("[{}] Failed to release the lock on transcode settings", repository, e);
                }
            }
        }

        @Nonnull
        @Override
        public RpcTransaction.Builder populateTransaction(@Nonnull RpcTransaction.Builder builder) {
            return builder.setSetTranscode(RpcSetTranscodeEnabledRequest.newBuilder()
                    .setEnabled(enabled)
                    .setRepository(repository.getId()));
        }

        void disable(Repository repository, Path attributes, Path script) {
            //First, remove transcoding entries from gitattributes so git will stop using the transcode driver.
            //Because customers may have their own gitattributes entries (even though they shouldn't), we need
            //to walk the file line by line, dropping transcode entries and retaining the rest
            try {
                staged = UnderLock.forFile(attributes).lock();
                staged.edit((reader, writer) -> {
                    if (reader != null) {
                        //Retain all existing attributes
                        String line;
                        while ((line = reader.readLine()) != null) {
                            if (!line.contains(ATTR_DIFF)) {
                                writer.write(line + "\n"); //Always UNIX-style line endings
                            }
                        }
                    }
                }, true);
            } catch (IOException e) {
                //If gitattributes could not be staged as appropriate, then transcoding is still enabled.
                //Don't update gitconfig or remove the script
                log.error("{}: Diff transcoding could not be disabled", repository, e);
                throw new DisableTranscodeFailedException(e);
            }

            //When the transaction commits, also remove the gitconfig section for the transcoding diff driver
            //and delete the transcode script.
            transaction.addListener(new TransactionListener() {

                @Override
                public void afterCommit(@Nonnull Transaction transaction) {
                    //Then remove the gitconfig section for the transcoding diff driver
                    try {
                        builderFactory.builder(repository).config()
                                .removeSection(SECTION_DIFF)
                                .build()
                                .run();
                    } catch (RuntimeException e) {
                        //If the configuration cannot be removed, that's not a problem. Without the gitattributes
                        //set, git will never use the transcode driver. Its presence becomes noise, nothing worse
                        log.warn("{}: The {} section could not be removed from gitconfig", repository, SECTION_DIFF, e);
                    }

                    //And lastly delete the transcode script. If the script can't be deleted, no harm. It's just cruft
                    delete(script);
                }
            });
        }

        void enable(Repository repository, Path attributes, Path script) {
            //This doesn't check for the existence of the transcode script and skip enablement because enabling
            //transcoding is a multi-step process. That means it's possible it could be partially enabled. Running
            //through the entire process should automatically repair any repository that might be in that state
            try {
                //First, write the transcode script into the repository
                Resource resource =
                        new ClassPathResource("/scripts/" + TRANSCODE_SCRIPT, DefaultTranscodeService.class);
                try (InputStream scriptStream = resource.getInputStream()) {
                    Files.copy(scriptStream, script, REPLACE_EXISTING);
                }

                //Mark the script executable
                if (!script.toFile().setExecutable(true)) {
                    throw new IOException("Could not mark " + script + " executable");
                }

                //Add a gitconfig section configuring the textconv script for a new transcoding diff driver.
                //Note the leading ./ on the script. Without that, git only appears to look in the PATH
                builderFactory.builder(repository).config()
                        .set(SECTION_DIFF + "." + ENTRY_TEXTCONV, "./" + TRANSCODE_SCRIPT)
                        .build()
                        .run();

                //Make no assumptions that the info directory already exists. It's part of the standard git
                //templates, but the templates exist to be customized
                MoreFiles.mkdir(attributes.getParent());

                //After writing the script _and_ adding the gitconfig entry, write the gitattributes that
                //tell git to use our new transcoding diff driver. Otherwise, if we do this first, there's
                //a subtle race condition where diffs may fail for attempting to use an unknown driver
                staged = UnderLock.forFile(attributes).lock();
                staged.edit((reader, writer) -> {
                    if (reader != null) {
                        //Retain all existing attributes
                        String line;
                        while ((line = reader.readLine()) != null) {
                            if (line.contains(ATTR_DIFF)) {
                                //Skip the line we're about to add; no need to duplicate
                                continue;
                            }
                            writer.write(line + "\n");
                        }
                    }
                    //Append the transcode attributes
                    writer.write(ATTR_TRANSCODE_ALL);
                }, true);
            } catch (IOException | AbstractGitException e) { //AbstractGitException is for the GitProcess
                log.error("{}: Diff transcoding could not be enabled", repository, e);

                throw new EnableTranscodeFailedException(e);
            }
        }

        void prepare() {
            Path repositoryDir = repository.getPath();
            Path attributes = MoreFiles.resolve(repositoryDir, PATH_INFO, PATH_ATTRIBUTES);
            Path script = repositoryDir.resolve(TRANSCODE_SCRIPT);

            if (enabled) {
                enable(repository, attributes, script);
            } else if (Files.isRegularFile(attributes)) {
                //The diff=transcode attribute is set as the final step in enabling transcoding. If no
                //attributes are set, then, it means transcoding isn't (fully) enabled, so there's no
                //point in trying to disable it
                disable(repository, attributes, script);
            }
        }
    }
}
