// 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.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeAnalysis.Completion.Providers;

internal abstract class AbstractInternalsVisibleToCompletionProvider : LSPCompletionProvider
{
    private const string ProjectGuidKey = nameof(ProjectGuidKey);

    protected abstract IImmutableList<SyntaxNode> GetAssemblyScopedAttributeSyntaxNodesOfDocument(SyntaxNode documentRoot);
    protected abstract SyntaxNode? GetConstructorArgumentOfInternalsVisibleToAttribute(SyntaxNode internalsVisibleToAttribute);

    public sealed override bool IsInsertionTrigger(SourceText text, int insertedCharacterPosition, CompletionOptions options)
    {
        // Should trigger in these cases ($$ is the cursor position)
        // [InternalsVisibleTo($$         -> user enters "
        // [InternalsVisibleTo("$$")]     -> user enters any character
        var ch = text[insertedCharacterPosition];
        if (ch == '\"')
        {
            return true;
        }
        else
        {
            if (insertedCharacterPosition > 0)
            {
                ch = text[insertedCharacterPosition - 1];
                if (ch == '\"')
                {
                    return ShouldTriggerAfterQuotes(text, insertedCharacterPosition);
                }
            }
        }

        return false;
    }

    protected abstract bool ShouldTriggerAfterQuotes(SourceText text, int insertedCharacterPosition);

    public override ImmutableHashSet<char> TriggerCharacters { get; } = ['\"'];

    public override async Task ProvideCompletionsAsync(CompletionContext context)
    {
        try
        {
            var cancellationToken = context.CancellationToken;
            var syntaxTree = await context.Document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
            var syntaxFactsService = context.Document.GetRequiredLanguageService<ISyntaxFactsService>();
            if (syntaxFactsService.IsEntirelyWithinStringOrCharOrNumericLiteral(syntaxTree, context.Position, cancellationToken))
            {
                var token = syntaxTree.FindTokenOnLeftOfPosition(context.Position, cancellationToken);
                var attributeSyntaxNode = GetAttributeSyntaxNodeOfToken(syntaxFactsService, token);
                if (attributeSyntaxNode == null)
                {
                    return;
                }

                if (await CheckTypeInfoOfAttributeAsync(context.Document, attributeSyntaxNode, context.CancellationToken).ConfigureAwait(false))
                {
                    await AddAssemblyCompletionItemsAsync(context, cancellationToken).ConfigureAwait(false);
                }
            }
        }
        catch (Exception e) when (FatalError.ReportAndCatchUnlessCanceled(e, ErrorSeverity.General))
        {
            // nop
        }
    }

    private static SyntaxNode? GetAttributeSyntaxNodeOfToken(ISyntaxFactsService syntaxFactsService, SyntaxToken token)
    {
        //Supported cases:
        //[Attribute("|
        //[Attribute(parameterName:"Text|")
        //Also supported but excluded by IsPositionEntirelyWithinStringLiteral in ProvideCompletionsAsync
        //[Attribute(""|
        //[Attribute("Text"|)
        var node = token.Parent;
        if (node != null && syntaxFactsService.IsStringLiteralExpression(node))
        {
            // Edge cases: 
            // ElementAccessExpressionSyntax is present if the following statement is another attribute:
            //   [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("|
            //   [assembly: System.Reflection.AssemblyVersion("1.0.0.0")]
            //   [assembly: System.Reflection.AssemblyCompany("Test")]
            // BinaryExpression is present if the string literal is concatenated:
            //   From: https://msdn.microsoft.com/de-de/library/system.runtime.compilerservices.internalsvisibletoattribute(v=vs.110).aspx
            //   [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Friend1, PublicKey=002400000480000094" + 
            //                                                                 "0000000602000000240000525341310004000" + ..
            while (syntaxFactsService.IsElementAccessExpression(node.Parent) || syntaxFactsService.IsBinaryExpression(node.Parent))
            {
                node = node.Parent;
            }

            // node -> AttributeArgumentSyntax -> AttributeArgumentListSyntax -> AttributeSyntax
            var attributeSyntaxNodeCandidate = node.Parent?.Parent?.Parent;
            if (syntaxFactsService.IsAttribute(attributeSyntaxNodeCandidate))
            {
                return attributeSyntaxNodeCandidate;
            }
        }

        return null;
    }

