﻿// 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.

using System;
using System.Threading;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.Text.Operations;

namespace Microsoft.CodeAnalysis.DocumentationComments
{
    internal abstract class AbstractXmlTagCompletionCommandHandler<
        TXmlNameSyntax,
        TXmlTextSyntax,
        TXmlElementSyntax,
        TXmlElementStartTagSyntax,
        TXmlElementEndTagSyntax,
        TDocumentationCommentTriviaSyntax>
        (ITextUndoHistoryRegistry undoHistory) : IChainedCommandHandler<TypeCharCommandArgs>
        where TXmlNameSyntax : SyntaxNode
        where TXmlTextSyntax : SyntaxNode
        where TXmlElementSyntax : SyntaxNode
        where TXmlElementStartTagSyntax : SyntaxNode
        where TXmlElementEndTagSyntax : SyntaxNode
        where TDocumentationCommentTriviaSyntax : SyntaxNode
    {
        private readonly ITextUndoHistoryRegistry _undoHistory = undoHistory;

        public string DisplayName => EditorFeaturesResources.XML_End_Tag_Completion;

        protected abstract TXmlElementStartTagSyntax GetStartTag(TXmlElementSyntax xmlElement);
        protected abstract TXmlElementEndTagSyntax GetEndTag(TXmlElementSyntax xmlElement);
        protected abstract TXmlNameSyntax GetName(TXmlElementStartTagSyntax startTag);
        protected abstract TXmlNameSyntax GetName(TXmlElementEndTagSyntax endTag);
        protected abstract SyntaxToken GetLocalName(TXmlNameSyntax name);

        public CommandState GetCommandState(TypeCharCommandArgs args, Func<CommandState> nextHandler)
            => nextHandler();

        public void ExecuteCommand(TypeCharCommandArgs args, Action nextHandler, CommandExecutionContext context)
        {
            // Ensure completion and any other buffer edits happen first.
            nextHandler();

            var cancellationToken = context.OperationContext.UserCancellationToken;
            if (cancellationToken.IsCancellationRequested)
                return;

            try
            {
                ExecuteCommandWorker(args, context);
            }
            catch (OperationCanceledException)
            {
                // According to Editor command handler API guidelines, it's best if we return early if cancellation
                // is requested instead of throwing. Otherwise, we could end up in an invalid state due to already
                // calling nextHandler().
            }
        }

        private void ExecuteCommandWorker(TypeCharCommandArgs args, CommandExecutionContext context)
        {
            if (args.TypedChar is not '>' and not '/')
                return;

            using (context.OperationContext.AddScope(allowCancellation: true, EditorFeaturesResources.Completing_Tag))
            {
                var buffer = args.SubjectBuffer;

                var document = buffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
                if (document == null)
                    return;

                // We actually want the caret position after any operations
                var position = args.TextView.GetCaretPoint(args.SubjectBuffer);

                // No caret position? No edit!
                if (!position.HasValue)
                    return;

                TryCompleteTag(args.TextView, args.SubjectBuffer, document, position.Value, context.OperationContext.UserCancellationToken);
            }
        }

        protected void InsertTextAndMoveCaret(ITextView textView, ITextBuffer subjectBuffer, SnapshotPoint position, string insertionText, int? finalCaretPosition)
        {
            using var transaction = _undoHistory.GetHistory(textView.TextBuffer).CreateTransaction("XmlTagCompletion");

            subjectBuffer.Insert(position, insertionText);

            if (finalCaretPosition.HasValue)
            {
                var point = subjectBuffer.CurrentSnapshot.GetPoint(finalCaretPosition.Value);
                textView.TryMoveCaretToAndEnsureVisible(point);
            }

            transaction.Complete();
        }

        private SyntaxToken GetLocalName(TXmlElementStartTagSyntax startTag)
            => GetLocalName(GetName(startTag));

        private SyntaxToken GetLocalName(TXmlElementEndTagSyntax startTag)
            => GetLocalName(GetName(startTag));

        private void TryCompleteTag(ITextView textView, ITextBuffer subjectBuffer, Document document, SnapshotPoint position, CancellationToken cancellationToken)
        {
            var tree = document.GetRequiredSyntaxTreeSynchronously(cancellationToken);
            var token = tree.FindTokenOnLeftOfPosition(position, cancellationToken, includeDocumentationComments: true);
            var syntaxFacts = document.GetRequiredLanguageService<ISyntaxFactsService>();
            var syntaxKinds = syntaxFacts.SyntaxKinds;

            var parentTrivia = token.GetAncestor<TDocumentationCommentTriviaSyntax>();
            if (parentTrivia is null)
                return;

            if (token.RawKind == syntaxKinds.GreaterThanToken)
            {
                if (token.Parent is not TXmlElementStartTagSyntax parentStartTag ||
                    parentStartTag.Parent is not TXmlElementSyntax parentElement)
                {
                    return;
                }

                // Slightly special case: <blah><blah$$</blah>
                // If we already have a matching end tag and we're parented by 
                // an xml element with the same start tag and a missing/non-matching end tag, 
                // do completion anyway. Generally, if this is the case, we have to walk
                // up the parent elements until we find an unmatched start tag.

                if (GetLocalName(parentStartTag).ValueText.Length > 0 && HasMatchingEndTag(parentElement))
                {
                    if (HasUnmatchedIdenticalParent(parentElement))
                    {
                        InsertTextAndMoveCaret(textView, subjectBuffer, position, "</" + GetLocalName(parentStartTag).ValueText + ">", position);
                        return;
                    }
                }

                CheckNameAndInsertText(textView, subjectBuffer, position, parentElement, position.Position, "</{0}>");
            }
            else if (token.RawKind == syntaxKinds.LessThanSlashToken)
            {
                // /// <summary>
                // /// </$$
                // /// </summary>
                // We need to check for non-trivia XML text tokens after $$ that match the expected end tag text.

                if (token.Parent is TXmlElementEndTagSyntax { Parent: TXmlElementSyntax parentElement })
                {
                    var startTag = GetStartTag(parentElement);
                    if (startTag != null &&
                        !HasFollowingEndTagTrivia(startTag, token))
                    {
                        CheckNameAndInsertText(textView, subjectBuffer, position, parentElement, null, "{0}>");
                    }
                }
            }
        }

        private bool HasFollowingEndTagTrivia(
            TXmlElementStartTagSyntax startTag,
            SyntaxToken lessThanSlashToken)
        {
            var tagName = GetLocalName(startTag).ValueText;
            var expectedEndTagText = "</" + tagName + ">";

            var token = lessThanSlashToken.GetNextToken(includeDocumentationComments: true);
            while (token.Parent is TXmlTextSyntax)
            {
                if (token.ValueText == expectedEndTagText)
                    return true;

                token = token.GetNextToken(includeDocumentationComments: true);
            }

            if (token.Parent is TXmlElementEndTagSyntax endTag &&
                GetLocalName(endTag).ValueText == tagName)
            {
                return true;
            }

            return false;
        }

        private bool HasUnmatchedIdenticalParent(TXmlElementSyntax parentElement)
        {
            if (parentElement.Parent is TXmlElementSyntax grandParentElement)
            {
                var parentStartTag = GetStartTag(parentElement);
                if (GetLocalName(GetStartTag(grandParentElement)).ValueText == GetLocalName(parentStartTag).ValueText)
                {
                    if (HasMatchingEndTag(grandParentElement))
                    {
                        return HasUnmatchedIdenticalParent(grandParentElement);
                    }

                    return true;
                }
            }

            return false;
        }

        private bool HasMatchingEndTag(TXmlElementSyntax parentElement)
        {
            var startTag = GetStartTag(parentElement);
            var endTag = GetEndTag(parentElement);
            return endTag != null &&
                !endTag.IsMissing &&
                GetLocalName(endTag).ValueText == GetLocalName(startTag).ValueText;
        }

        private void CheckNameAndInsertText(
            ITextView textView,
            ITextBuffer subjectBuffer,
            SnapshotPoint position,
            TXmlElementSyntax parentElement,
            int? finalCaretPosition,
            string formatString)
        {
            var startTag = GetStartTag(parentElement);
            var endTag = GetEndTag(parentElement);
            if (startTag is null || endTag is null)
                return;

            var elementName = GetLocalName(startTag).ValueText;

            if (elementName.Length > 0 &&
                GetLocalName(endTag).ValueText != elementName)
            {
                InsertTextAndMoveCaret(textView, subjectBuffer, position, string.Format(formatString, elementName), finalCaretPosition);
            }
        }
    }
}
