/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using NUnit.Framework;
using Avro.Specific;

namespace Avro.Test.AvroGen
{
    class AvroGenToolResult
    {
        public int ExitCode { get; set; }
        public string[] StdOut { get; set; }
        public string[] StdErr { get; set; }
    }

    class AvroGenHelper
    {
        public static AvroGenToolResult RunAvroGenTool(params string[] args)
        {
            // Save stdout and stderr
            TextWriter conOut = Console.Out;
            TextWriter conErr = Console.Error;

            try
            {
                AvroGenToolResult result = new AvroGenToolResult();
                StringBuilder strBuilderOut = new StringBuilder();
                StringBuilder strBuilderErr = new StringBuilder();

                using (StringWriter writerOut = new StringWriter(strBuilderOut))
                using (StringWriter writerErr = new StringWriter(strBuilderErr))
                {
                    writerOut.NewLine = "\n";
                    writerErr.NewLine = "\n";

                    // Overwrite stdout and stderr to be able to capture console output
                    Console.SetOut(writerOut);
                    Console.SetError(writerErr);

                    result.ExitCode = AvroGenTool.Main(args.ToArray());

                    writerOut.Flush();
                    writerErr.Flush();

                    result.StdOut = strBuilderOut.Length == 0 ? Array.Empty<string>() : strBuilderOut.ToString().Split(writerOut.NewLine);
                    result.StdErr = strBuilderErr.Length == 0 ? Array.Empty<string>() : strBuilderErr.ToString().Split(writerErr.NewLine);
                }

                return result;
            }
            finally
            {
                // Restore console
                Console.SetOut(conOut);
                Console.SetError(conErr);
            }
        }

        public static Assembly CompileCSharpFilesIntoLibrary(IEnumerable<string> sourceFiles, string assemblyName = null, bool loadAssembly = true)
        {
            // Create random assembly name if not specified
            if (assemblyName == null)
                assemblyName = Path.GetRandomFileName();

            // Base path to assemblies .NET assemblies
            var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);

            using (var compilerStream = new MemoryStream())
            {
                List<string> assemblies = new List<string>()
                {
                    typeof(object).Assembly.Location,
                    typeof(Schema).Assembly.Location,
                    typeof(System.CodeDom.Compiler.GeneratedCodeAttribute).Assembly.Location,
                    Path.Combine(assemblyPath, "System.Runtime.dll"),
                    Path.Combine(assemblyPath, "netstandard.dll")
                };

                // Create compiler
                CSharpCompilation compilation = CSharpCompilation
                    .Create(assemblyName)
                    .WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
                    .AddReferences(assemblies.Select(path => MetadataReference.CreateFromFile(path)))
                    .AddSyntaxTrees(sourceFiles.Select(sourceFile =>
                    {
                        string sourceText = System.IO.File.ReadAllText(sourceFile);
                        return CSharpSyntaxTree.ParseText(sourceText);
                    }));

                // Compile
                EmitResult compilationResult = compilation.Emit(compilerStream);

#if DEBUG
                if (!compilationResult.Success)
                {
                    foreach (Diagnostic diagnostic in compilationResult.Diagnostics)
                    {
                        if (diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error)
                        {
                            TestContext.WriteLine($"{diagnostic.Id} - {diagnostic.GetMessage()} - {diagnostic.Location}");
                        }
                    }
                }
#endif

                Assert.That(compilationResult.Success, Is.True);

                if (!loadAssembly)
                {
                    return null;
                }

                // Load assembly from stream
                compilerStream.Seek(0, SeekOrigin.Begin);
                return Assembly.Load(compilerStream.ToArray());
            }
        }

        public static string CreateEmptyTemporaryFolder(out string uniqueId, string path = null)
        {
            // Create unique id
            uniqueId = Guid.NewGuid().ToString();

            // Temporary folder name in working folder or the specified path
            string tempFolder = Path.Combine(path ?? TestContext.CurrentContext.WorkDirectory, uniqueId);

            // Create folder
            Directory.CreateDirectory(tempFolder);

            // Make sure it is empty
            Assert.That(new DirectoryInfo(tempFolder), Is.Empty);

            return tempFolder;
        }

