﻿// 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.ComponentModel.Composition;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.Text.Operations;
using Microsoft.VisualStudio.Utilities;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.Editor.CSharp.BlockCommentEditing
{
    [Export(typeof(ICommandHandler))]
    [ContentType(ContentTypeNames.CSharpContentType)]
    [Name(nameof(BlockCommentEditingCommandHandler))]
    [Order(After = PredefinedCompletionNames.CompletionCommandHandler)]
    internal sealed class BlockCommentEditingCommandHandler : ICommandHandler<ReturnKeyCommandArgs>
    {
        private readonly ITextUndoHistoryRegistry _undoHistoryRegistry;
        private readonly IEditorOperationsFactoryService _editorOperationsFactoryService;
        private readonly EditorOptionsService _editorOptionsService;

        [ImportingConstructor]
        [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
        public BlockCommentEditingCommandHandler(
            ITextUndoHistoryRegistry undoHistoryRegistry,
            IEditorOperationsFactoryService editorOperationsFactoryService,
            EditorOptionsService editorOptionsService)
        {
            Contract.ThrowIfNull(undoHistoryRegistry);
            Contract.ThrowIfNull(editorOperationsFactoryService);

            _undoHistoryRegistry = undoHistoryRegistry;
            _editorOperationsFactoryService = editorOperationsFactoryService;
            _editorOptionsService = editorOptionsService;
        }

        public string DisplayName => EditorFeaturesResources.Block_Comment_Editing;

        public CommandState GetCommandState(ReturnKeyCommandArgs args)
            => CommandState.Unspecified;

        public bool ExecuteCommand(ReturnKeyCommandArgs args, CommandExecutionContext context)
            => TryHandleReturnKey(args.SubjectBuffer, args.TextView, context.OperationContext.UserCancellationToken);

        private bool TryHandleReturnKey(ITextBuffer subjectBuffer, ITextView textView, CancellationToken cancellationToken)
        {
            if (!_editorOptionsService.GlobalOptions.GetOption(BlockCommentEditingOptionsStorage.AutoInsertBlockCommentStartString, LanguageNames.CSharp))
                return false;

            var caretPosition = textView.GetCaretPoint(subjectBuffer);
            if (caretPosition == null)
                return false;

            var textToInsert = GetTextToInsert(caretPosition.Value, subjectBuffer, _editorOptionsService, cancellationToken);
            if (textToInsert == null)
                return false;

            using var transaction = _undoHistoryRegistry.GetHistory(textView.TextBuffer).CreateTransaction(EditorFeaturesResources.Insert_new_line);

            var editorOperations = _editorOperationsFactoryService.GetEditorOperations(textView);
            editorOperations.ReplaceText(GetReplacementSpan(caretPosition.Value), textToInsert);

            transaction.Complete();
            return true;
        }

        private static Span GetReplacementSpan(SnapshotPoint caretPosition)
        {
            // We want to replace all the whitespace following the caret.  This is standard <enter> behavior in VS that
            // we want to mimic.
            var snapshot = caretPosition.Snapshot;
            var start = caretPosition.Position;
            var end = caretPosition;
            while (end < snapshot.Length && SyntaxFacts.IsWhitespace(end.GetChar()) && !SyntaxFacts.IsNewLine(end.GetChar()))
                end = end + 1;

            return Span.FromBounds(start, end);
        }

        private static string? GetTextToInsert(SnapshotPoint caretPosition, ITextBuffer buffer, EditorOptionsService editorOptionsService, CancellationToken cancellationToken)
        {
            var currentLine = caretPosition.GetContainingLine();
            var firstNonWhitespacePosition = currentLine.GetFirstNonWhitespacePosition() ?? -1;
            if (firstNonWhitespacePosition == -1)
                return null;

            // Do quick textual checks to see if it looks like we're inside a comment. That way we only do the expensive
            // syntactic work when necessary.
            //
            // The line either has to contain `/*` or it has to start with `*`.  The former looks like we're starting a
            // comment in this line.  The latter looks like the continuation of a block comment.
            var containsBlockCommentStartString = currentLine.Contains(firstNonWhitespacePosition, "/*", ignoreCase: false);
            var startsWithBlockCommentMiddleString = currentLine.StartsWith(firstNonWhitespacePosition, "*", ignoreCase: false);

            if (!containsBlockCommentStartString &&
                !startsWithBlockCommentMiddleString)
            {
                return null;
            }

            // Now do more expensive syntactic check to see if we're actually in the block comment.
            if (!IsCaretInsideBlockCommentSyntax(caretPosition, buffer, editorOptionsService, out var blockComment, out var newLine, cancellationToken))
                return null;

            var textSnapshot = caretPosition.Snapshot;

            // Now that we've found the real start of the comment, ensure that it's accurate with our quick textual check.
            containsBlockCommentStartString = currentLine.LineNumber == textSnapshot.GetLineFromPosition(blockComment.FullSpan.Start).LineNumber;

            // The whitespace indentation on the line where the block-comment starts.
            var commentIndentation = GetCommentIndentation();

            // The whitespace indentation on the current line up to the first non-whitespace char.
            var lineIndentation = textSnapshot.GetText(Span.FromBounds(
                currentLine.Start,
                firstNonWhitespacePosition));

            var exteriorText = GetExteriorText();
            if (exteriorText == null)
                return null;

            return newLine + exteriorText;

            string GetCommentIndentation()
            {
                var sb = PooledStringBuilder.GetInstance();

                var commentStart = blockComment.FullSpan.Start;
                var commentLine = textSnapshot.GetLineFromPosition(commentStart);
                for (var i = commentLine.Start.Position; i < commentStart; i++)
                {
                    var ch = textSnapshot[i];
                    sb.Builder.Append(ch == '\t' ? ch : ' ');
                }

                return sb.ToStringAndFree();
            }

            string? GetExteriorText()
            {
                if (containsBlockCommentStartString)
                    return GetExteriorTextAfterBlockCommentStart();

                var startsWithBlockCommentEndString = currentLine.StartsWith(firstNonWhitespacePosition, "*/", ignoreCase: false);
                if (startsWithBlockCommentEndString)
                    return GetExteriorTextBeforeBlockCommentEnd();

                if (startsWithBlockCommentMiddleString)
                    return GetExteriorTextInBlockCommentMiddle();

                return null;
            }

            string? GetExteriorTextAfterBlockCommentStart()
            {
                if (BlockCommentEndsRightAfterCaret(caretPosition))
                {
                    //      /*|*/
                    return commentIndentation + " ";
                }
                else if (caretPosition == firstNonWhitespacePosition + 1)
                {
                    //      /|*
                    return null; // The newline inserted could break the syntax in a way that this handler cannot fix, let's leave it.
                }
                else
                {
                    // /*|    or  /*   |
                    //
                    // In the latter case, keep the whitespace the user has typed.  in the former, insert at least one
                    // space. This is the idiomatic style for C#.
                    var whitespace = GetWhitespaceBetweenCommentAsteriskAndCaret();
                    return commentIndentation + " *" + (whitespace == "" ? " " : whitespace);
                }
            }

            string? GetExteriorTextBeforeBlockCommentEnd()
            {
                if (BlockCommentEndsRightAfterCaret(caretPosition))
                {
                    //      /*
                    //      |*/
                    return commentIndentation + " ";
                }
                else if (caretPosition == firstNonWhitespacePosition + 1)
                {
                    //      *|/
                    return lineIndentation + "*";
                }
                else
                {
                    //      /*
                    //   |   */
                    return commentIndentation + " ";
                }
            }

            string? GetExteriorTextInBlockCommentMiddle()
            {
                if (BlockCommentEndsRightAfterCaret(caretPosition))
                {
                    //      *|*/
                    return lineIndentation;
                }
                else if (caretPosition > firstNonWhitespacePosition)
                {
                    //     /*
                    //      *
                    //      *|
                    //
                    // We don't add a space here. If the user isn't adding spaces at this point, we respect that and
                    // continue with that style.
                    return lineIndentation + "*" + GetWhitespaceBetweenCommentAsteriskAndCaret();
                }
                else
                {
                    //      /*
                    //   |   *
                    return commentIndentation + " ";
                }
            }

            // Returns the whitespace after the * in either '/*' or just '*' and the caret.
            string GetWhitespaceBetweenCommentAsteriskAndCaret()
            {
                var currentChar = containsBlockCommentStartString
                    ? blockComment.FullSpan.Start
                    : firstNonWhitespacePosition;

                if (textSnapshot[currentChar] == '/')
                    currentChar++;

                if (textSnapshot[currentChar] == '*')
                    currentChar++;

                var start = currentChar;
                while (currentChar < caretPosition && SyntaxFacts.IsWhitespace(textSnapshot[currentChar]))
                    currentChar++;

                return textSnapshot.GetText(Span.FromBounds(start, currentChar));
            }
        }

        private static bool BlockCommentEndsRightAfterCaret(SnapshotPoint caretPosition)
        {
            var snapshot = caretPosition.Snapshot;
            return (int)caretPosition + 2 <= snapshot.Length && snapshot.GetText(caretPosition, 2) == "*/";
        }

        public static bool IsCaretInsideBlockCommentSyntax(
            SnapshotPoint caretPosition,
            ITextBuffer buffer,
            EditorOptionsService editorOptionsService,
            out SyntaxTrivia trivia,
            [NotNullWhen(true)] out string? newLine,
            CancellationToken cancellationToken)
        {
            trivia = default;
            newLine = null;

            var snapshot = caretPosition.Snapshot;
            var document = snapshot.GetOpenDocumentInCurrentContextWithChanges();
            if (document == null)
                return false;

            var syntaxTree = document.GetRequiredSyntaxTreeSynchronously(cancellationToken);
            trivia = syntaxTree.FindTriviaAndAdjustForEndOfFile(caretPosition, cancellationToken);

            var isBlockComment = trivia.IsKind(SyntaxKind.MultiLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia);
            if (isBlockComment)
            {
                newLine = buffer.GetLineFormattingOptions(editorOptionsService, explicitFormat: false).NewLine;

                var span = trivia.FullSpan;
                if (span.Start < caretPosition && caretPosition < span.End)
                    return true;

                // FindTriviaAndAdjustForEndOfFile always returns something if position is EOF,
                // whether or not the result includes the position.
                // And the SyntaxTrivia for block comments always ends on EOF, closed or not.
                // So we need to handle
                // /**/|EOF
                // and
                // /*  |EOF
                if (caretPosition == snapshot.Length)
                {
                    if (span.Length < "/**/".Length)
                        return true;

                    // If the block comment is not closed, SyntaxTrivia contains diagnostics
                    // So when the SyntaxTrivia is clean, the block comment should be closed
                    if (!trivia.ContainsDiagnostics)
                        return false;

                    var textBeforeCaret = snapshot.GetText(caretPosition.Position - 2, 2);
                    return textBeforeCaret != "*/";
                }
            }

            return false;
        }
    }
}
