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

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.event.EventPublisher;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitUtils;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.catfile.EditFileMetadataBatchCatFileStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.catfile.TypeCatFileStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.commit.GitCommitVerify;
import com.atlassian.bitbucket.mesh.git.builder.lstree.ModeLsTreeStdioHandler;
import com.atlassian.bitbucket.mesh.git.commit.Commit;
import com.atlassian.bitbucket.mesh.git.exception.EditFileException;
import com.atlassian.bitbucket.mesh.git.exception.InvalidEditFileException;
import com.atlassian.bitbucket.mesh.git.exception.PathNotFoundException;
import com.atlassian.bitbucket.mesh.git.exception.TranscodingFailedException;
import com.atlassian.bitbucket.mesh.git.fetch.ObjectRefFetchStrategy;
import com.atlassian.bitbucket.mesh.git.fetch.RefFetchParameters;
import com.atlassian.bitbucket.mesh.git.fetch.TimedRefFetchStrategy;
import com.atlassian.bitbucket.mesh.git.porcelain.GitPorcelain;
import com.atlassian.bitbucket.mesh.git.porcelain.WorkTreeParameters;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.io.EncodingUtils;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import com.atlassian.bitbucket.mesh.repository.RefsUpdatedEvent;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcInvalidEditFile;
import com.atlassian.util.profiling.Ticker;
import com.atlassian.util.profiling.Timers;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.CharStreams;

import javax.annotation.Nonnull;
import java.io.*;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Set;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static com.atlassian.bitbucket.mesh.io.MoreFiles.isWithin;
import static com.atlassian.bitbucket.mesh.io.MoreFiles.mkdir;
import static java.nio.file.attribute.PosixFilePermission.*;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.appendIfMissing;

@Profiled
public class DefaultEditFileManager implements EditFileManager {

    static final int BUFFER_SIZE_BINARY = 8192;
    static final int BUFFER_SIZE_ENCODING = 32768;

    //Git only supports 2 modes: 755, which means a file is executable, and 644, which means it's not.
    //The read and write parts of the permission _don't actually matter_ to Git, but need to be set.
    private static final Set<PosixFilePermission> PERMISSION_755 = ImmutableSet.of(
            OWNER_READ, OWNER_WRITE, OWNER_EXECUTE,
            GROUP_READ, GROUP_EXECUTE,
            OTHERS_READ, OTHERS_EXECUTE);

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;
    private final EventPublisher eventPublisher;
    private final GitPorcelain porcelain;

    public DefaultEditFileManager(GitAgent agent, GitProcessBuilderFactory builderFactory,
                                  EventPublisher eventPublisher, GitPorcelain porcelain) {
        this.agent = agent;
        this.builderFactory = builderFactory;
        this.eventPublisher = eventPublisher;
        this.porcelain = porcelain;
    }

    @Nonnull
    @Override
    public String editFile(@Nonnull EditFileParameters parameters, @Nonnull Path content) {
        Repository repository = requireNonNull(parameters, "parameters").getRepository();
        if (parameters.getBranch() == null && !agent.isEmpty(repository)) {
            throw new InvalidEditFileException(RpcInvalidEditFile.Reason.EDIT_FILE_NO_BRANCH);
        }

        Path workTree = null;
        try (Ticker ignored = Timers.start("git: edit file " + parameters.getPath())) {
            workTree = porcelain.workTree(new WorkTreeParameters.Builder(parameters).build());

            Path file = workTree.resolve(parameters.getPath());
            try {
                if (!isWithin(file, workTree) || isWithin(file, workTree.resolve(".git"))) {
                    throw new InvalidEditFileException(RpcInvalidEditFile.Reason.EDIT_FILE_PATH);
                }
                mkdir(file.getParent());

                String sourceCommitId = parameters.getSourceCommitId();
                if (sourceCommitId == null) {
                    createFile(parameters, workTree, content, file);
                } else {
                    updateFile(parameters, workTree, content, file);
                }

                commit(parameters, workTree);

                return TimedRefFetchStrategy.time(new ObjectRefFetchStrategy(agent, builderFactory))
                        .fetch(new RefFetchParameters.Builder(parameters).build(), workTree);
            } catch (IOException e) {
                throw new EditFileException(e);
            }
        } finally {
            if (workTree != null) {
                MoreFiles.deleteQuietly(workTree);
            }
        }
    }

