// Copyright 2008-2010 Victor Iacoban
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software distributed under
// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
// either express or implied. See the License for the specific language governing permissions and
// limitations under the License.
package org.zmlx.hg4idea.provider.update;

import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vcs.update.FileGroup;
import com.intellij.openapi.vcs.update.UpdatedFiles;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.zmlx.hg4idea.*;
import org.zmlx.hg4idea.action.HgCommandResultNotifier;
import org.zmlx.hg4idea.command.*;
import org.zmlx.hg4idea.execution.HgCommandException;
import org.zmlx.hg4idea.execution.HgCommandResult;
import org.zmlx.hg4idea.repo.HgRepository;
import org.zmlx.hg4idea.util.HgErrorUtil;
import org.zmlx.hg4idea.util.HgUtil;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

import static org.zmlx.hg4idea.HgNotificationIdsHolder.REBASE_CONTINUE_ERROR;
import static org.zmlx.hg4idea.HgNotificationIdsHolder.REBASE_ERROR;
import static org.zmlx.hg4idea.provider.update.HgUpdateType.MERGE;
import static org.zmlx.hg4idea.provider.update.HgUpdateType.ONLY_UPDATE;
import static org.zmlx.hg4idea.util.HgErrorUtil.ensureSuccess;

public class HgRegularUpdater implements HgUpdater {

  private final @NotNull Project project;
  private final @NotNull VirtualFile repoRoot;
  private final @NotNull HgUpdateConfigurationSettings updateConfiguration;
  private static final Logger LOG = Logger.getInstance(HgRegularUpdater.class);

  public HgRegularUpdater(@NotNull Project project, @NotNull VirtualFile repository, @NotNull HgUpdateConfigurationSettings configuration) {
    this.project = project;
    this.repoRoot = repository;
    this.updateConfiguration = configuration;
  }

  @Override
  public boolean update(final UpdatedFiles updatedFiles, ProgressIndicator indicator, List<VcsException> warnings)
    throws VcsException {
    indicator.setText(HgBundle.message("hg4idea.progress.updating", repoRoot.getPath()));

    String defaultPath = HgUtil.getRepositoryDefaultPath(project, repoRoot);

    if (StringUtil.isEmptyOrSpaces(defaultPath)) {
      throw new VcsException(HgBundle.message("hg4idea.warning.no-default-update-path", repoRoot.getPath()));
    }


    List<HgRevisionNumber> branchHeadsBeforePull = new HgHeadsCommand(project, repoRoot).executeInCurrentThread();

    if (branchHeadsBeforePull.size() > 1) {
      reportWarning(warnings, HgBundle.message("hg4idea.update.warning.multipleHeadsBeforeUpdate", repoRoot.getPath()));
    }

    //TODO perhaps report a warning in this case ?
//    //if the parent of the working dir is not the tip of the current branch, the user has
//    //manually brought his working copy to some specific revision. In that case we won't touch
//    //his setup
//    if (!parentRevision.equals(currentBranchHead)) {
//      throw new VcsException("working dir not at branch tip (use \"Update to...\" to check out branch tip)");
//    }

    if (updateConfiguration.shouldPull()) {
      HgCommandExitCode pullResult = pull(repoRoot, indicator);
      if (pullResult == HgCommandExitCode.ERROR) {
        return false;
      }
    }

    List<HgRevisionNumber> parentsBeforeUpdate = new HgWorkingCopyRevisionsCommand(project).parents(repoRoot);
    if (parentsBeforeUpdate.size() > 1) {
      throw new VcsException(HgBundle.message("hg4idea.update.error.uncommittedMerge", repoRoot.getPath()));
    }

    indicator.setText2(HgBundle.message("hg4idea.progress.countingHeads"));

    List<HgRevisionNumber> branchHeadsAfterPull = new HgHeadsCommand(project, repoRoot).executeInCurrentThread();
    List<HgRevisionNumber> pulledBranchHeads = determinePulledBranchHeads(branchHeadsBeforePull, branchHeadsAfterPull);
    List<HgRevisionNumber> remainingOriginalBranchHeads =
      determingRemainingOriginalBranchHeads(branchHeadsBeforePull, branchHeadsAfterPull);
    HgUpdateType updateType = updateConfiguration.getUpdateType();

    if (branchHeadsAfterPull.size() > 1 && updateType != ONLY_UPDATE) {
      // merge strategy
      if (updateType == MERGE) {
        abortOnLocalChanges();
        abortOnMultiplePulledHeads(pulledBranchHeads);
        abortOnMultipleLocalHeads(remainingOriginalBranchHeads);

        HgCommandResult mergeResult = doMerge(indicator);

        if (updateConfiguration.shouldCommitAfterMerge()) {
          commitOrWarnAboutConflicts(warnings, mergeResult);
        }
      }
      //rebase strategy
      else {
        processRebase(indicator, updatedFiles);  //resolve conflicts processed during rebase
        return true;
      }
    }
    //if pull complete successfully and there are only one head, we need just update working directory to the head
    else {
      //in case of multiple heads the update will report the appropriate error
      update(repoRoot, indicator, updatedFiles, warnings);
    }
    //any kind of update could have resulted in merges and merge conflicts, so run the resolver
    resolvePossibleConflicts(updatedFiles);

    return true;
  }

