package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.event.EventPublisher;
import com.atlassian.bitbucket.mesh.git.exception.InternalFailureException;
import com.atlassian.bitbucket.mesh.io.ListenableWritableByteChannel;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.channels.WritableByteChannel;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static java.nio.file.StandardOpenOption.*;

@Profiled
public class DefaultUploadManager implements UploadManager {

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

    private final GitAgent agent;
    private final EventPublisher eventPublisher;
    private final RepositoryHashService repositoryHashService;

    public DefaultUploadManager(GitAgent agent, EventPublisher eventPublisher,
                                RepositoryHashService repositoryHashService) {
        this.agent = agent;
        this.eventPublisher = eventPublisher;
        this.repositoryHashService = repositoryHashService;
    }

    @Override
    public boolean want(@Nonnull UploadFileParameters parameters) {
        Repository logicalDestination = parameters.getRepository();
        Repository uploadDestination = getDestinationRepository(parameters);
        boolean result = wantsFile(uploadDestination, parameters);
        if (result && !uploadDestination.equals(logicalDestination)) {
            //Objects and packs can exist in the repository or shared-objects, so check both locations
            result = wantsFile(logicalDestination, parameters);
        }
        return result;
    }

    @Nonnull
    @Override
    public ListenableWritableByteChannel uploadFile(@Nonnull UploadFileParameters parameters) throws IOException {
        Repository destination = getDestinationRepository(parameters);
        Path destinationPath = getPath(destination, parameters);
        Path tmpPath = destinationPath.resolveSibling(destinationPath.getFileName() + ".tmp");
        MoreFiles.mkdir(destinationPath.getParent());
        WritableByteChannel rawChannel = Files.newByteChannel(tmpPath, WRITE, CREATE, TRUNCATE_EXISTING);
        return wrapUpOnClose(destination, parameters,
                new UploadFileWritableByteChannel(rawChannel, parameters, tmpPath, destinationPath));
    }

    private static Path getPath(Repository destination, UploadFileParameters parameters) {
        Path path;
        String relativePath = parameters.getPath();
        switch (parameters.getType()) {
            case GIT_DARK_REFS:
                path = destination.getPath().resolve(PATH_DARK_REFS);
                break;
            case GIT_LFS:
                path = destination.getLfsDir();
                break;
            case GIT_LOG:
                path = destination.getPath().resolve(PATH_LOGS);
                relativePath = destination.accept(DarkRefMapper.INSTANCE).apply(relativePath);
                break;
            case GIT_OBJECTS:
                path = destination.getPath().resolve(PATH_OBJECTS);
                break;
            case GIT_REFS:
                path = destination.getPath().resolve(PATH_REFS);
                break;
            case GIT_ROOT:
                path = destination.getPath();
                break;
            default:
                throw new IllegalArgumentException("Unsupported UploadType " + parameters.getType());
        }
        return path.resolve(relativePath);
    }

    private Repository getDestinationRepository(UploadFileParameters parameters) {
        Repository repository = parameters.getRepository();
        if (parameters.getType() == UploadType.GIT_OBJECTS && isEmptyManagedRepository(repository)) {
            return ((ManagedRepository) repository).getSharedObjects();
        }
        return repository;
    }

    private boolean isEmptyManagedRepository(Repository repository) {
        return repository instanceof ManagedRepository && !agent.hasObjects(repository);
    }

