// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using AsciiDocNet;
using DocGenerator.XmlDocs;
using Elasticsearch.Net;
using Microsoft.CodeAnalysis;
using Nest;
using NuDoq;
using Container = AsciiDocNet.Container;
using Document = AsciiDocNet.Document;
using Exception = System.Exception;

namespace DocGenerator.AsciiDoc
{
	/// <summary>
	/// Visits the "raw" asciidoc generated using Roslyn and adds attribute entries,
	/// section titles, rearranges sections, etc.
	/// </summary>
	public class GeneratedAsciidocVisitor : NoopVisitor
	{
		private static readonly Dictionary<string, string> Ids = new Dictionary<string, string>();
		private readonly FileInfo _destination;
		private readonly Dictionary<string, Project> _projects;

		private readonly FileInfo _source;
		private Document _document;
		private Document _newDocument;
		private bool _topLevel = true;
		private int _topSectionTitleLevel;

		public GeneratedAsciidocVisitor(FileInfo source, FileInfo destination, Dictionary<string, Project> projects)
		{
			_source = source;
			_destination = destination;
			_projects = projects;
		}

		public Document Convert(Document document)
		{
			_document = document;
			document.Accept(this);
			return _newDocument;
		}

		public override void VisitDocument(Document document)
		{
			_newDocument = new Document
			{
				Title = document.Title,
				DocType = document.DocType
			};

			foreach (var authorInfo in document.Authors) _newDocument.Authors.Add(authorInfo);

			RemoveDocDirectoryAttribute(_newDocument);
			RemoveDocDirectoryAttribute(document);

			foreach (var attributeEntry in document.Attributes) _newDocument.Attributes.Add(attributeEntry);

			if (document.Attributes.All(a => a.Name != "ref_current"))
				_newDocument.Attributes.Add(new AttributeEntry("ref_current",
					$"https://www.elastic.co/guide/en/elasticsearch/reference/{Program.DocVersion}"));

			var github = "https://github.com/elastic/elasticsearch-net";
			if (document.Attributes.All(a => a.Name != "github")) _newDocument.Attributes.Add(new AttributeEntry("github", github));

			if (document.Attributes.All(a => a.Name != "nuget"))
				_newDocument.Attributes.Add(new AttributeEntry("nuget", "https://www.nuget.org/packages"));

			var originalFile = Regex.Replace(_source.FullName.Replace("\\", "/"), @"^(.*Tests/)",
				$"{github}/tree/{Program.BranchName}/src/Tests/Tests/");

			var eol = Environment.NewLine;
			_newDocument.Insert(0, new Comment
			{
				Style = CommentStyle.MultiLine,
				Text = $"IMPORTANT NOTE{eol}=============={eol}This file has been generated from {originalFile}. {eol}" +
					$"If you wish to submit a PR for any spelling mistakes, typos or grammatical errors for this file,{eol}" +
					"please modify the original csharp file found at the link and submit the PR with that change. Thanks!"
			});

			_topSectionTitleLevel = _source.Directory.Name.Equals("request", StringComparison.OrdinalIgnoreCase) &&
				_source.Directory.Parent != null &&
				_source.Directory.Parent.Name.Equals("search", StringComparison.OrdinalIgnoreCase)
					? 2
					: 3;

			// see if the document has some kind of top level title and add one with an anchor if not.
			// Used to add titles to *Usage test files
			if (document.Title == null && document.Count > 0)
			{
				var sectionTitle = document[0] as SectionTitle;

				// capture existing top level
				if (sectionTitle != null && sectionTitle.Level <= 3)
					_topSectionTitleLevel = sectionTitle.Level;

				if (sectionTitle == null || sectionTitle.Level > 3)
				{
					var id = Path.GetFileNameWithoutExtension(_destination.Name);
					var title = id.LowercaseHyphenToPascal();
					sectionTitle = new SectionTitle(title, _topSectionTitleLevel);
					sectionTitle.Attributes.Add(new Anchor(id));
					_newDocument.Add(sectionTitle);
				}
			}

			base.VisitDocument(document);
		}

