﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

#nullable disable

using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Editor.Undo;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Notification;
using Microsoft.CodeAnalysis.Rename;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Operations;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.Editor.Implementation.RenameTracking
{
    internal sealed partial class RenameTrackingTaggerProvider
    {
        private class RenameTrackingCommitter
        {
            private readonly StateMachine _stateMachine;
            private readonly SnapshotSpan _snapshotSpan;
            private readonly IEnumerable<IRefactorNotifyService> _refactorNotifyServices;
            private readonly ITextUndoHistoryRegistry _undoHistoryRegistry;
            private readonly string _displayText;
            private readonly AsyncLazy<RenameTrackingSolutionSet> _renameSymbolResultGetter;

            public RenameTrackingCommitter(
                StateMachine stateMachine,
                SnapshotSpan snapshotSpan,
                IEnumerable<IRefactorNotifyService> refactorNotifyServices,
                ITextUndoHistoryRegistry undoHistoryRegistry,
                string displayText)
            {
                _stateMachine = stateMachine;
                _snapshotSpan = snapshotSpan;
                _refactorNotifyServices = refactorNotifyServices;
                _undoHistoryRegistry = undoHistoryRegistry;
                _displayText = displayText;
                _renameSymbolResultGetter = AsyncLazy.Create(c => RenameSymbolWorkerAsync(c));
            }

            /// <summary>
            /// Returns non-null error message if renaming fails.
            /// </summary>
            public async Task<(NotificationSeverity severity, string message)?> TryCommitAsync(CancellationToken cancellationToken)
            {
                _stateMachine.ThreadingContext.ThrowIfNotOnUIThread();

                try
                {
                    return await TryApplyChangesToWorkspaceAsync(cancellationToken).ConfigureAwait(false);
                }
                finally
                {
                    // Clear the state machine so that future updates to the same token work, and any text changes
                    // caused by this update are not interpreted as potential renames.  Intentionally pass
                    // CancellationToken.None.  We must clear this state out.
                    await _stateMachine.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken.None);
                    _stateMachine.ClearTrackingSession();
                }
            }

            public async Task<RenameTrackingSolutionSet> RenameSymbolAsync(CancellationToken cancellationToken)
                => await _renameSymbolResultGetter.GetValueAsync(cancellationToken).ConfigureAwait(false);

            private async Task<RenameTrackingSolutionSet> RenameSymbolWorkerAsync(CancellationToken cancellationToken)
            {
                var document = _snapshotSpan.Snapshot.GetOpenDocumentInCurrentContextWithChanges();
                var newName = _snapshotSpan.GetText();

                Contract.ThrowIfNull(document, "Invoked rename tracking smart tag but cannot find the document for the snapshot span.");

                // Get copy of solution with the original name in the place of the renamed name
                var solutionWithOriginalName = await CreateSolutionWithOriginalNameAsync(
                    document, cancellationToken).ConfigureAwait(false);

                var symbol = await TryGetSymbolAsync(solutionWithOriginalName, document.Id, cancellationToken).ConfigureAwait(false);
                Contract.ThrowIfNull(symbol, "Invoked rename tracking smart tag but cannot find the symbol.");

                var options = new SymbolRenameOptions(RenameOverloads: _stateMachine.TrackingSession.ForceRenameOverloads);
                var renamedSolution = await Renamer.RenameSymbolAsync(solutionWithOriginalName, symbol, options, newName, cancellationToken).ConfigureAwait(false);
                return new RenameTrackingSolutionSet(symbol, solutionWithOriginalName, renamedSolution);
            }

            /// <summary>
            /// Returns non-null error message if renaming fails.
            /// </summary>
            private async Task<(NotificationSeverity, string)?> TryApplyChangesToWorkspaceAsync(CancellationToken cancellationToken)
            {
                // Now that the necessary work has been done to create the intermediate and final
                // solutions during PreparePreview, check one more time for cancellation before making all of the
                // workspace changes.
                cancellationToken.ThrowIfCancellationRequested();

                // Undo must backtrack to the state with the original identifier before the state
                // with the user-edited identifier. For example,
                // 
                //   1. Original:                           void M() { M(); }
                //   2. User types:                         void Method() { M(); }
                //   3. Invoke rename:                      void Method() { Method(); }
                // 
                // The undo process should be as follows
                //   1. Back to original name everywhere:   void M() { M(); }       // No tracking session
                //   2. Back to state 2 above:              void Method() { M(); }  // Resume tracking session
                //   3. Finally, start undoing typing:      void M() { M(); }
                //
                // As far as the user can see, undo state 1 never actually existed so we must insert
                // a state here to facilitate the undo. Do the work to obtain the intermediate and
                // final solution without updating the workspace, and then finally disallow
                // cancellation and update the workspace twice.

                var renameTrackingSolutionSet = await RenameSymbolAsync(cancellationToken).ConfigureAwait(false);

                var document = _snapshotSpan.Snapshot.GetOpenDocumentInCurrentContextWithChanges();
                var newName = _snapshotSpan.GetText();

                var workspace = document.Project.Solution.Workspace;

                // Since the state machine is only watching buffer changes, it will interpret the 
                // text changes caused by undo and redo actions as potential renames, so carefully
                // update the state machine after undo/redo actions. 

                var changedDocuments = renameTrackingSolutionSet.RenamedSolution.GetChangedDocuments(renameTrackingSolutionSet.OriginalSolution);
                try
                {
                    // When this action is undone (the user has undone twice), restore the state
                    // machine to so that they can continue their original rename tracking session.

                    await _stateMachine.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
                    var trackingSessionId = _stateMachine.StoreCurrentTrackingSessionAndGenerateId();
                    var result = TryUpdateWorkspaceForResetOfTypedIdentifier(workspace, renameTrackingSolutionSet.OriginalSolution, trackingSessionId);
                    if (result is not null)
                        return result;

                    // Now that the solution is back in its original state, notify third parties about
                    // the coming rename operation.
                    if (!_refactorNotifyServices.TryOnBeforeGlobalSymbolRenamed(workspace, changedDocuments, renameTrackingSolutionSet.Symbol, newName, throwOnFailure: false))
                        return (NotificationSeverity.Error, EditorFeaturesResources.Rename_operation_was_cancelled_or_is_not_valid);

                    // move all changes to final solution based on the workspace's current solution, since the current solution
                    // got updated when we reset it above.
                    var finalSolution = workspace.CurrentSolution;
                    foreach (var docId in changedDocuments)
                    {
                        // because changes have already been made to the workspace (UpdateWorkspaceForResetOfTypedIdentifier() above),
                        // these calls can't be cancelled and must be allowed to complete.
                        var root = await renameTrackingSolutionSet.RenamedSolution.GetDocument(docId).GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
                        finalSolution = finalSolution.WithDocumentSyntaxRoot(docId, root);
                    }

                    // Undo/redo on this action must always clear the state machine
                    await _stateMachine.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
                    return TryUpdateWorkspaceForGlobalIdentifierRename(
                        workspace,
                        finalSolution,
                        _displayText,
                        changedDocuments,
                        renameTrackingSolutionSet.Symbol,
                        newName,
                        trackingSessionId);
                }
                finally
                {
                    // Explicit CancellationToken.None here.  We must clean up our state no matter what.
                    await _stateMachine.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken.None);
                    RenameTrackingDismisser.DismissRenameTracking(workspace, changedDocuments);
                }
            }

            private async Task<Solution> CreateSolutionWithOriginalNameAsync(
                Document document, CancellationToken cancellationToken)
            {
                var syntaxTree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
                var fullText = syntaxTree.GetText(cancellationToken);
                var textChange = new TextChange(new TextSpan(_snapshotSpan.Start, _snapshotSpan.Length), _stateMachine.TrackingSession.OriginalName);

                var newFullText = fullText.WithChanges(textChange);
#if DEBUG
                var syntaxTreeWithOriginalName = syntaxTree.WithChangedText(newFullText);
                var documentWithOriginalName = document.WithSyntaxRoot(syntaxTreeWithOriginalName.GetRoot(cancellationToken));

                Debug.Assert(newFullText.ToString() == documentWithOriginalName.GetTextSynchronously(cancellationToken).ToString());
#endif

                // Apply the original name to all linked documents to construct a consistent solution
                var solution = document.Project.Solution;
                foreach (var documentId in document.GetLinkedDocumentIds().Add(document.Id))
                {
                    solution = solution.WithDocumentText(documentId, newFullText);
                }

                return solution;
            }

            private async Task<ISymbol> TryGetSymbolAsync(Solution solutionWithOriginalName, DocumentId documentId, CancellationToken cancellationToken)
            {
                var documentWithOriginalName = solutionWithOriginalName.GetDocument(documentId);
                var syntaxTreeWithOriginalName = await documentWithOriginalName.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);

                var syntaxFacts = documentWithOriginalName.GetLanguageService<ISyntaxFactsService>();
                var semanticFacts = documentWithOriginalName.GetLanguageService<ISemanticFactsService>();
                var semanticModel = await documentWithOriginalName.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);

                var token = await syntaxTreeWithOriginalName.GetTouchingWordAsync(_snapshotSpan.Start, syntaxFacts, cancellationToken).ConfigureAwait(false);
                var tokenRenameInfo = RenameUtilities.GetTokenRenameInfo(semanticFacts, semanticModel, token, cancellationToken);

                return tokenRenameInfo.HasSymbols ? tokenRenameInfo.Symbols.First() : null;
            }

            /// <summary>
            /// Returns non-null error message if renaming fails.
            /// </summary>
            private (NotificationSeverity, string)? TryUpdateWorkspaceForResetOfTypedIdentifier(Workspace workspace, Solution newSolution, int trackingSessionId)
            {
                _stateMachine.ThreadingContext.ThrowIfNotOnUIThread();

                // Update document in an ITextUndoTransaction with custom behaviors on undo/redo to
                // deal with the state machine.

                var undoHistory = _undoHistoryRegistry.RegisterHistory(_stateMachine.Buffer);
                using var localUndoTransaction = undoHistory.CreateTransaction(EditorFeaturesResources.Text_Buffer_Change);

                var undoPrimitiveBefore = new UndoPrimitive(_stateMachine.Buffer, trackingSessionId, shouldRestoreStateOnUndo: true);
                localUndoTransaction.AddUndo(undoPrimitiveBefore);

                if (!workspace.TryApplyChanges(newSolution))
                    return (NotificationSeverity.Error, EditorFeaturesResources.Rename_operation_could_not_complete_due_to_external_change_to_workspace);

                // If we successfully updated the workspace then make sure the undo transaction is committed and is
                // always able to undo anything any other external listener did.

                // Never resume tracking session on redo
                var undoPrimitiveAfter = new UndoPrimitive(_stateMachine.Buffer, trackingSessionId, shouldRestoreStateOnUndo: false);
                localUndoTransaction.AddUndo(undoPrimitiveAfter);

                localUndoTransaction.Complete();

                return null;
            }

            /// <summary>
            /// Returns non-null error message if renaming fails.
            /// </summary>
            private (NotificationSeverity, string)? TryUpdateWorkspaceForGlobalIdentifierRename(
                Workspace workspace,
                Solution newSolution,
                string undoName,
                IEnumerable<DocumentId> changedDocuments,
                ISymbol symbol,
                string newName,
                int trackingSessionId)
            {
                _stateMachine.ThreadingContext.ThrowIfNotOnUIThread();

                // Perform rename in a workspace undo action so that undo will revert all 
                // references. It should also be performed in an ITextUndoTransaction to handle 

                var undoHistory = _undoHistoryRegistry.RegisterHistory(_stateMachine.Buffer);

                using var workspaceUndoTransaction = workspace.OpenGlobalUndoTransaction(undoName);
                using var localUndoTransaction = undoHistory.CreateTransaction(undoName);

                var undoPrimitiveBefore = new UndoPrimitive(_stateMachine.Buffer, trackingSessionId, shouldRestoreStateOnUndo: false);
                localUndoTransaction.AddUndo(undoPrimitiveBefore);

                if (!workspace.TryApplyChanges(newSolution))
                    return (NotificationSeverity.Error, EditorFeaturesResources.Rename_operation_could_not_complete_due_to_external_change_to_workspace);

                try
                {
                    if (!_refactorNotifyServices.TryOnAfterGlobalSymbolRenamed(workspace, changedDocuments, symbol, newName, throwOnFailure: false))
                        return (NotificationSeverity.Information, EditorFeaturesResources.Rename_operation_was_not_properly_completed_Some_file_might_not_have_been_updated);

                    return null;
                }
                finally
                {
                    // If we successfully updated the workspace then make sure the undo transaction is committed and is
                    // always able to undo anything any other external listener did.

                    // Never resume tracking session on redo
                    var undoPrimitiveAfter = new UndoPrimitive(_stateMachine.Buffer, trackingSessionId, shouldRestoreStateOnUndo: false);
                    localUndoTransaction.AddUndo(undoPrimitiveAfter);

                    localUndoTransaction.Complete();
                    workspaceUndoTransaction.Commit();
                }
            }
        }
    }
}