  private static List<HgRevisionNumber> determingRemainingOriginalBranchHeads(List<HgRevisionNumber> branchHeadsBeforePull,
                                                                              List<HgRevisionNumber> branchHeadsAfterPull) {
    List<HgRevisionNumber> originalBranchHeadsRemaining = new ArrayList<>();
    for (HgRevisionNumber headAfterPull : branchHeadsAfterPull) {
      if (branchHeadsBeforePull.contains(headAfterPull)) {
        originalBranchHeadsRemaining.add(headAfterPull);
      }
    }
    return originalBranchHeadsRemaining;
  }

  private static List<HgRevisionNumber> determinePulledBranchHeads(List<HgRevisionNumber> branchHeadsBeforePull,
                                                                   List<HgRevisionNumber> branchHeadsAfterPull) {
    List<HgRevisionNumber> pulledBranchHeads = new ArrayList<>(branchHeadsAfterPull);
    pulledBranchHeads.removeAll(branchHeadsBeforePull);
    return pulledBranchHeads;
  }

  private void abortOnMultipleLocalHeads(List<HgRevisionNumber> originalBranchHeadsRemaining) throws VcsException {
    if (originalBranchHeadsRemaining.size() != 1) {
      throw new VcsException(HgBundle.message("hg4idea.update.error.merge.multipleLocalHeads", repoRoot.getPath()));
    }
  }

  private void abortOnMultiplePulledHeads(List<HgRevisionNumber> newBranchHeadsAfterPull) throws VcsException {
    if (newBranchHeadsAfterPull.size() != 1) {
      throw new VcsException(HgBundle.message("hg4idea.update.error.merge.multipleRemoteHeads", newBranchHeadsAfterPull.size(),
                                              repoRoot.getPath()));
    }
  }

  private void updateToPulledHead(VirtualFile repo, UpdatedFiles updatedFiles, HgRevisionNumber newHead, ProgressIndicator indicator) {
    indicator.setText2(HgBundle.message("hg4idea.update.progress.updating.to.pulled.head"));
    HgRevisionNumber parentBeforeUpdate = new HgWorkingCopyRevisionsCommand(project).firstParent(repo);
    HgUpdateCommand updateCommand = new HgUpdateCommand(project, repoRoot);
    updateCommand.setRevision(newHead.getChangeset());
    updateCommand.setClean(true);
    updateCommand.execute();

    HgRevisionNumber commonParent = findCommonParent(newHead, parentBeforeUpdate);
    addUpdatedFiles(repo, updatedFiles, commonParent, newHead);
  }