    private static async Task<bool> CheckTypeInfoOfAttributeAsync(Document document, SyntaxNode attributeNode, CancellationToken cancellationToken)
    {
        var semanticModel = await document.ReuseExistingSpeculativeModelAsync(attributeNode, cancellationToken).ConfigureAwait(false);
        var typeInfo = semanticModel.GetTypeInfo(attributeNode, cancellationToken);
        var type = typeInfo.Type;
        if (type == null)
        {
            return false;
        }

        var internalsVisibleToAttributeSymbol = semanticModel.Compilation.GetTypeByMetadataName(typeof(InternalsVisibleToAttribute).FullName!);
        return type.Equals(internalsVisibleToAttributeSymbol);
    }

    private async Task AddAssemblyCompletionItemsAsync(CompletionContext context, CancellationToken cancellationToken)
    {
        var currentProject = context.Document.Project;
        var allInternalsVisibleToAttributesOfProject = await GetAllInternalsVisibleToAssemblyNamesOfProjectAsync(context, cancellationToken).ConfigureAwait(false);
        foreach (var project in context.Document.Project.Solution.Projects)
        {
            if (project == currentProject)
            {
                continue;
            }

            if (IsProjectTypeUnsupported(project))
            {
                continue;
            }

            if (allInternalsVisibleToAttributesOfProject.Contains(project.AssemblyName))
            {
                continue;
            }

            var projectGuid = project.Id.Id.ToString();
            var completionItem = CommonCompletionItem.Create(
                displayText: project.AssemblyName,
                displayTextSuffix: "",
                rules: CompletionItemRules.Default,
                glyph: project.GetGlyph(),
                properties: [KeyValuePair.Create(ProjectGuidKey, projectGuid)]);
            context.AddItem(completionItem);
        }

        if (context.Items.Count > 0)
        {
            context.CompletionListSpan = await GetTextChangeSpanAsync(
                context.Document, context.CompletionListSpan, cancellationToken).ConfigureAwait(false);
        }
    }

    private static bool IsProjectTypeUnsupported(Project project)
        => !project.SupportsCompilation;

    private async Task<IImmutableSet<string>> GetAllInternalsVisibleToAssemblyNamesOfProjectAsync(CompletionContext completionContext, CancellationToken cancellationToken)
    {
        // Looking up other InternalsVisibleTo attributes of this project. This is faster than compiling all projects of the solution and checking access via 
        // sourceAssembly.GivesAccessTo(compilation.Assembly)
        // at the cost of being not so precise (can't check the validity of the PublicKey).
        var project = completionContext.Document.Project;
        var resultBuilder = (ImmutableHashSet<string>.Builder?)null;
        foreach (var document in project.Documents)
        {
            var syntaxRoot = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
            var assemblyScopedAttributes = GetAssemblyScopedAttributeSyntaxNodesOfDocument(syntaxRoot);
            foreach (var attribute in assemblyScopedAttributes)
            {
                // Skip attributes with errors. This skips the attribute that is currently edited, until it is complete:
                // [assembly: InternalsVisibleTo("$$
                // CS1003: Syntax error, ']' expected; CS1010: A string was not properly delimited; CS1026: An incomplete statement was found
                // see also SyntaxNode.HasErrors
                if (attribute.ContainsDiagnostics)
                {
                    foreach (var diagnostic in attribute.GetDiagnostics())
                    {
                        if (diagnostic.Severity == DiagnosticSeverity.Error)
                        {
                            continue;
                        }
                    }
                }

                if (await CheckTypeInfoOfAttributeAsync(document, attribute, completionContext.CancellationToken).ConfigureAwait(false))
                {
                    // See Microsoft.CodeAnalysis.PEAssembly.BuildInternalsVisibleToMap for reference on how
                    // the 'real' InternalsVisibleTo logic extracts and compares the assemblyName:
                    // * Extract the assemblyName by AssemblyIdentity.TryParseDisplayName
                    // * Compare with StringComparer.OrdinalIgnoreCase
                    // We take the same approach, but we do only a limited check of the PublicKey. 
                    // The PublicKey is checked by AssemblyIdentity.TryParseDisplayName to be 
                    // parseable (length, can be converted to bytes, etc.), but it is not tested whether 
                    // the public key actually fits to the assembly.
                    var assemblyName = await GetAssemblyNameFromInternalsVisibleToAttributeAsync(document, attribute, completionContext.CancellationToken).ConfigureAwait(false);
                    if (!string.IsNullOrWhiteSpace(assemblyName))
                    {
                        resultBuilder ??= ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
                        resultBuilder.Add(assemblyName);
                    }
                }
            }
        }

        return resultBuilder == null
            ? []
            : resultBuilder.ToImmutable();
    }