		public override void VisitContainer(Container elements)
		{
			if (_topLevel)
			{
				_topLevel = false;
				for (var index = 0; index < elements.Count; index++)
				{
					var element = elements[index];
					var source = element as Source;

					if (source != null)
					{
						// remove empty source blocks
						if (string.IsNullOrWhiteSpace(source.Text)) continue;

						var method = source.Attributes.OfType<NamedAttribute>().FirstOrDefault(a => a.Name == "method");
						if (method == null)
						{
							_newDocument.Add(source);
							continue;
						}

						// if there is a section title since the last source block, don't add one
						var lastSourceBlock = _newDocument.LastOrDefault(e => e is Source);
						var lastSectionTitle = _newDocument.OfType<SectionTitle>().LastOrDefault(e => e.Level == _topSectionTitleLevel + 1);
						if (lastSourceBlock != null && lastSectionTitle != null)
						{
							var lastSectionTitleIndex = _newDocument.IndexOf(lastSectionTitle);
							var lastSourceBlockIndex = _newDocument.IndexOf(lastSourceBlock);
							if (lastSectionTitleIndex > lastSourceBlockIndex)
							{
								_newDocument.Add(source);
								continue;
							}
						}

						switch (method.Value)
						{
							case "fluent":
							case "queryfluent":
							case "fluentaggs":
								if (!LastSectionTitleMatches(text => text.StartsWith("Fluent DSL", StringComparison.OrdinalIgnoreCase)))
									_newDocument.Add(CreateSubsectionTitle("Fluent DSL example"));

								_newDocument.Add(source);
								break;
							case "initializer":
							case "queryinitializer":
							case "initializeraggs":
								_newDocument.Add(CreateSubsectionTitle("Object Initializer syntax example"));
								_newDocument.Add(source);
								break;
							case "expectresponse":
								// Don't add the Handlng Response section title if it was the last title (it might be defined in the doc already)
								if (!LastSectionTitleMatches(text => text.Equals("Handling responses", StringComparison.OrdinalIgnoreCase)))
									_newDocument.Add(CreateSubsectionTitle("Handling Responses"));
								_newDocument.Add(source);
								break;
							default:
								_newDocument.Add(source);
								break;
						}
					}
					else
						_newDocument.Add(element);
				}
			}

			base.VisitContainer(elements);
		}

		public override void VisitSource(Source source)
		{
			// remove method attributes as the elastic doc generation doesn't like them; it
			// expects a linenumbering in the index 2 position of a source block
			var methodAttribute = source.Attributes.FirstOrDefault(a => a.Name == "method");
			if (methodAttribute != null) source.Attributes.Remove(methodAttribute);

			// Replace tabs with spaces and remove C# comment escaping from callouts
			// (elastic docs generation does not like this callout format)
			source.Text = Regex.Replace(source.Text.Replace("\t", "    "), @"//[ \t]*\<(\d+)\>.*", "<$1>");

			base.VisitSource(source);
		}

		public override void VisitSectionTitle(SectionTitle sectionTitle)
		{
			// Generate an anchor for all top level section titles
			if (_document.IndexOf(sectionTitle) == 0 && !sectionTitle.Attributes.HasAnchor)
			{
				var builder = new StringBuilder();
				using (var writer = new AsciiDocVisitor(new StringWriter(builder))) writer.VisitInlineContainer(sectionTitle);

				var title = builder.ToString().PascalToHyphen();
				sectionTitle.Attributes.Add(new Anchor(title));
			}

			if (sectionTitle.Attributes.HasAnchor)
			{
				// Check for duplicate ids across documents
				var key = sectionTitle.Attributes.Anchor.Id;
				if (Ids.TryGetValue(key, out var existingFile))
					throw new Exception($"duplicate id {key} in {_destination.FullName}. Id already exists in {existingFile}");

				Ids.Add(key, _destination.FullName);
			}

			base.VisitSectionTitle(sectionTitle);
		}

		public override void VisitAttributeEntry(AttributeEntry attributeEntry)
		{
			if (attributeEntry.Name != "xml-docs") return;

			var configuration = new DirectoryInfo(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location)).Parent?.Name;