    @Nonnull
    @Override
    public Commit updateRef(@Nonnull EditFileParameters parameters, @Nonnull String commitId) {
        requireNonNull(parameters, "parameters");
        requireNonNull(commitId, "commitId");

        TimedRefFetchStrategy.time(new ObjectRefFetchStrategy(agent, builderFactory))
                .updateRef(new RefFetchParameters.Builder(parameters).build(), commitId);

        eventPublisher.publish(new RefsUpdatedEvent(parameters.getRepository()));
        Commit.Builder commitBuilder = new Commit.Builder()
                .author(parameters.getAuthor())
                .authorTimestamp(parameters.getTimestamp().toEpochMilli())
                .committer(parameters.getCommitter())
                .committerTimestamp(parameters.getTimestamp().toEpochMilli())
                .id(commitId)
                //If the provided message doesn't have a trailing newline, git commit -m will implicitly
                //add one. If there is one, a second is not added. appendIfMissing applies the same logic
                .message(appendIfMissing(parameters.getMessage(), "\n"));
        if (parameters.getBranch() != null) {
            commitBuilder.parent(parameters.getBranch().getLatestCommit());
        }
        return commitBuilder.build();
    }

    private static InputStream openContent(Path content, boolean symbolicLink) throws IOException {
        BufferedInputStream inputStream = new BufferedInputStream(Files.newInputStream(content), BUFFER_SIZE_ENCODING);

        //When writing a symbolic link, line breaks (\r, \r\n, \n) are invalid characters. Rather than
        //writing an invalid symbolic link, "assist" callers by removing any line breaks automatically
        return symbolicLink ? new LineBreakFilterInputStream(inputStream) : inputStream;
    }

    private static Charset toCharset(String encoding) {
        if (encoding != null && Charset.isSupported(encoding)) {
            return Charset.forName(encoding);
        }

        return StandardCharsets.UTF_8;
    }

    private void commit(EditFileParameters parameters, Path workTree) {
        String isoDateTime = ZonedDateTime.ofInstant(parameters.getTimestamp(), ZoneId.systemDefault())
                .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);