  private @Nullable HgRevisionNumber findCommonParent(HgRevisionNumber newHead, HgRevisionNumber parentBeforeUpdate) {
    // hg log -r 0:source --prune dest --limit 1
    final List<HgRevisionNumber> pulledRevisions = new HgMergePreviewCommand(project, newHead, parentBeforeUpdate, 1).executeInCurrentThread(repoRoot);
    if (pulledRevisions == null || pulledRevisions.isEmpty()) {
      return null;
    }
    HgRevisionNumber pulledRevision = pulledRevisions.get(0);
    final List<HgRevisionNumber> parentRevisions = new HgWorkingCopyRevisionsCommand(project).getRevisions(repoRoot, "parent", null, pulledRevision, true);
    if (parentRevisions.isEmpty()) {
      return null;
    }
    return parentRevisions.get(0);
  }

  private void commitOrWarnAboutConflicts(List<VcsException> exceptions, HgCommandResult mergeResult) throws VcsException {
    if (mergeResult.getExitValue() == 0) { //operation successful and no conflicts
      try {
        HgRepository hgRepository = HgUtil.getRepositoryForFile(project, repoRoot);
        if (hgRepository == null) {
          LOG.warn("Couldn't find repository info for " + repoRoot.getName());
          return;
        }
        new HgCommitCommand(project, hgRepository, "Automated merge").executeInCurrentThread(); //NON-NLS
      }
      catch (HgCommandException e) {
        throw new VcsException(e);
      }
    }
    else {
      reportWarning(exceptions, HgBundle.message("hg4idea.update.warning.merge.conflicts", repoRoot.getPath()));
    }
  }

  private HgCommandResult doMerge(ProgressIndicator indicator) throws VcsException {
    indicator.setText2(HgBundle.message("hg4idea.update.progress.merging"));
    HgRepository repository = HgUtil.getRepositoryManager(project).getRepositoryForRoot(repoRoot);
    if (repository == null) {
      LOG.error("Couldn't find repository for " + repoRoot.getName());
      return null;
    }
    HgMergeCommand mergeCommand = new HgMergeCommand(project, repository);
    //do not explicitly set the revision, that way mercurial itself checks that there are exactly
    //two heads in this branch
    //    mergeCommand.setRevision(headToMerge.getRevision());
    return mergeCommand.mergeSynchronously();
  }

  private void processRebase(ProgressIndicator indicator, final UpdatedFiles updatedFiles) throws VcsException {
    indicator.setText2(HgBundle.message("hg4idea.progress.rebase"));
    HgRepository repository = HgUtil.getRepositoryManager(project).getRepositoryForRoot(repoRoot);
    if (repository == null) {
      throw new VcsException(HgBundle.message("error.cannot.find.repository.for.file", repoRoot.getPresentableUrl()));
    }
    HgRebaseCommand rebaseCommand = new HgRebaseCommand(project, repository);
    HgCommandResult result = new HgRebaseCommand(project, repository).startRebase();
    if (HgErrorUtil.isCommandExecutionFailed(result)) {
      new HgCommandResultNotifier(project).notifyError(REBASE_ERROR,
                                                       result,
                                                       HgBundle.message("hg4idea.hg.error"),
                                                       HgBundle.message("action.hg4idea.Rebase.error"));
      return;
    }
    //noinspection ConstantConditions
    while (result.getExitValue() == 1) {    //if result == null isAbort will be true;
      resolvePossibleConflicts(updatedFiles);
      if (HgConflictResolver.hasConflicts(project, repoRoot) || HgErrorUtil.isNothingToRebase(result)) {
        break;
      }
      result = rebaseCommand.continueRebase();
      if (HgErrorUtil.isAbort(result)) {
        new HgCommandResultNotifier(project).notifyError(REBASE_CONTINUE_ERROR,
                                                         result,
                                                         HgBundle.message("hg4idea.hg.error"),
                                                         HgBundle.message("action.hg4idea.Rebase.Continue.error"));
        break;
      }
    }
    repository.update();
    repoRoot.refresh(true, true);
  }