			string XmlFile(string project)
			{
				if (configuration == null)
					return Path.Combine(Program.InputDirPath, project, "netstandard2.0", $"{project}.xml");

				return Path.Combine(Program.InputDirPath, project, "bin", configuration, "netstandard2.0",
					$"{project}.xml");
			}

			var value = attributeEntry.Value;

			if (string.IsNullOrEmpty(value))
			{
				base.VisitAttributeEntry(attributeEntry);
				return;
			}

			var parts = value.Split(':');
			var assemblyName = parts[0];
			var typeName = parts[1];

			string xmlDocsFile;
			Assembly assembly;
			string assemblyNamespace;

			//TODO: tidy this up
			switch (assemblyName.ToLowerInvariant())
			{
				case "elasticsearch.net":
					xmlDocsFile = Path.GetFullPath(XmlFile("Elasticsearch.Net"));
					assembly = typeof(ElasticLowLevelClient).Assembly;
					assemblyNamespace = typeof(ElasticLowLevelClient).Namespace;
					break;
				default:
					xmlDocsFile = Path.GetFullPath(XmlFile("Nest"));
					assembly = typeof(ElasticClient).Assembly;
					assemblyNamespace = typeof(ElasticClient).Namespace;
					break;
			}

			// build xml documentation file on the fly if it doesn't exist
			if (!File.Exists(xmlDocsFile))
			{
				Console.WriteLine($"Can not find {xmlDocsFile} attempting to build");
				var project = _projects[assemblyName];

				var compilation = project.GetCompilationAsync().Result;

				using (var peStream = new MemoryStream())
				using (var commentStream = File.Create(xmlDocsFile))
				{
					var emitResult = compilation.Emit(peStream, null, commentStream);

					if (!emitResult.Success)
					{
						var failures = emitResult.Diagnostics.Where(diagnostic =>
							diagnostic.IsWarningAsError ||
							diagnostic.Severity == DiagnosticSeverity.Error);

						var builder = new StringBuilder($"Unable to emit compilation for: {assemblyName}");
						foreach (var diagnostic in failures.Take(50)) builder.AppendLine($"{diagnostic.Id}: {diagnostic.GetMessage()}");

						builder.AppendLine(new string('-', 30));

						throw new Exception(builder.ToString());
					}
				}
			}

			var assemblyMembers = DocReader.Read(assembly, xmlDocsFile);
			var type = assembly.GetType(assemblyNamespace + "." + typeName);
			var visitor = new XmlDocsVisitor(type);

			visitor.VisitAssembly(assemblyMembers);
			if (visitor.LabeledListItems.Any())
			{
				var labeledList = new LabeledList();
				foreach (var item in visitor.LabeledListItems
					.OrderBy(l => l.Label)
					.GroupBy(l => l.Label)
					.Select(x => x.First()))
					labeledList.Items.Add(item);

				_newDocument.Insert(_newDocument.IndexOf(attributeEntry), labeledList);
			}
		}

		private void RemoveDocDirectoryAttribute(Document document)
		{
			var directoryAttribute = document.Attributes.FirstOrDefault(a => a.Name == "docdir");
			if (directoryAttribute != null) document.Attributes.Remove(directoryAttribute);
		}

		private bool LastSectionTitleMatches(Func<string, bool> predicate)
		{
			var lastSectionTitle = _newDocument.OfType<SectionTitle>().LastOrDefault(e => e.Level == _topSectionTitleLevel + 1);
			if (lastSectionTitle != null && lastSectionTitle.Level == _topSectionTitleLevel + 1)
			{
				var builder = new StringBuilder();
				using (var visitor = new AsciiDocVisitor(new StringWriter(builder))) visitor.VisitInlineContainer(lastSectionTitle);

				return predicate(builder.ToString());
			}

			return false;
		}

		private SectionTitle CreateSubsectionTitle(string title)
		{
			var level = _topSectionTitleLevel + 1;
			var sectionTitle = new SectionTitle(title, level);

			// levels 1-3 need to be floating so the Elasticsearch docs generation does not
			// split into separate file
			if (level < 4)
				sectionTitle.IsFloating = true;

			return sectionTitle;
		}
	}
}
