package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.AbstractStatusException;
import com.atlassian.bitbucket.mesh.git.commit.Commit;
import com.atlassian.bitbucket.mesh.git.exception.*;
import com.atlassian.bitbucket.mesh.git.file.EditFileManager;
import com.atlassian.bitbucket.mesh.git.file.EditFileParameters;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.RefManager;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.RepositoryManager;
import com.atlassian.bitbucket.mesh.rpc.v1.*;
import com.atlassian.bitbucket.mesh.util.Person;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

class EditFileRequestObserver implements StreamObserver<RpcEditFileFragment> {

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

    private final EditFileManager editFileManager;
    private final RefManager refManager;
    private final RepositoryManager repositoryManager;
    private final StreamObserver<RpcEditFileResponseFragment> responseObserver;
    private final Path tempDir;

    private volatile boolean completed;

    private String commitId;
    private Path content;
    private FileChannel contentWriter;
    private EditFileParameters parameters;
    private Repository repository;

    EditFileRequestObserver(EditFileManager editFileManager, RefManager refManager,
                            RepositoryManager repositoryManager,
                            StreamObserver<RpcEditFileResponseFragment> responseObserver, Path tempDir) {
        this.editFileManager = editFileManager;
        this.refManager = refManager;
        this.repositoryManager = repositoryManager;
        this.responseObserver = responseObserver;
        this.tempDir = tempDir;
    }

    @Override
    public void onCompleted() {
        cleanup();
    }

    @Override
    public void onError(Throwable t) {
        if (Status.fromThrowable(t).getCode() == Status.Code.CANCELLED) {
            //Log cancellation more quietly; it's not an error for the client to cancel
            log.debug("The interactive client has canceled the request");
        } else {
            log.error("Canceling the process; the interactive client has failed", t);
        }

        cleanup();
    }

    @Override
    public void onNext(RpcEditFileFragment fragment) {
        try {
            switch (fragment.getRequestOneofCase()) {
                case CONTENT:
                    handleContent(fragment.getContent());
                    break;
                case REQUEST:
                    handleRequest(fragment.getRequest());
                    break;
                case UPDATE_REF:
                    handleUpdateRef();
                    break;
                default:
                    throw new InvalidArgumentException("[" + fragment.getRequestOneofCase() + "] is not supported");
            }
        } catch (RuntimeException e) {
            if (e instanceof AbstractStatusException) {
                e = ((AbstractStatusException) e).toStatusException();
            }

            completed = true; //cleanup() doesn't need to complete the RPC
            responseObserver.onError(e);
        }
    }

    private EditFileParameters buildEditFileParameters(RpcEditFileRequest request) {
        repository = repositoryManager.getById(request.getRepository());

        EditFileParameters.Builder builder = new EditFileParameters.Builder(repository, request.getTimeouts())
                .author(new Person(request.getAuthor()))
                .committer(new Person(request.hasCommitter() ? request.getCommitter() : request.getAuthor()))
                .message(request.getMessage().toStringUtf8())
                .path(request.getPath().toStringUtf8())
                .sourceCommitId(request.getSourceCommitish().toStringUtf8())
                .targetBranch(request.getTargetBranch().toStringUtf8());
        if (request.hasBranch()) {
            builder.branch(Branch.fromBranch(request.getBranch()));
        }

        return builder.build();
    }

    /**
     * Ensures the {@link #contentWriter} is closed and deletes the temporary {@link #content} file, if it exists.
     * Any cleanup for the worktree used to perform the file edit is expected to be done by {@link EditFileManager}.
     */
    private void cleanup() {
        if (contentWriter != null) {
            try {
                contentWriter.close();
                contentWriter = null;
            } catch (IOException e) {
                log.warn("Failed to close temporary content file", e);
            }
        }

        if (content != null) {
            if (!MoreFiles.deleteQuietly(content)) {
                log.warn("Failed to delete temporary content file; will try to delete on exit");
                MoreFiles.deleteOnExit(content);
            }
            content = null;
        }

        if (!completed) {
            //If the client doesn't send an RpcUpdateRef response (or if it cancels or calls onError),
            //we can end up not completing the server-side part of the RPC. This detects that case and
            //completes the RPC so it doesn't linger until its deadline
            responseObserver.onCompleted();
        }
    }

    private void handleContent(RpcFileContent fileContent) {
        if (contentWriter == null || repository == null) {
            throw new InvalidArgumentException("An EditFileRequest must be sent before content");
        }
        if (commitId != null) {
            throw new InvalidArgumentException("Additional content cannot be sent after close");
        }

        if (writeContent(fileContent)) {
            //Once we've written the final chunk of FileContent, perform the edit and create the new commit
            commitId = editFileManager.editFile(parameters, content);

            //Notify the client that the new commit has been created and allow the client to run hooks
            responseObserver.onNext(RpcEditFileResponseFragment.newBuilder()
                    .setRunHooks(RpcRunEditFileHooks.newBuilder()
                            .setRepository(repository.getId())
                            .setCommitId(commitId))
                    .build());
        }
    }

    private void handleRequest(RpcEditFileRequest editFileRequest) {
        if (parameters != null) {
            throw new InvalidArgumentException("Only a single EditFileRequest may be sent");
        }

        //If the source and target branches are different it may indicate a branch creation, so validate that the
        //target branch name is valid (performing the same checks as branch creation), to avoid any surprises later.
        String targetBranch = editFileRequest.getTargetBranch().toStringUtf8();
        if (!editFileRequest.hasBranch() || !editFileRequest.getBranch().getId().toStringUtf8().equals(targetBranch)) {
            refManager.validateId(targetBranch);
        }

        parameters = buildEditFileParameters(editFileRequest);

        try {
            content = Files.createTempFile(tempDir, "edit-file", ".tmp");
            contentWriter = FileChannel.open(content, StandardOpenOption.WRITE);
        } catch (IOException e) {
            throw new InternalFailureException("Failed to create temp file to receive file contents", e);
        }
    }

    private void handleUpdateRef() {
        if (parameters == null) {
            throw new InvalidArgumentException("An EditFileRequest must be sent before a ref can be updated");
        }
        if (contentWriter != null) {
            throw new InvalidArgumentException("File content must be closed before a ref can be updated");
        }

        Commit commit = editFileManager.updateRef(parameters, commitId);
        responseObserver.onNext(RpcEditFileResponseFragment.newBuilder()
                .setResponse(RpcEditFileResponse.newBuilder()
                        .setCommit(commit.toCommit()))
                .build());

        completed = true; //cleanup() doesn't need to complete the RPC
        responseObserver.onCompleted();
    }

    private boolean writeContent(RpcFileContent fileContent) {
        try {
            for (ByteBuffer content : fileContent.getData().asReadOnlyByteBufferList()) {
                if (content.hasRemaining()) {
                    contentWriter.write(content);
                }
            }

            if (fileContent.getClosed()) {
                contentWriter.close();
                contentWriter = null;

                return true;
            }

            return false;
        } catch (IOException e) {
            throw new InternalFailureException("Failed to write file contents", e);
        }
    }
}