    private ListenableWritableByteChannel wrapUpOnClose(Repository destination,
                                                        UploadFileParameters parameters,
                                                        ListenableWritableByteChannel channel) {
        Repository repository = parameters.getRepository();
        UploadType type = parameters.getType();
        if (type == UploadType.GIT_DARK_REFS || type == UploadType.GIT_REFS ||
                (type == UploadType.GIT_ROOT && PACKED_REFS.equals(parameters.getPath()))) {
            //We've uploaded a ref/packed-refs; raise an event to protect the objects it references from gc
            channel.addListener(() -> {
                repositoryHashService.resetContentHash(repository);
                eventPublisher.publish(new RefsUpdatedEvent(repository));
            });
        } else if (type == UploadType.GIT_ROOT) {
            channel.addListener(() -> repositoryHashService.resetMetadataHash(repository));
        } else if (type == UploadType.GIT_OBJECTS && !repository.equals(destination)) {
            //We've uploaded the object or pack to shared-objects; raise an event to ensure it is protected from gc
            channel.addListener(() -> eventPublisher.publish(
                    new ObjectsUploadedToSharedObjectsEvent((ManagedRepository) repository)));
        } else if (type == UploadType.GIT_LOG) {
            //We've uploaded a reflog; raise an event to ensure the objects it references are protected from gc
            channel.addListener(() -> eventPublisher.publish(new RefsUpdatedEvent(repository)));
        }
        return channel;
    }

    private boolean wantsFile(Repository repository, UploadFileParameters parameters) {
        Path path = getPath(repository, parameters);

        try {
            BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);

            if (attributes.isDirectory()) {
                log.debug("[{}] Rejecting {} upload to {} because a directory exists at the destination path",
                        parameters.getRepository(), parameters.getType(), parameters.getPath());
                return false;
            }

            //A file exists at the target location, decide whether we want to overwrite it or not
            switch (parameters.getType()) {
                case GIT_LFS:
                case GIT_OBJECTS:
                    //For LFS, regular git objects and packs, we only overwrite if an upload size was specified that
                    //differs from the size of the file on disk
                    return attributes.size() != parameters.getSize();
                case GIT_DARK_REFS:
                case GIT_LOG:
                case GIT_REFS:
                case GIT_ROOT:
                    //Otherwise, only overwrite if the uploaded lastModified date is newer, or the file size differs
                    return parameters.getLastModifiedTime().isAfter(attributes.lastModifiedTime().toInstant()) ||
                            //Overwrite if the provided file size did not match
                            attributes.size() != parameters.getSize();
                default:
                    throw new IllegalArgumentException("Unsupported UploadType " + parameters.getType());
            }
        } catch (FileNotFoundException | NoSuchFileException e) {
            //There is no file at the target location, let the upload proceed
            return true;
        } catch (IOException e) {
            log.warn("[{}] Rejecting {} upload to {} because the destination path cannot be accessed",
                    parameters.getRepository(), parameters.getType(), parameters.getPath(), e);
            return false;
        }
    }

    private static class UploadFileWritableByteChannel extends ListenableWritableByteChannel {

        private final Path destinationPath;
        private final UploadFileParameters parameters;
        private final Path tmpPath;

        UploadFileWritableByteChannel(WritableByteChannel delegate, UploadFileParameters parameters,
                                      Path tmpPath, Path destinationPath) {
            super(delegate);

            this.destinationPath = destinationPath;
            this.parameters = parameters;
            this.tmpPath = tmpPath;

            addListener(() -> {
                moveToDestinationPath();
                setFileTimestamps();
            });
        }

        private void moveToDestinationPath() {
            try {
                Files.move(tmpPath, destinationPath, StandardCopyOption.REPLACE_EXISTING);
            } catch (IOException e) {
                log.warn("Failed to move {} to {}", tmpPath, destinationPath, e);
                MoreFiles.deleteQuietly(tmpPath);
                throw new InternalFailureException("Upload of " + parameters.getType() + " to " + parameters.getPath()
                        + " failed");
            }
        }

        private void setFileTimestamps() {
            BasicFileAttributeView attributeView =
                    Files.getFileAttributeView(destinationPath, BasicFileAttributeView.class);
            if (attributeView != null) {
                try {
                    FileTime lastModifiedTime = FileTime.from(parameters.getLastModifiedTime());
                    attributeView.setTimes(lastModifiedTime, lastModifiedTime, lastModifiedTime);
                } catch (IOException e) {
                    log.warn("[{}] Could not update file times for {}", parameters.getRepository(), destinationPath, e);
                }
            }
        }
    }
}
