package com.atlassian.bitbucket.mesh.repository;

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.event.EventListener;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitRefPattern;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.fetch.GitFetchBuilder;
import com.atlassian.bitbucket.mesh.git.builder.fetch.GitFetchTagMode;
import com.atlassian.bitbucket.mesh.git.builder.updateref.GitUpdateRefSetBuilder;
import com.atlassian.bitbucket.mesh.git.builder.updateref.UpdateRefCompletionHandler;
import com.atlassian.bitbucket.mesh.git.fetch.CopyLooseObjectsVisitor;
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.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.git.worktree.*;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.process.handler.LoggingStdioHandler;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcInvalidWorkTree;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcInvalidWorkTreePath;
import com.atlassian.bitbucket.mesh.util.ShaUtils;
import com.atlassian.util.profiling.Ticker;
import com.atlassian.util.profiling.Timers;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.channels.WritableByteChannel;
import java.nio.file.*;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static com.google.common.base.MoreObjects.firstNonNull;
import static java.nio.file.StandardOpenOption.*;
import static java.util.Objects.requireNonNull;

@Profiled
public class DefaultWorkTreeManager implements WorkTreeManager {

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

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;
    private final GitPorcelain porcelain;
    private final RepositoryManager repositoryManager;
    private final WorkTreeStateDao workTreeStateDao;

    public DefaultWorkTreeManager(GitAgent agent, GitProcessBuilderFactory builderFactory,
                                  DataStoreManager dataStoreManager, GitPorcelain porcelain,
                                  RepositoryManager repositoryManager, TaskScheduler scheduler,
                                  PlatformTransactionManager transactionManager, WorkTreeStateDao workTreeStateDao) {
        this.agent = agent;
        this.builderFactory = builderFactory;
        this.porcelain = porcelain;
        this.repositoryManager = repositoryManager;
        this.workTreeStateDao = workTreeStateDao;

        scheduler.scheduleAtFixedRate(
                new WorkTreeCleanupTask(dataStoreManager, new TransactionTemplate(transactionManager),
                        workTreeStateDao),
                Instant.now().plusSeconds(300L), Duration.ofHours(6));
    }

    @Override
    @Transactional
    public void close(@Nonnull WorkTree workTree) {
        requireNonNull(workTree, "workTree");

        workTreeStateDao.deleteById(workTree.getLocalId());
        try {
            MoreFiles.deleteRecursively(workTree.getPath());
        } catch (IOException e) {
            log.warn("Failed to remove work tree files", e);
        }
    }

    @Transactional
    @Override
    @Nonnull
    public WorkTree create(@Nonnull CreateWorkTreeParameters parameters) {
        requireNonNull(parameters, "parameters");

        Repository repository = parameters.getRepository();
        Instant deadline = Instant.now().plus(parameters.getExpiry());
        boolean hasAlternates = !parameters.getAlternates().isEmpty();
        long id = workTreeStateDao.create(deadline, hasAlternates, repository.getId());
        Path workTreePath = resolveWorkTreeDirectory(repository, id);
        try {
            workTreePath = createWorkTree(parameters, workTreePath);
            return new DefaultWorkTree(deadline, hasAlternates, id, workTreePath, repository);
        } catch (RuntimeException e) {
            try {
                MoreFiles.deleteRecursively(workTreePath);
            } catch (NoSuchFileException | FileNotFoundException ex) {
                //ignored because the directory we tried to delete does not actually exist so all is fine.
            } catch (IOException ex) {
                log.info("Failed to remove work tree files after failed creation", e);
            }
            throw e;
        }
    }

    @Override
    public WorkTree findById(@Nonnull String id) {
        Long localId = DefaultWorkTree.parseId(id);
        if (localId != null) {
            WorkTreeState state = workTreeStateDao.findById(localId);
            if (state != null) {
                Repository repository = repositoryManager.getById(state.getRepositoryId());
                Path workTreeDirectory = resolveWorkTreeDirectory(repository, state.getId());
                return new DefaultWorkTree(state.getExpiry(), state.hasAlternates(), state.getId(),
                        workTreeDirectory, repository);
            }
        }
        return null;
    }

    @Override
    @Nonnull
    public WorkTree getById(@Nonnull String id) {
        WorkTree wt = findById(id);
        if (wt == null) {
            throw new WorkTreeNotFoundException(id);
        }
        return wt;
    }

