// 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.Linq;
using Microsoft.AspNetCore.Razor.Language.Intermediate;
using Microsoft.AspNetCore.Razor.Language.Legacy;

namespace Microsoft.AspNetCore.Razor.Language.Components
{
	internal class ComponentMarkupEncodingPass : ComponentIntermediateNodePassBase, IRazorOptimizationPass
	{
		// Runs after ComponentMarkupBlockPass
		public override int Order => ComponentMarkupDiagnosticPass.DefaultOrder + 20;

		protected override void ExecuteCore( RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode )
		{
			if ( !IsComponentDocument( documentNode ) )
			{
				return;
			}

			var rewriter = new Rewriter();
			rewriter.Visit( documentNode );
		}

		private class Rewriter : IntermediateNodeWalker
		{
			// Markup content in components are rendered in one of the following two ways,
			// AddContent - we encode it when used with prerendering and inserted into the DOM in a safe way (low perf impact)
			// AddMarkupContent - renders the content directly as markup (high perf impact)
			// Because of this, we want to use AddContent as much as possible.
			//
			// We want to use AddMarkupContent to avoid aggresive encoding during prerendering.
			// Specifically, when one of the following characters are in the content,
			// 1. New lines (\r, \n), tabs(\t) - so they get rendered as actual new lines, tabs instead of &#xA;
			// 2. Any character outside the ASCII range

			private static readonly char[] EncodedCharacters = new[] { '\r', '\n', '\t' };

			private readonly Dictionary<string, string> _seenEntities = new Dictionary<string, string>( StringComparer.Ordinal );

			public override void VisitHtml( HtmlContentIntermediateNode node )
			{
				for ( var i = 0; i < node.Children.Count; i++ )
				{
					var child = node.Children[i];
					if ( !(child is IntermediateToken token) || !token.IsHtml || string.IsNullOrEmpty( token.Content ) )
					{
						// We only care about Html tokens.
						continue;
					}

					for ( var j = 0; j < token.Content.Length; j++ )
					{
						var ch = token.Content[j];
						// ASCII range is 0 - 127
						if ( ch > 127 || EncodedCharacters.Contains( ch ) )
						{
							node.SetEncoded();
							return;
						}
					}
				}

				// If we reach here, we don't have newlines, tabs or non-ascii characters in this node.
				// If we can successfully decode all HTML entities(if any) in this node, we can safely let it call AddContent.
				var decodedContent = new string[node.Children.Count];
				for ( var i = 0; i < node.Children.Count; i++ )
				{
					var child = node.Children[i];
					if ( !(child is IntermediateToken token) || !token.IsHtml || string.IsNullOrEmpty( token.Content ) )
					{
						// We only care about Html tokens.
						continue;
					}

					if ( TryDecodeHtmlEntities( token.Content, out var decoded ) )
					{
						decodedContent[i] = decoded;
					}
					else
					{
						node.SetEncoded();
						return;
					}
				}

				// If we reach here, it means we have successfully decoded all content.
				// Replace all token content with the decoded value.
				for ( var i = 0; i < node.Children.Count; i++ )
				{
					var child = node.Children[i];
					if ( !(child is IntermediateToken token) || !token.IsHtml || string.IsNullOrEmpty( token.Content ) )
					{
						// We only care about Html tokens.
						continue;
					}

					token.Content = decodedContent[i];
				}
			}

			private bool TryDecodeHtmlEntities( string content, out string decoded )
			{
				_seenEntities.Clear();
				decoded = content;
				var i = 0;
				while ( i < content.Length )
				{
					var ch = content[i];
					if ( ch == '&' )
					{
						if ( TryGetHtmlEntity( content, i, out var entity, out var replacement ) )
						{
							if ( !_seenEntities.ContainsKey( entity ) )
							{
								_seenEntities.Add( entity, replacement );
							}

							i += entity.Length;
						}
						else
						{
							// We found a '&' that we don't know what to do with. Don't try to decode further.
							return false;
						}
					}
					else
					{
						i++;
					}
				}

				foreach ( var entity in _seenEntities )
				{
					decoded = decoded.Replace( entity.Key, entity.Value );
				}

				return true;
			}

			private bool TryGetHtmlEntity( string content, int position, out string entity, out string replacement )
			{
				// We're at '&'. Check if it is the start of an HTML entity.
				entity = null;
				replacement = null;
				var endPosition = -1;
				for ( var i = position + 1; i < content.Length; i++ )
				{
					var ch = content[i];
					if ( char.IsLetterOrDigit( ch ) || ch == '#' )
					{
						continue;
					}
					else if ( ch == ';' )
					{
						endPosition = i;
					}

					break;
				}

				if ( endPosition != -1 )
				{
					entity = content.Substring( position, endPosition - position + 1 );
					if ( entity.StartsWith( "&#", StringComparison.Ordinal ) )
					{
						// Extract the codepoint and map it to an entity.

						// `entity` is guaranteed to be of the format &#****;
						var entityValue = entity.Substring( 2, entity.Length - 3 );
						int codePoint;
						if ( !int.TryParse( entityValue, out codePoint ) )
						{
							// If it is not an integer, check if it is hexadecimal like 0x00CD
							try
							{
								codePoint = Convert.ToInt32( entityValue, 16 );
							}
							catch ( FormatException )
							{
								// Do nothing.
							}
						}

						if ( ParserHelpers.HtmlEntityCodePoints.TryGetValue( codePoint, out replacement ) )
						{
							// This is a known html entity unicode codepoint.
							return true;
						}

						// Unknown entity.
						return false;
					}
					else if ( ParserHelpers.NamedHtmlEntities.TryGetValue( entity, out replacement ) )
					{
						return true;
					}
				}

				// The '&' is not part of an HTML entity.
				return false;
			}
		}
	}
}