        public static Assembly CompileCSharpFilesAndCheckTypes(
            string outputDir,
            string assemblyName,
            IEnumerable<string> typeNamesToCheck = null,
            IEnumerable<string> generatedFilesToCheck = null)
        {
            // Check if all generated files exist
            if (generatedFilesToCheck != null)
            {
                foreach (string generatedFile in generatedFilesToCheck)
                {
                    Assert.That(new FileInfo(Path.Combine(outputDir, generatedFile)), Does.Exist);
                }
            }

            // Compile into netstandard library and load assembly
            Assembly assembly = CompileCSharpFilesIntoLibrary(
                new DirectoryInfo(outputDir)
                    .EnumerateFiles("*.cs", SearchOption.AllDirectories)
                    .Select(fi => fi.FullName),
                    assemblyName);

            if (typeNamesToCheck != null)
            {
                // Check if the compiled code has the same number of types defined as the check list
                // Note: Ignore types which are injected by the compiler (System.* and Microsoft.*), e.g. Microsoft.CodeAnalysis.EmbeddedAttribute
                Assert.That(
                    typeNamesToCheck.Count(),
                    Is.EqualTo(
                        assembly
                        .DefinedTypes
                        .Where(t =>
                        {
                            return !t.Namespace.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) &&
                                   !t.Namespace.StartsWith("System.", StringComparison.OrdinalIgnoreCase);
                        })
                        .Count()));

                // Check if types available in compiled assembly
                foreach (string typeName in typeNamesToCheck)
                {
                    Type type = assembly.GetType(typeName);
                    Assert.That(type, Is.Not.Null);

                    // Protocols are abstract and cannot be instantiated
                    if (typeof(ISpecificProtocol).IsAssignableFrom(type))
                    {
                        Assert.That(type.IsAbstract, Is.True);

                        // If directly inherited from ISpecificProtocol, use reflection to read static private field
                        // holding the protocol. Callback objects are not directly inherited from ISpecificProtocol,
                        // so private fields in the base class cannot be accessed
                        if (type.BaseType.Equals(typeof(ISpecificProtocol)))
                        {
                            // Use reflection to read static field, holding the protocol
                            FieldInfo protocolField = type.GetField("protocol", BindingFlags.NonPublic | BindingFlags.Static);
                            Protocol protocol = protocolField.GetValue(null) as Protocol;

                            Assert.That(protocol, Is.Not.Null);
                        }
                    }
                    else
                    {
                        Assert.That(type.IsClass || type.IsEnum, Is.True);

                        // Instantiate object
                        object obj = Activator.CreateInstance(type);
                        Assert.That(obj, Is.Not.Null);

                        // If ISpecificRecord, call its member for sanity check
                        if (obj is ISpecificRecord record)
                        {
                            // Read record's schema object
                            Assert.That(record.Schema, Is.Not.Null);
                            // Force exception by reading/writing invalid field
                            Assert.Throws<AvroRuntimeException>(() => record.Get(-1));
                            Assert.Throws<AvroRuntimeException>(() => record.Put(-1, null));
                        }
                    }
                }
            }

            return assembly;
        }

        public static Assembly TestSchema(
            string schema,
            IEnumerable<string> typeNamesToCheck = null,
            IEnumerable<KeyValuePair<string, string>> namespaceMapping = null,
            IEnumerable<string> generatedFilesToCheck = null,
            bool skipDirectories = false)
        {
            // Create temp folder
            string outputDir = CreateEmptyTemporaryFolder(out string uniqueId);

            try
            {
                // Save schema
                string schemaFileName = Path.Combine(outputDir, $"{uniqueId}.avsc");
                System.IO.File.WriteAllText(schemaFileName, schema);

                // Generate from schema file
                Assert.That(AvroGenTool.GenSchema(schemaFileName, outputDir, namespaceMapping ?? new Dictionary<string, string>(), skipDirectories), Is.EqualTo(0));

                return CompileCSharpFilesAndCheckTypes(outputDir, uniqueId, typeNamesToCheck, generatedFilesToCheck);
            }
            finally
            {
                Directory.Delete(outputDir, true);
            }
        }

        public static Assembly TestProtocol(
            string protocol,
            IEnumerable<string> typeNamesToCheck = null,
            IEnumerable<KeyValuePair<string, string>> namespaceMapping = null,
            IEnumerable<string> generatedFilesToCheck = null)
        {
            // Create temp folder
            string outputDir = CreateEmptyTemporaryFolder(out string uniqueId);

            try
            {
                // Save protocol
                string schemaFileName = Path.Combine(outputDir, $"{uniqueId}.avpr");
                System.IO.File.WriteAllText(schemaFileName, protocol);

                // Generate from protocol file
                Assert.That(AvroGenTool.GenProtocol(schemaFileName, outputDir, namespaceMapping ?? new Dictionary<string, string>()), Is.EqualTo(0));

                return CompileCSharpFilesAndCheckTypes(outputDir, uniqueId, typeNamesToCheck, generatedFilesToCheck);
            }
            finally
            {
                Directory.Delete(outputDir, true);
            }
        }
    }
}