    @Override
    public void mkdir(@Nonnull WorkTree workTree, @Nonnull String relativePath) {
        requireNonNull(relativePath, "relativePath");
        requireNonNull(workTree, "workTree");
        checkNotExpired(workTree);

        Path toCreate = toPath(workTree, relativePath);
        try {
            Files.createDirectories(toCreate);
        } catch (FileAlreadyExistsException e) {
            throw new InvalidWorkTreePathException(relativePath, RpcInvalidWorkTreePath.Reason.FILE_ALREADY_EXISTS);
        } catch (IOException e) {
            throw new WorkTreeWriteException(relativePath + " could not be created");
        }
    }

    @EventListener
    public void onRepositoryDeleted(@Nonnull RepositoryDeletedEvent event) {
        requireNonNull(event, "event");

        Repository repository = event.getRepository();
        List<WorkTreeState> toRemove = workTreeStateDao.findByRepository(repository.getId());
        toRemove.forEach(state -> {
            Path workTreePath = resolveWorkTreeDirectory(repository, state.getId());
            try {
                MoreFiles.deleteRecursively(workTreePath);
                workTreeStateDao.deleteById(state.getId());
                log.debug("[{}] Deleted work tree with ID {}", repository, state.getId());
            } catch (FileNotFoundException | NoSuchFileException e) {
                //ignored, the files were already removed, possibly by the cleanup job
            } catch (IOException e) {
                log.info("Could not remove work tree directory '{}'", workTreePath);
            }
        });
    }

    @Override
    @Nonnull
    public RefUpdate publish(@Nonnull WorkTree workTree, @Nonnull PublishWorkTreeParameters parameters) {
        requireNonNull(workTree, "workTree");
        requireNonNull(parameters, "parameters");
        checkNotExpired(workTree);

        //Resolve the commit hashes to be published first. If this fails, there's no point in forking any git processes.
        String commitHash = agent.revParse(workTree.getPath().resolve(PATH_GIT), GitAgent.HEAD);

        if (GitAgent.HEAD.equals(commitHash)) {
            throw new InvalidWorkTreeException(RpcInvalidWorkTree.Reason.NO_COMMIT);
        }

        String refId = GitRefPattern.HEADS.qualify(parameters.getBranch());
        String fromHash = parameters.getExpectedHash();
        if (fromHash == null) {
            //It's going to be a force-update, resolve the ref to get the current hash
            Branch branch = agent.resolveBranch(workTree.getRepository(), refId, true);
            fromHash = branch == null ? ShaUtils.NULL_SHA1 : branch.getLatestCommit();
        }
        return new RefUpdate.Builder(refId)
                .oldHash(fromHash)
                .newHash(commitHash)
                .build();
    }

    @Override
    public void updateRef(@Nonnull WorkTree workTree, @Nonnull PublishWorkTreeParameters parameters,
                          @Nonnull RefUpdate refUpdate) {
        requireNonNull(workTree, "workTree");
        requireNonNull(parameters, "parameters");
        requireNonNull(refUpdate, "refUpdate");
        checkNotExpired(workTree);

        if (workTree.hasAlternates()) {
            //If the work tree has alternates we can't just copy loose objects because we can't be sure
            //whether those objects refer to other objects borrowed from an alternate. Running git fetch
            //ensures all the necessary objects get copied
            fetch(workTree, parameters, refUpdate);
        } else {
            //Otherwise, if the work tree has no alternates, we can just copy any loose objects in the
            //work tree into the target repository, because they can only be referring to objects that
            //are already in the repository
            fetchViaCopy(workTree);
        }
        updateRef(workTree.getRepository(), parameters, refUpdate);
    }

    @Nonnull
    @Override
    public WritableByteChannel writeFile(@Nonnull WorkTree workTree, @Nonnull String relativePath) {
        requireNonNull(workTree, "workTree");
        requireNonNull(relativePath, "relativePath");
        checkNotExpired(workTree);

        try {
            return Files.newByteChannel(toPath(workTree, relativePath), WRITE, CREATE, TRUNCATE_EXISTING);
        } catch (IOException e) {
            throw new WorkTreeWriteException(relativePath + " could not be written to");
        }
    }

    @VisibleForTesting
    @Nonnull
    static Path resolveWorkTreeDirectory(@Nonnull Repository repository, long workTreeId) {
        return repository.getWorkTreesDir().resolve("wt-" + workTreeId);
    }