        parameters.configureAndRun(builderFactory.builder().commit()
                .author(parameters.getAuthor())
                .committer(parameters.getCommitter())
                .message(parameters.getMessage())
                .path(parameters.getPath())
                .quiet(true)
                .verify(GitCommitVerify.OFF)
                //Set core.autocrlf to input so git converts all CRLFs to LFs if the previous file had LF line endings
                .withConfiguration(CONFIG_CORE_AUTOCRLF, "input")
                //Set core.safecrlf to warn so git only prints a warning, but accepts an irreversible conversion
                .withConfiguration(CONFIG_CORE_SAFECRLF, "warn")
                //Ensure git does not change symlinks to regular files
                .withConfiguration(CONFIG_CORE_SYMLINKS, false)
                .withEnvironment(ENV_AUTHOR_DATE, isoDateTime)
                .withEnvironment(ENV_COMMITTER_DATE, isoDateTime)
                .workDir(workTree)
                .build());
    }

    private void createFile(EditFileParameters parameters, Path workTree, Path content, Path file) throws IOException {
        Branch branch = parameters.getBranch();
        if (branch != null) {
            try {
                parameters.configureAndRun(builderFactory.builder().catFile()
                        .type()
                        .object(branch.getLatestCommit(), parameters.getPath())
                        .workDir(workTree)
                        .build(new TypeCatFileStdioHandler()));

                //If we make it here, it means the file already exists
                throw new InvalidEditFileException(RpcInvalidEditFile.Reason.EDIT_FILE_EXISTS);
            } catch (PathNotFoundException expected) {
                //This means the file doesn't already exist, which confirms we'll be creating a new file
            }
        }

        try {
            Files.move(content, file, StandardCopyOption.ATOMIC_MOVE);
        } catch (IOException e) {
            //If we can't do an atomic move, fall back on a copy
            Files.copy(content, file);
        }

        parameters.configureAndRun(builderFactory.builder().add()
                .path(parameters.getPath())
                .workDir(workTree)
                .build());
    }

    private void updateFile(EditFileParameters parameters, Path workTree, Path content, Path file) throws IOException {
        //branch and sourceCommitId will never be null here because:
        //- branch can only be null if the repository is empty--in which case there are no files to update
        //- sourceCommitId can only be null when creating a new file (regardless of whether the repository is empty)
        @SuppressWarnings("ConstantConditions")
        EditFileMetadata metadata = parameters.configureAndRun(builderFactory.builder().catFile()
                .batch()
                .workDir(workTree)
                .build(new EditFileMetadataBatchCatFileStdioHandler(BUFFER_SIZE_BINARY, BUFFER_SIZE_ENCODING,
                        parameters.getSourceCommitId(), parameters.getBranch(), parameters.getPath())));

        String mode = parameters.configureAndRun(builderFactory.builder().lsTree()
                .nullTerminated(true)
                .path(parameters.getPath())
                .tree(GitAgent.HEAD)
                .workDir(workTree)
                .build(new ModeLsTreeStdioHandler()));
        boolean symbolicLink = mode != null && GitUtils.isSymbolicLink(mode);

        try (InputStream inputStream = openContent(content, symbolicLink)) {
            if (metadata.isBinary()) {
                Files.copy(content, file);
            } else {
                Charset sourceCharset = toCharset(EncodingUtils.detectEncoding(inputStream, BUFFER_SIZE_ENCODING));
                Charset targetCharset = toCharset(metadata.getEncoding());

                try (Reader reader = new InputStreamReader(inputStream, sourceCharset.newDecoder());
                     Writer writer = Files.newBufferedWriter(file, targetCharset)) {
                    CharStreams.copy(reader, writer);
                } catch (CharacterCodingException e) {
                    throw new TranscodingFailedException(sourceCharset.name(), targetCharset.name());
                }
            }
        }

        //If the original file was executable, ensure the file we're about to commit is too. This only
        //runs if the filesystem supports POSIX permissions (e.g. it's skipped on FAT32 and NTFS)
        if (mode != null && GitUtils.isExecutable(mode) == Boolean.TRUE && MoreFiles.isPosixSupported(file)) {
            Files.setPosixFilePermissions(file, PERMISSION_755);
        }

        Boolean statusHasOutput = parameters.configureAndRun(builderFactory.builder().status()
                .path(parameters.getPath())
                .shorten(true)
                .workDir(workTree)
                .build(new EmptyResponseStdioHandler()));
        if (statusHasOutput != Boolean.TRUE) {
            //The updated contents are identical to the existing contents, so there's nothing to commit
            throw new InvalidEditFileException(RpcInvalidEditFile.Reason.EDIT_FILE_SAME);
        }
    }

    /**
     * Can be used to inform the caller if the command has registered any output at all. Will return true if
     * any lines are processed.
     */
    private static class EmptyResponseStdioHandler extends AbstractLineStdioHandler<Boolean> {

        private boolean outputReceived;

        EmptyResponseStdioHandler() {
            super(StandardCharsets.UTF_8);
        }

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

        @Override
        protected boolean onStdout(@Nonnull String line, boolean truncated) {
            if (line.isEmpty()) {
                //Treat blank lines as no output
                return true;
            }

            //If we find a non-blank line, there's no need to read further output (because we didn't
            //want any output to start with)
            outputReceived = true;
            return false;
        }
    }

    /**
     * Removes line breaks(`\n` or `\r`) from the stream.
     * <p>
     * <b>Implementation note</b>: It is assumed the wrapped {@code InputStream} is performing its own buffering.
     * Otherwise this implementation may be extremely inefficient.
     */
    private static class LineBreakFilterInputStream extends FilterInputStream {

        LineBreakFilterInputStream(BufferedInputStream in) {
            super(in);
        }

        @Override
        public int read() throws IOException {
            while (true) {
                int value = super.read();
                if (value != '\n' && value != '\r') {
                    return value;
                }
            }
        }

        @Override
        public int read(@Nonnull byte[] buffer, int off, int len) throws IOException {
            if (len == 0) {
                return 0;
            }

            int read = 0;
            while (read < len) {
                int value = read();
                if (value == -1) {
                    break;
                }
                buffer[off++] = (byte) value;
                ++read;
            }

            return read == 0 ? -1 : read;
        }
    }
}