    private async Task<string> GetAssemblyNameFromInternalsVisibleToAttributeAsync(Document document, SyntaxNode node, CancellationToken cancellationToken)
    {
        var constructorArgument = GetConstructorArgumentOfInternalsVisibleToAttribute(node);
        if (constructorArgument == null)
        {
            return string.Empty;
        }

        var semanticModel = await document.ReuseExistingSpeculativeModelAsync(constructorArgument, cancellationToken).ConfigureAwait(false);
        var constantCandidate = semanticModel.GetConstantValue(constructorArgument, cancellationToken);
        if (constantCandidate.HasValue && constantCandidate.Value is string argument)
        {
            if (AssemblyIdentity.TryParseDisplayName(argument, out var assemblyIdentity))
            {
                return assemblyIdentity.Name;
            }
        }

        return string.Empty;
    }

    private static async Task<TextSpan> GetTextChangeSpanAsync(Document document, TextSpan startSpan, CancellationToken cancellationToken)
    {
        var result = startSpan;
        var syntaxFacts = document.GetRequiredLanguageService<ISyntaxFactsService>();
        var root = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
        var token = root.FindToken(result.Start);
        if (syntaxFacts.IsStringLiteral(token) || syntaxFacts.IsVerbatimStringLiteral(token))
        {
            var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);

            // Expand selection in both directions until a double quote or any line break character is reached
            static bool IsWordCharacter(char ch) => !(ch == '"' || TextUtilities.IsAnyLineBreakCharacter(ch));

            result = CommonCompletionUtilities.GetWordSpan(
                text, startSpan.Start, IsWordCharacter, IsWordCharacter, alwaysExtendEndSpan: true);
        }

        return result;
    }

    public override async Task<CompletionChange> GetChangeAsync(Document document, CompletionItem item, char? commitKey = null, CancellationToken cancellationToken = default)
    {
        var projectIdGuid = item.GetProperty(ProjectGuidKey);
        var projectId = ProjectId.CreateFromSerialized(new Guid(projectIdGuid));
        var project = document.Project.Solution.GetRequiredProject(projectId);
        var assemblyName = item.DisplayText;
        var publicKey = await GetPublicKeyOfProjectAsync(project, cancellationToken).ConfigureAwait(false);
        if (!string.IsNullOrEmpty(publicKey))
        {
            assemblyName += $", PublicKey={publicKey}";
        }

        var textChange = new TextChange(item.Span, assemblyName);
        return CompletionChange.Create(textChange);
    }

    private static async Task<string> GetPublicKeyOfProjectAsync(Project project, CancellationToken cancellationToken)
    {
        var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false);
        if (compilation?.Assembly?.Identity?.IsStrongName == true)
        {
            return GetPublicKeyAsHexString(compilation.Assembly.Identity.PublicKey);
        }

        return string.Empty;
    }

    private static string GetPublicKeyAsHexString(ImmutableArray<byte> publicKey)
    {
        var pooledStrBuilder = PooledStringBuilder.GetInstance();
        var builder = pooledStrBuilder.Builder;
        foreach (var b in publicKey)
        {
            builder.Append(b.ToString("x2"));
        }

        return pooledStrBuilder.ToStringAndFree();
    }
}