    @VisibleForTesting
    WorkTreeParameters buildWorkTreeParameters(CreateWorkTreeParameters parameters, Path workTreeDirectory) {
        return new WorkTreeParameters.Builder(parameters.getRepository(), parameters.getCommit())
                .branch(parameters.getBranch())
                .alternates(parameters.getAlternates())
                .targetDir(workTreeDirectory)
                .pristine(parameters.isPristine())
                .build();
    }

    private void checkNotExpired(WorkTree workTree) {
        if (workTree.isExpired()) {
            throw new InvalidWorkTreeException(RpcInvalidWorkTree.Reason.EXPIRED);
        }
    }

    private Path createWorkTree(CreateWorkTreeParameters parameters, Path workTreeDirectory) {
        WorkTreeParameters wtParams = buildWorkTreeParameters(parameters, workTreeDirectory);
        return porcelain.workTree(wtParams);
    }

    /**
     * Fetches the work tree objects into the repository.
     *
     * @param workTree   the work tree containing the changes to fetch
     * @param parameters describes, among others, whether the refs should be force-updated
     * @param refUpdate  describes the change being fetched
     */
    private void fetch(WorkTree workTree, PublishWorkTreeParameters parameters, RefUpdate refUpdate) {
        GitFetchBuilder builder = builderFactory.builder(workTree.getRepository())
                .fetch()
                .author(firstNonNull(parameters.getAuthor(), SYSTEM_GIT_IDENT))
                .refspec(refUpdate.getNewHash())
                .repository(workTree)
                .tags(GitFetchTagMode.NO_TAGS);
        parameters.configureAndRun(builder.build(new LoggingStdioHandler()));
    }

    /**
     * Fetches the objects by directly copying loose objects from the work tree to the destination repository.
     * <p>
     * Because the work tree has an alternate back to the origin repository, and has no objects of its own when
     * it's created, any objects in the work tree must belong to the newly created commits. Copying those loose
     * objects to the destination repository is conceptually identical to running {@code git fetch}, but avoids
     * all of its overhead.
     * <p>
     * Note that this means, as objects are copied, the destination repository likely has incomplete objects, like
     * commits referring to trees that don't exist or trees referring to blobs that don't exist. It appears to be
     * natural for Git to handle this case, though, because a normal {@code git fetch} unpacks its new objects into
     * the repository without writing the pack first. That results in the same transient missing objects as copying.
     *
     * @param workTree the work tree containing the objects to fetch
     */
    private void fetchViaCopy(WorkTree workTree) {
        Repository repository = workTree.getRepository();
        Path repositoryDir = repository.getPath();
        Path workTreeDir = workTree.getPath();

        try (Ticker ignored = Timers.start("copy loose objects")) {
            boolean tryMove = CopyLooseObjectsVisitor.isSameFileStore(repositoryDir, workTreeDir);

            Files.walkFileTree(MoreFiles.resolve(workTreeDir, PATH_GIT, PATH_OBJECTS),
                    EnumSet.noneOf(FileVisitOption.class), 2, new CopyLooseObjectsVisitor(repositoryDir, tryMove));
        } catch (IOException e) {
            log.debug("{}: Fetch work tree changes via copy failed", repository, e);
            throw new WorkTreeWriteException(e);
        }
    }

    private Path toPath(WorkTree workTree, String relativePath) {
        Path workTreePath = workTree.getPath();
        Path toCheck = workTreePath.resolve(relativePath);
        try {
            if (!MoreFiles.isWithin(toCheck, workTreePath)) {
                throw new InvalidWorkTreePathException(relativePath, RpcInvalidWorkTreePath.Reason.INVALID_LOCATION);
            } else if (MoreFiles.isWithin(toCheck, workTreePath.resolve(".git"))) {
                throw new InvalidWorkTreePathException(relativePath, RpcInvalidWorkTreePath.Reason.INVALID_LOCATION);
            }
        } catch (IOException e) {
            throw new InvalidWorkTreePathException(relativePath, RpcInvalidWorkTreePath.Reason.INVALID_NAME);
        }
        return toCheck;
    }

    private void updateRef(Repository repository, PublishWorkTreeParameters parameters, RefUpdate refUpdate) {
        GitUpdateRefSetBuilder builder = builderFactory.builder(repository).updateRef()
                .set(refUpdate.getRefId(), refUpdate.getNewHash())
                .author(firstNonNull(parameters.getAuthor(), SYSTEM_GIT_IDENT)) //for reflogs
                .completionHandler(new UpdateRefCompletionHandler())
                .oldValue(refUpdate.getOldHash());
        parameters.configureAndRun(builder.build());
    }
}
