// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.Extensions.Internal;

namespace Microsoft.AspNetCore.Razor.Language.Legacy
{
	internal class ImplicitExpressionEditHandler : SpanEditHandler
	{
		private readonly ISet<string> _keywords;
		private readonly IReadOnlyCollection<string> _readOnlyKeywords;

		public ImplicitExpressionEditHandler( Func<string, IEnumerable<Syntax.InternalSyntax.SyntaxToken>> tokenizer, ISet<string> keywords, bool acceptTrailingDot )
			: base( tokenizer )
		{
			_keywords = keywords ?? new HashSet<string>();

			// HashSet<T> implements IReadOnlyCollection<T> as of 4.6, but does not for 4.5.1. If the runtime cast
			// succeeds, avoid creating a new collection.
			_readOnlyKeywords = (_keywords as IReadOnlyCollection<string>) ?? _keywords.ToArray();

			AcceptTrailingDot = acceptTrailingDot;
		}

		public bool AcceptTrailingDot { get; }

		public IReadOnlyCollection<string> Keywords
		{
			get
			{
				return _readOnlyKeywords;
			}
		}

		public override string ToString()
		{
			return string.Format( CultureInfo.InvariantCulture, "{0};ImplicitExpression[{1}];K{2}", base.ToString(), AcceptTrailingDot ? "ATD" : "RTD", Keywords.Count );
		}

		public override bool Equals( object obj )
		{
			var other = obj as ImplicitExpressionEditHandler;
			return base.Equals( other ) &&
				AcceptTrailingDot == other.AcceptTrailingDot;
		}

		public override int GetHashCode()
		{
			// Hash code should include only immutable properties and base has none.
			var hashCodeCombiner = HashCodeCombiner.Start();
			hashCodeCombiner.Add( AcceptTrailingDot );

			return hashCodeCombiner;
		}

		protected override PartialParseResultInternal CanAcceptChange( SyntaxNode target, SourceChange change )
		{
			if ( AcceptedCharacters == AcceptedCharactersInternal.Any )
			{
				return PartialParseResultInternal.Rejected;
			}

			// In some editors intellisense insertions are handled as "dotless commits".  If an intellisense selection is confirmed
			// via something like '.' a dotless commit will append a '.' and then insert the remaining intellisense selection prior
			// to the appended '.'.  This 'if' statement attempts to accept the intermediate steps of a dotless commit via
			// intellisense.  It will accept two cases:
			//     1. '@foo.' -> '@foobaz.'.
			//     2. '@foobaz..' -> '@foobaz.bar.'. Includes Sub-cases '@foobaz()..' -> '@foobaz().bar.' etc.
			// The key distinction being the double '.' in the second case.
			if ( IsDotlessCommitInsertion( target, change ) )
			{
				return HandleDotlessCommitInsertion( target );
			}

			if ( IsAcceptableIdentifierReplacement( target, change ) )
			{
				return TryAcceptChange( target, change );
			}

			if ( IsAcceptableReplace( target, change ) )
			{
				return HandleReplacement( target, change );
			}
			var changeRelativePosition = change.Span.AbsoluteIndex - target.Position;

			// Get the edit context
			char? lastChar = null;
			if ( changeRelativePosition > 0 && target.FullWidth > 0 )
			{
				lastChar = target.GetContent()[changeRelativePosition - 1];
			}

			// Don't support 0->1 length edits
			if ( lastChar == null )
			{
				return PartialParseResultInternal.Rejected;
			}

			// Accepts cases when insertions are made at the end of a span or '.' is inserted within a span.
			if ( IsAcceptableInsertion( target, change ) )
			{
				// Handle the insertion
				return HandleInsertion( target, lastChar.Value, change );
			}

			if ( IsAcceptableInsertionInBalancedParenthesis( target, change ) )
			{
				return PartialParseResultInternal.Accepted;
			}

			if ( IsAcceptableDeletion( target, change ) )
			{
				return HandleDeletion( target, lastChar.Value, change );
			}

			if ( IsAcceptableDeletionInBalancedParenthesis( target, change ) )
			{
				return PartialParseResultInternal.Accepted;
			}

			return PartialParseResultInternal.Rejected;
		}