  private void abortOnLocalChanges() throws VcsException {
    if (!getLocalChanges().isEmpty()) {
      throw new VcsException(HgBundle.message("hg4idea.update.error.localchanges", repoRoot.getPath()));
    }
  }

  private void resolvePossibleConflicts(final UpdatedFiles updatedFiles) {
    new HgConflictResolver(project, updatedFiles).resolve(repoRoot);
  }

  private Set<HgChange> getLocalChanges() {
    HgStatusCommand statusCommand = new HgStatusCommand.Builder(true).unknown(false).ignored(false).build(project);
    return statusCommand.executeInCurrentThread(repoRoot);
  }

  private HgCommandExitCode pull(@NotNull VirtualFile repo, @NotNull ProgressIndicator indicator) {
    indicator.setText2(HgBundle.message("hg4idea.progress.pull.with.update"));
    HgPullCommand hgPullCommand = new HgPullCommand(project, repo);
    final String defaultPath = HgUtil.getRepositoryDefaultPath(project, repo);
    hgPullCommand.setSource(defaultPath);
    return hgPullCommand.executeInCurrentThread();
  }

  private void update(@NotNull VirtualFile repo, ProgressIndicator indicator, UpdatedFiles updatedFiles, List<VcsException> warnings) throws VcsException {
    indicator.setText2(HgBundle.message("hg4idea.progress.updatingworkingdir"));

    HgRevisionNumber parentBeforeUpdate = new HgWorkingCopyRevisionsCommand(project).firstParent(repo);
    HgUpdateCommand hgUpdateCommand = new HgUpdateCommand(project, repo);
    HgCommandResult updateResult = hgUpdateCommand.execute();
    String warningMessages = ensureSuccess(updateResult).getRawError();
    handlePossibleWarning(warnings, warningMessages);

    HgRevisionNumber parentAfterUpdate = new HgWorkingCopyRevisionsCommand(project).firstParent(repo);

    addUpdatedFiles(repo, updatedFiles, parentBeforeUpdate, parentAfterUpdate);
  }

  private static void handlePossibleWarning(List<VcsException> exceptions, @Nls String possibleWarning) {
    if (!StringUtil.isEmptyOrSpaces(possibleWarning)) {
      reportWarning(exceptions, possibleWarning);
    }
  }

  private static void reportWarning(List<VcsException> exceptions, @Nls String warningMessage) {
    VcsException warningException = new VcsException(warningMessage);
    warningException.setIsWarning(true);
    exceptions.add(warningException);
  }

  private void addUpdatedFiles(VirtualFile repo, UpdatedFiles updatedFiles, HgRevisionNumber parentBeforeUpdate, HgRevisionNumber parentAfterUpdate) {
    if (parentAfterUpdate == null || parentBeforeUpdate == null) {
      return;
    }
    if (parentAfterUpdate.equals(parentBeforeUpdate)) { // nothing to update => returning not to capture local uncommitted changes
      return;
    }
    HgStatusCommand statusCommand = new HgStatusCommand.Builder(true).ignored(false).unknown(false).baseRevision(parentBeforeUpdate).targetRevision(
      parentAfterUpdate).build(project);
    Set<HgChange> changes = statusCommand.executeInCurrentThread(repo);
    for (HgChange change : changes) {
      HgFileStatusEnum status = change.getStatus();
      switch (status) {
        case ADDED -> addToGroup(updatedFiles, change, FileGroup.CREATED_ID);
        case MODIFIED -> addToGroup(updatedFiles, change, FileGroup.UPDATED_ID);
        case DELETED -> addToGroup(updatedFiles, change, FileGroup.REMOVED_FROM_REPOSITORY_ID);
        case COPY -> addToGroup(updatedFiles, change, FileGroup.CHANGED_ON_SERVER_ID);
        default -> {
          //do nothing
        }
      }
    }
  }

  private static void addToGroup(UpdatedFiles updatedFiles, HgChange change, String id) {
    updatedFiles.getGroupById(id).add(change.afterFile().getFile().getAbsolutePath(), HgVcs.VCS_NAME, null);
  }
}
