﻿// 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.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.BraceCompletion;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Formatting.Rules;
using Microsoft.CodeAnalysis.Indentation;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.CSharp.BraceCompletion
{
    internal abstract class AbstractCurlyBraceOrBracketCompletionService : AbstractCSharpBraceCompletionService
    {
        /// <summary>
        /// Annotation used to find the closing brace location after formatting changes are applied.
        /// The closing brace location is then used as the caret location.
        /// </summary>
        private static readonly SyntaxAnnotation s_closingBraceFormatAnnotation = new(nameof(s_closingBraceFormatAnnotation));
        private static readonly SyntaxAnnotation s_closingBraceNewlineAnnotation = new(nameof(s_closingBraceNewlineAnnotation));

        protected abstract ImmutableArray<AbstractFormattingRule> GetBraceFormattingIndentationRulesAfterReturn(IndentationOptions options);

        protected abstract int AdjustFormattingEndPoint(ParsedDocument document, int startPoint, int endPoint);

        public sealed override BraceCompletionResult? GetTextChangesAfterCompletion(BraceCompletionContext context, IndentationOptions options, CancellationToken cancellationToken)
        {
            // After the closing brace is completed we need to format the span from the opening point to the closing point.
            // E.g. when the user triggers completion for an if statement ($$ is the caret location) we insert braces to get
            // if (true){$$}
            // We then need to format this to
            // if (true) { $$}

            if (!options.AutoFormattingOptions.FormatOnCloseBrace)
            {
                return null;
            }

            var (_, formattingChanges, finalCurlyBraceEnd) = FormatTrackingSpan(
                context.Document,
                context.OpeningPoint,
                context.ClosingPoint,
                // We're not trying to format the indented block here, so no need to pass in additional rules.
                braceFormattingIndentationRules: ImmutableArray<AbstractFormattingRule>.Empty,
                options,
                cancellationToken);

            if (formattingChanges.IsEmpty)
            {
                return null;
            }

            // The caret location should be at the start of the closing brace character.
            var formattedText = context.Document.Text.WithChanges(formattingChanges);
            var caretLocation = formattedText.Lines.GetLinePosition(finalCurlyBraceEnd - 1);

            return new BraceCompletionResult(formattingChanges, caretLocation);
        }

        private static bool ContainsOnlyWhitespace(SourceText text, int openingPosition, int closingBraceEndPoint)
        {
            // Set the start point to the character after the opening brace.
            var start = openingPosition + 1;
            // Set the end point to the closing brace start character position.
            var end = closingBraceEndPoint - 1;

            for (var i = start; i < end; i++)
            {
                if (!char.IsWhiteSpace(text[i]))
                {
                    return false;
                }
            }

            return true;
        }

        public sealed override BraceCompletionResult? GetTextChangeAfterReturn(
            BraceCompletionContext context,
            IndentationOptions options,
            CancellationToken cancellationToken)
        {
            var document = context.Document;
            var closingPoint = context.ClosingPoint;
            var openingPoint = context.OpeningPoint;
            var originalDocumentText = document.Text;

            // check whether shape of the braces are what we support
            // shape must be either "{|}" or "{ }". | is where caret is. otherwise, we don't do any special behavior
            if (!ContainsOnlyWhitespace(originalDocumentText, openingPoint, closingPoint))
            {
                return null;
            }

            var openingPointLine = originalDocumentText.Lines.GetLineFromPosition(openingPoint).LineNumber;
            var closingPointLine = originalDocumentText.Lines.GetLineFromPosition(closingPoint).LineNumber;

            // If there are already multiple empty lines between the braces, don't do anything.
            // We need to allow a single empty line between the braces to account for razor scenarios where they insert a line.
            if (closingPointLine - openingPointLine > 2)
            {
                return null;
            }

            // If there is not already an empty line inserted between the braces, insert one.
            TextChange? newLineEdit = null;
            if (closingPointLine - openingPointLine == 1)
            {
                // Handling syntax tree directly to avoid parsing in potentially UI blocking code-path
                var closingToken = FindClosingBraceToken(document.Root, closingPoint);
                var annotatedNewline = SyntaxFactory.EndOfLine(options.FormattingOptions.NewLine).WithAdditionalAnnotations(s_closingBraceNewlineAnnotation);
                var newClosingToken = closingToken.WithPrependedLeadingTrivia(SpecializedCollections.SingletonEnumerable(annotatedNewline));

                var rootToFormat = document.Root.ReplaceToken(closingToken, newClosingToken);
                annotatedNewline = rootToFormat.GetAnnotatedTrivia(s_closingBraceNewlineAnnotation).Single();
                document = document.WithChangedRoot(rootToFormat, cancellationToken);

                // Calculate text change for adding a newline and adjust closing point location.
                closingPoint = annotatedNewline.Token.Span.End;
                newLineEdit = new TextChange(new TextSpan(annotatedNewline.SpanStart, 0), annotatedNewline.ToString());
            }

            // Format the text that contains the newly inserted line.
            var (formattedRoot, formattingChanges, newClosingPoint) = FormatTrackingSpan(
                document,
                openingPoint,
                closingPoint,
                braceFormattingIndentationRules: GetBraceFormattingIndentationRulesAfterReturn(options),
                options,
                cancellationToken);

            var newDocument = document.WithChangedRoot(formattedRoot, cancellationToken);

            // Get the empty line between the curly braces.
            var desiredCaretLine = GetLineBetweenCurlys(newClosingPoint, newDocument.Text);
            Debug.Assert(desiredCaretLine.GetFirstNonWhitespacePosition() == null, "the line between the formatted braces is not empty");

            // Set the caret position to the properly indented column in the desired line.
            var caretPosition = GetIndentedLinePosition(newDocument, newDocument.Text, desiredCaretLine.LineNumber, options, cancellationToken);

            return new BraceCompletionResult(GetMergedChanges(newLineEdit, formattingChanges, newDocument.Text), caretPosition);

            static TextLine GetLineBetweenCurlys(int closingPosition, SourceText text)
            {
                var closingBraceLineNumber = text.Lines.GetLineFromPosition(closingPosition - 1).LineNumber;
                return text.Lines[closingBraceLineNumber - 1];
            }

            static LinePosition GetIndentedLinePosition(ParsedDocument document, SourceText sourceText, int lineNumber, IndentationOptions options, CancellationToken cancellationToken)
            {
                var indentationService = document.LanguageServices.GetRequiredService<IIndentationService>();
                var indentation = indentationService.GetIndentation(document, lineNumber, options, cancellationToken);

                var baseLinePosition = sourceText.Lines.GetLinePosition(indentation.BasePosition);
                var offsetOfBasePosition = baseLinePosition.Character;
                var totalOffset = offsetOfBasePosition + indentation.Offset;
                var indentedLinePosition = new LinePosition(lineNumber, totalOffset);
                return indentedLinePosition;
            }

            static ImmutableArray<TextChange> GetMergedChanges(TextChange? newLineEdit, ImmutableArray<TextChange> formattingChanges, SourceText formattedText)
            {
                // The new line edit is calculated against the original text, d0, to get text d1.
                // The formatting edits are calculated against d1 to get text d2.
                // Merge the formatting and new line edits into a set of whitespace only text edits that all apply to d0.
                if (!newLineEdit.HasValue)
                    return formattingChanges;

                // Depending on options, we might not get any formatting change.
                // In this case, the newline edit is the only change.
                if (formattingChanges.IsEmpty)
                    return ImmutableArray.Create(newLineEdit.Value);

                var newRanges = TextChangeRangeExtensions.Merge(
                    ImmutableArray.Create(newLineEdit.Value.ToTextChangeRange()),
                    formattingChanges.SelectAsArray(f => f.ToTextChangeRange()));

                using var _ = ArrayBuilder<TextChange>.GetInstance(out var mergedChanges);
                var amountToShift = 0;
                foreach (var newRange in newRanges)
                {
                    var newTextChangeSpan = newRange.Span;
                    // Get the text to put in the text change by looking at the span in the formatted text.
                    // As the new range start is relative to the original text, we need to adjust it assuming the previous changes were applied
                    // to get the correct start location in the formatted text.
                    // E.g. with changes
                    //     1. Insert "hello" at 2
                    //     2. Insert "goodbye" at 3
                    // "goodbye" is after "hello" at location 3 + 5 (length of "hello") in the new text.
                    var newTextChangeText = formattedText.GetSubText(new TextSpan(newRange.Span.Start + amountToShift, newRange.NewLength)).ToString();
                    amountToShift += (newRange.NewLength - newRange.Span.Length);
                    mergedChanges.Add(new TextChange(newTextChangeSpan, newTextChangeText));
                }

                return mergedChanges.ToImmutable();
            }
        }

        /// <summary>
        /// Formats the span between the opening and closing points, options permitting.
        /// Returns the text changes that should be applied to the input document to 
        /// get the formatted text and the end of the close curly brace in the formatted text.
        /// </summary>
        private (SyntaxNode formattedRoot, ImmutableArray<TextChange> textChanges, int finalBraceEnd) FormatTrackingSpan(
            ParsedDocument document,
            int openingPoint,
            int closingPoint,
            ImmutableArray<AbstractFormattingRule> braceFormattingIndentationRules,
            IndentationOptions options,
            CancellationToken cancellationToken)
        {
            var startPoint = openingPoint;
            var endPoint = AdjustFormattingEndPoint(document, startPoint, closingPoint);

            if (options.IndentStyle == FormattingOptions2.IndentStyle.Smart)
            {
                // Set the formatting start point to be the beginning of the first word to the left 
                // of the opening brace location.
                // skip whitespace
                while (startPoint >= 0 && char.IsWhiteSpace(document.Text[startPoint]))
                {
                    startPoint--;
                }

                // skip tokens in the first word to the left.
                startPoint--;
                while (startPoint >= 0 && !char.IsWhiteSpace(document.Text[startPoint]))
                {
                    startPoint--;
                }
            }

            var spanToFormat = TextSpan.FromBounds(Math.Max(startPoint, 0), endPoint);
            var rules = FormattingRuleUtilities.GetFormattingRules(document, spanToFormat, braceFormattingIndentationRules);

            // Annotate the original closing brace so we can find it after formatting.
            var annotatedRoot = GetSyntaxRootWithAnnotatedClosingBrace(document.Root, closingPoint);

            var result = Formatter.GetFormattingResult(
                annotatedRoot, SpecializedCollections.SingletonEnumerable(spanToFormat), document.SolutionServices, options.FormattingOptions, rules, cancellationToken);

            if (result == null)
            {
                return (document.Root, ImmutableArray<TextChange>.Empty, closingPoint);
            }

            var newRoot = result.GetFormattedRoot(cancellationToken);
            var newClosingPoint = newRoot.GetAnnotatedTokens(s_closingBraceFormatAnnotation).Single().SpanStart + 1;

            var textChanges = result.GetTextChanges(cancellationToken).ToImmutableArray();
            return (newRoot, textChanges, newClosingPoint);

            SyntaxNode GetSyntaxRootWithAnnotatedClosingBrace(SyntaxNode originalRoot, int closingBraceEndPoint)
            {
                var closeBraceToken = FindClosingBraceToken(originalRoot, closingBraceEndPoint);
                var newCloseBraceToken = closeBraceToken.WithAdditionalAnnotations(s_closingBraceFormatAnnotation);
                return originalRoot.ReplaceToken(closeBraceToken, newCloseBraceToken);
            }
        }

        private SyntaxToken FindClosingBraceToken(SyntaxNode root, int closingBraceEndPoint)
        {
            var closeBraceToken = root.FindToken(closingBraceEndPoint - 1);
            Debug.Assert(IsValidClosingBraceToken(closeBraceToken));
            return closeBraceToken;
        }
    }
}