		// A dotless commit is the process of inserting a '.' with an intellisense selection.
		private static bool IsDotlessCommitInsertion( SyntaxNode target, SourceChange change )
		{
			return IsNewDotlessCommitInsertion( target, change ) || IsSecondaryDotlessCommitInsertion( target, change );
		}

		// Completing 'DateTime' in intellisense with a '.' could result in: '@DateT' -> '@DateT.' -> '@DateTime.' which is accepted.
		private static bool IsNewDotlessCommitInsertion( SyntaxNode target, SourceChange change )
		{
			return !IsAtEndOfSpan( target, change ) &&
				   change.Span.AbsoluteIndex > 0 &&
				   change.NewText.Length > 0 &&
				   target.GetContent().Last() == '.' &&
				   ParserHelpers.IsIdentifier( change.NewText, requireIdentifierStart: false ) &&
				   (change.Span.Length == 0 || ParserHelpers.IsIdentifier( change.GetOriginalText( target ), requireIdentifierStart: false ));
		}

		// Once a dotless commit has been performed you then have something like '@DateTime.'.  This scenario is used to detect the
		// situation when you try to perform another dotless commit resulting in a textchange with '..'.  Completing 'DateTime.Now'
		// in intellisense with a '.' could result in: '@DateTime.' -> '@DateTime..' -> '@DateTime.Now.' which is accepted.
		private static bool IsSecondaryDotlessCommitInsertion( SyntaxNode target, SourceChange change )
		{
			// Do not need to worry about other punctuation, just looking for double '.' (after change)
			return change.NewText.Length == 1 &&
				   change.NewText == "." &&
				   !string.IsNullOrEmpty( target.GetContent() ) &&
				   target.GetContent().Last() == '.' &&
				   change.Span.Length == 0;
		}

		private static bool IsAcceptableReplace( SyntaxNode target, SourceChange change )
		{
			return IsEndReplace( target, change ) ||
				   (change.IsReplace && RemainingIsWhitespace( target, change ));
		}

		private bool IsAcceptableIdentifierReplacement( SyntaxNode target, SourceChange change )
		{
			if ( !change.IsReplace )
			{
				return false;
			}

			var tokens = target.DescendantNodes().Where( n => n.IsToken ).Cast<SyntaxToken>().ToArray();
			for ( var i = 0; i < tokens.Length; i++ )
			{
				var token = tokens[i];

				if ( token == null )
				{
					break;
				}

				var tokenStartIndex = token.Position;
				var tokenEndIndex = token.EndPosition;

				// We're looking for the first token that contains the SourceChange.
				if ( tokenEndIndex > change.Span.AbsoluteIndex )
				{
					if ( tokenEndIndex >= change.Span.AbsoluteIndex + change.Span.Length && token.Kind == SyntaxKind.Identifier )
					{
						// The token we're changing happens to be an identifier. Need to check if its transformed state is also one.
						// We do this transformation logic to capture the case that the new text change happens to not be an identifier;
						// i.e. "5". Alone, it's numeric, within an identifier it's classified as identifier.
						var transformedContent = change.GetEditedContent( token.Content, change.Span.AbsoluteIndex - tokenStartIndex );
						var newTokens = Tokenizer( transformedContent );

						if ( newTokens.Count() != 1 )
						{
							// The transformed content resulted in more than one token; we can only replace a single identifier with
							// another single identifier.
							break;
						}

						var newToken = newTokens.First();
						if ( newToken.Kind == SyntaxKind.Identifier )
						{
							return true;
						}
					}

					// Change is touching a non-identifier token or spans multiple tokens.

					break;
				}
			}

			return false;
		}

		private static bool IsAcceptableDeletion( SyntaxNode target, SourceChange change )
		{
			return IsEndDeletion( target, change ) ||
				   (change.IsDelete && RemainingIsWhitespace( target, change ));
		}

		// Acceptable insertions can occur at the end of a span or when a '.' is inserted within a span.
		private static bool IsAcceptableInsertion( SyntaxNode target, SourceChange change )
		{
			return change.IsInsert &&
				(IsAcceptableEndInsertion( target, change ) ||
				IsAcceptableInnerInsertion( target, change ));
		}

		// Internal for testing
		internal static bool IsAcceptableDeletionInBalancedParenthesis( SyntaxNode target, SourceChange change )
		{
			if ( !change.IsDelete )
			{
				return false;
			}

			var changeStart = change.Span.AbsoluteIndex;
			var changeLength = change.Span.Length;
			var changeEnd = changeStart + changeLength;
			var tokens = target.DescendantNodes().Where( n => n.IsToken ).Cast<SyntaxToken>().ToArray();
			if ( !IsInsideParenthesis( changeStart, tokens ) || !IsInsideParenthesis( changeEnd, tokens ) )
			{
				// Either the start or end of the delete does not fall inside of parenthesis, unacceptable inner deletion.
				return false;
			}

			var relativePosition = changeStart - target.Position;
			var deletionContent = new StringSegment( target.GetContent(), relativePosition, changeLength );

			if ( deletionContent.IndexOfAny( new[] { '(', ')' } ) >= 0 )
			{
				// Change deleted some parenthesis
				return false;
			}

			return true;
		}

		// Internal for testing
		internal static bool IsAcceptableInsertionInBalancedParenthesis( SyntaxNode target, SourceChange change )
		{
			if ( !change.IsInsert )
			{
				return false;
			}

			if ( change.NewText.IndexOfAny( new[] { '(', ')' } ) >= 0 )
			{
				// Insertions of parenthesis aren't handled by us. If someone else wants to accept it, they can.
				return false;
			}

			var tokens = target.DescendantNodes().Where( n => n.IsToken ).Cast<SyntaxToken>().ToArray();
			if ( IsInsideParenthesis( change.Span.AbsoluteIndex, tokens ) )
			{
				return true;
			}

			return false;
		}

		// Internal for testing
		internal static bool IsInsideParenthesis( int position, IReadOnlyList<SyntaxToken> tokens )
		{
			var balanceCount = 0;
			var foundInsertionPoint = false;
			for ( var i = 0; i < tokens.Count; i++ )
			{
				var currentToken = tokens[i];
				if ( ContainsPosition( position, currentToken ) )
				{
					if ( balanceCount == 0 )
					{
						// Insertion point is outside of parenthesis, i.e. inserting at the pipe: @Foo|Baz()
						return false;
					}

					foundInsertionPoint = true;
				}

				if ( !TryUpdateBalanceCount( currentToken, ref balanceCount ) )
				{
					// Couldn't update the count. This usually occurrs when we run into a ')' outside of any parenthesis.
					return false;
				}

				if ( foundInsertionPoint && balanceCount == 0 )
				{
					// Once parenthesis become balanced after the insertion point we return true, no need to go further.
					// If they get unbalanced down the line the expression was already unbalanced to begin with and this
					// change happens prior to any ambiguity.
					return true;
				}
			}

			// Unbalanced parenthesis
			return false;
		}

		// Internal for testing
		internal static bool ContainsPosition( int position, SyntaxToken currentToken )
		{
			var tokenStart = currentToken.Position;
			if ( tokenStart == position )
			{
				// Token is exactly at the insertion point.
				return true;
			}

			var tokenEnd = tokenStart + currentToken.Content.Length;
			if ( tokenStart < position && tokenEnd > position )
			{
				// Insertion point falls in the middle of the current token.
				return true;
			}

			return false;
		}

		// Internal for testing
		internal static bool TryUpdateBalanceCount( SyntaxToken token, ref int count )
		{
			var updatedCount = count;
			if ( token.Kind == SyntaxKind.LeftParenthesis )
			{
				updatedCount++;
			}
			else if ( token.Kind == SyntaxKind.RightParenthesis )
			{
				if ( updatedCount == 0 )
				{
					return false;
				}

				updatedCount--;
			}
			else if ( token.Kind == SyntaxKind.StringLiteral )
			{
				var content = token.Content;
				if ( content.Length > 0 && content[content.Length - 1] != '"' )
				{
					// Incomplete string literal may have consumed some of our parenthesis and usually occurr during auto-completion of '"' => '""'.
					if ( !TryUpdateCountFromContent( content, ref updatedCount ) )
					{
						return false;
					}
				}
			}
			else if ( token.Kind == SyntaxKind.CharacterLiteral )
			{
				var content = token.Content;
				if ( content.Length > 0 && content[content.Length - 1] != '\'' )
				{
					// Incomplete character literal may have consumed some of our parenthesis and usually occurr during auto-completion of "'" => "''".
					if ( !TryUpdateCountFromContent( content, ref updatedCount ) )
					{
						return false;
					}
				}
			}

			if ( updatedCount < 0 )
			{
				return false;
			}

			count = updatedCount;
			return true;
		}

		// Internal for testing
		internal static bool TryUpdateCountFromContent( string content, ref int count )
		{
			var updatedCount = count;
			for ( var i = 0; i < content.Length; i++ )
			{
				if ( content[i] == '(' )
				{
					updatedCount++;
				}
				else if ( content[i] == ')' )
				{
					if ( updatedCount == 0 )
					{
						// Unbalanced parenthesis, i.e. @Foo)
						return false;
					}

					updatedCount--;
				}
			}

			count = updatedCount;
			return true;
		}

		// Accepts character insertions at the end of spans.  AKA: '@foo' -> '@fooo' or '@foo' -> '@foo   ' etc.
		private static bool IsAcceptableEndInsertion( SyntaxNode target, SourceChange change )
		{
			Debug.Assert( change.IsInsert );

			return IsAtEndOfSpan( target, change ) ||
				   RemainingIsWhitespace( target, change );
		}

		// Accepts '.' insertions in the middle of spans. Ex: '@foo.baz.bar' -> '@foo..baz.bar'
		// This is meant to allow intellisense when editing a span.
		private static bool IsAcceptableInnerInsertion( SyntaxNode target, SourceChange change )
		{
			Debug.Assert( change.IsInsert );

			// Ensure that we're actually inserting in the middle of a span and not at the end.
			// This case will fail if the IsAcceptableEndInsertion does not capture an end insertion correctly.
			Debug.Assert( !IsAtEndOfSpan( target, change ) );

			return change.Span.AbsoluteIndex > 0 &&
				   change.NewText == ".";
		}

		private static bool RemainingIsWhitespace( SyntaxNode target, SourceChange change )
		{
			var offset = (change.Span.AbsoluteIndex - target.Position) + change.Span.Length;
			return string.IsNullOrWhiteSpace( target.GetContent().Substring( offset ) );
		}

		private PartialParseResultInternal HandleDotlessCommitInsertion( SyntaxNode target )
		{
			var result = PartialParseResultInternal.Accepted;
			if ( !AcceptTrailingDot && target.GetContent().LastOrDefault() == '.' )
			{
				result |= PartialParseResultInternal.Provisional;
			}
			return result;
		}

		private PartialParseResultInternal HandleReplacement( SyntaxNode target, SourceChange change )
		{
			// Special Case for IntelliSense commits.
			//  When IntelliSense commits, we get two changes (for example user typed "Date", then committed "DateTime" by pressing ".")
			//  1. Insert "." at the end of this span
			//  2. Replace the "Date." at the end of the span with "DateTime."
			//  We need partial parsing to accept case #2.
			var oldText = change.GetOriginalText( target );

			var result = PartialParseResultInternal.Rejected;
			if ( EndsWithDot( oldText ) && EndsWithDot( change.NewText ) )
			{
				result = PartialParseResultInternal.Accepted;
				if ( !AcceptTrailingDot )
				{
					result |= PartialParseResultInternal.Provisional;
				}
			}
			return result;
		}

		private PartialParseResultInternal HandleDeletion( SyntaxNode target, char previousChar, SourceChange change )
		{
			// What's left after deleting?
			if ( previousChar == '.' )
			{
				return TryAcceptChange( target, change, PartialParseResultInternal.Accepted | PartialParseResultInternal.Provisional );
			}
			else if ( ParserHelpers.IsIdentifierPart( previousChar ) )
			{
				return TryAcceptChange( target, change );
			}
			else if ( previousChar == '(' )
			{
				var changeRelativePosition = change.Span.AbsoluteIndex - target.Position;
				if ( target.GetContent()[changeRelativePosition] == ')' )
				{
					return PartialParseResultInternal.Accepted | PartialParseResultInternal.Provisional;
				}
			}

			return PartialParseResultInternal.Rejected;
		}

		private PartialParseResultInternal HandleInsertion( SyntaxNode target, char previousChar, SourceChange change )
		{
			// What are we inserting after?
			if ( previousChar == '.' )
			{
				return HandleInsertionAfterDot( target, change );
			}
			else if ( ParserHelpers.IsIdentifierPart( previousChar ) || previousChar == ')' || previousChar == ']' )
			{
				return HandleInsertionAfterIdPart( target, change );
			}
			else if ( previousChar == '(' )
			{
				return HandleInsertionAfterOpenParenthesis( target, change );
			}
			else
			{
				return PartialParseResultInternal.Rejected;
			}
		}

		private PartialParseResultInternal HandleInsertionAfterIdPart( SyntaxNode target, SourceChange change )
		{
			// If the insertion is a full identifier part, accept it
			if ( ParserHelpers.IsIdentifier( change.NewText, requireIdentifierStart: false ) )
			{
				return TryAcceptChange( target, change );
			}
			else if ( IsDoubleParenthesisInsertion( change ) || IsOpenParenthesisInsertion( change ) )
			{
				// Allow inserting parens after an identifier - this is needed to support signature
				// help intellisense in VS.
				return TryAcceptChange( target, change );
			}
			else if ( EndsWithDot( change.NewText ) )
			{
				// Accept it, possibly provisionally
				var result = PartialParseResultInternal.Accepted;
				if ( !AcceptTrailingDot )
				{
					result |= PartialParseResultInternal.Provisional;
				}
				return TryAcceptChange( target, change, result );
			}
			else
			{
				return PartialParseResultInternal.Rejected;
			}
		}

		private PartialParseResultInternal HandleInsertionAfterOpenParenthesis( SyntaxNode target, SourceChange change )
		{
			if ( IsCloseParenthesisInsertion( change ) )
			{
				return TryAcceptChange( target, change );
			}

			return PartialParseResultInternal.Rejected;
		}

		private PartialParseResultInternal HandleInsertionAfterDot( SyntaxNode target, SourceChange change )
		{
			// If the insertion is a full identifier or another dot, accept it
			if ( ParserHelpers.IsIdentifier( change.NewText ) || change.NewText == "." )
			{
				return TryAcceptChange( target, change );
			}
			return PartialParseResultInternal.Rejected;
		}

		private PartialParseResultInternal TryAcceptChange( SyntaxNode target, SourceChange change, PartialParseResultInternal acceptResult = PartialParseResultInternal.Accepted )
		{
			var content = change.GetEditedContent( target );
			if ( StartsWithKeyword( content ) )
			{
				return PartialParseResultInternal.Rejected | PartialParseResultInternal.SpanContextChanged;
			}

			return acceptResult;
		}

		private static bool IsDoubleParenthesisInsertion( SourceChange change )
		{
			return
				change.IsInsert &&
				change.NewText.Length == 2 &&
				change.NewText == "()";
		}

		private static bool IsOpenParenthesisInsertion( SourceChange change )
		{
			return
				change.IsInsert &&
				change.NewText.Length == 1 &&
				change.NewText == "(";
		}

		private static bool IsCloseParenthesisInsertion( SourceChange change )
		{
			return
				change.IsInsert &&
				change.NewText.Length == 1 &&
				change.NewText == ")";
		}

		private static bool EndsWithDot( string content )
		{
			return (content.Length == 1 && content[0] == '.') ||
				   (content[content.Length - 1] == '.' &&
					content.Take( content.Length - 1 ).All( ParserHelpers.IsIdentifierPart ));
		}

		private bool StartsWithKeyword( string newContent )
		{
			using ( var reader = new StringReader( newContent ) )
			{
				return _keywords.Contains( reader.ReadWhile( ParserHelpers.IsIdentifierPart ) );
			}
		}
	}
}
