// 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.Immutable;
using Microsoft.CodeAnalysis.CodeStyle;
using Microsoft.CodeAnalysis.CSharp.CodeStyle;
using Microsoft.CodeAnalysis.CSharp.Diagnostics;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeAnalysis.CSharp.InvokeDelegateWithConditionalAccess
{
    internal static class Constants
    {
        public const string Kind = nameof(Kind);
        public const string VariableAndIfStatementForm = nameof(VariableAndIfStatementForm);
        public const string SingleIfStatementForm = nameof(SingleIfStatementForm);
    }

    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    internal class InvokeDelegateWithConditionalAccessAnalyzer : AbstractBuiltInCodeStyleDiagnosticAnalyzer
    {
        public InvokeDelegateWithConditionalAccessAnalyzer()
            : base(IDEDiagnosticIds.InvokeDelegateWithConditionalAccessId,
                   EnforceOnBuildValues.InvokeDelegateWithConditionalAccess,
                   CSharpCodeStyleOptions.PreferConditionalDelegateCall,
                   new LocalizableResourceString(nameof(CSharpAnalyzersResources.Delegate_invocation_can_be_simplified), CSharpAnalyzersResources.ResourceManager, typeof(CSharpAnalyzersResources)))
        {
        }

        protected override void InitializeWorker(AnalysisContext context)
            => context.RegisterSyntaxNodeAction(SyntaxNodeAction, SyntaxKind.IfStatement);

        private void SyntaxNodeAction(SyntaxNodeAnalysisContext syntaxContext)
        {
            var styleOption = syntaxContext.GetCSharpAnalyzerOptions().PreferConditionalDelegateCall;
            if (!styleOption.Value)
            {
                // Bail if the user has disabled this feature.
                return;
            }

            // look for the form "if (a != null)" or "if (null != a)"
            var ifStatement = (IfStatementSyntax)syntaxContext.Node;

            // ?. is only available in C# 6.0 and above.  Don't offer this refactoring
            // in projects targeting a lesser version.
            if (ifStatement.SyntaxTree.Options.LanguageVersion() < LanguageVersion.CSharp6)
            {
                return;
            }

            if (!ifStatement.Condition.IsKind(SyntaxKind.NotEqualsExpression))
            {
                return;
            }

            if (ifStatement.Else != null)
            {
                return;
            }

            // Check for both:  "if (...) { a(); }" and "if (...) a();"
            var innerStatement = ifStatement.Statement;
            if (innerStatement is BlockSyntax block)
            {
                if (block.Statements.Count != 1)
                {
                    return;
                }

                innerStatement = block.Statements[0];
            }

            if (innerStatement is not ExpressionStatementSyntax expressionStatement)
            {
                return;
            }

            // Check that it's of the form: "if (a != null) { a(); }
            if (expressionStatement.Expression is not InvocationExpressionSyntax invocationExpression)
            {
                return;
            }

            // Function pointers can only be invoked directly via the "()" operator, not "?.Invoke()".
            if (syntaxContext.SemanticModel.GetTypeInfo(invocationExpression.Expression, syntaxContext.CancellationToken).Type is { TypeKind: TypeKind.FunctionPointer })
            {
                return;
            }

            var severity = styleOption.Notification.Severity;
            var condition = (BinaryExpressionSyntax)ifStatement.Condition;
            if (TryCheckVariableAndIfStatementForm(
                    syntaxContext, ifStatement, condition,
                    expressionStatement, invocationExpression,
                    severity))
            {
                return;
            }

            TryCheckSingleIfStatementForm(
                syntaxContext, ifStatement, condition,
                expressionStatement, invocationExpression,
                severity);
        }

        private bool TryCheckSingleIfStatementForm(
            SyntaxNodeAnalysisContext syntaxContext,
            IfStatementSyntax ifStatement,
            BinaryExpressionSyntax condition,
            ExpressionStatementSyntax expressionStatement,
            InvocationExpressionSyntax invocationExpression,
            ReportDiagnostic severity)
        {
            // Look for the form:  "if (someExpr != null) someExpr()"
            if (condition.Left.IsKind(SyntaxKind.NullLiteralExpression) ||
                condition.Right.IsKind(SyntaxKind.NullLiteralExpression))
            {
                var expr = condition.Left.IsKind(SyntaxKind.NullLiteralExpression)
                    ? condition.Right
                    : condition.Left;

                if (InvocationExpressionIsEquivalent(expr, invocationExpression))
                {
                    // Looks good!
                    var tree = syntaxContext.SemanticModel.SyntaxTree;
                    var additionalLocations = ImmutableArray.Create<Location>(
                        Location.Create(tree, ifStatement.Span),
                        Location.Create(tree, expressionStatement.Span));

                    ReportDiagnostics(
                        syntaxContext, ifStatement, ifStatement,
                        expressionStatement, severity, additionalLocations,
                        Constants.SingleIfStatementForm);

                    return true;
                }
            }

            return false;

            static bool InvocationExpressionIsEquivalent(ExpressionSyntax expression, InvocationExpressionSyntax invocationExpression)
            {
                // expr(...)
                if (SyntaxFactory.AreEquivalent(expression, invocationExpression.Expression, topLevel: false))
                    return true;

                // expr.Invoke(...);
                if (invocationExpression.Expression is MemberAccessExpressionSyntax { Name: IdentifierNameSyntax { Identifier.ValueText: nameof(Action.Invoke) } } memberAccessExpression &&
                    SyntaxFactory.AreEquivalent(expression, memberAccessExpression.Expression, topLevel: false))
                {
                    return true;
                }

                return false;
            }
        }

        private void ReportDiagnostics(
            SyntaxNodeAnalysisContext syntaxContext,
            StatementSyntax firstStatement,
            IfStatementSyntax ifStatement,
            ExpressionStatementSyntax expressionStatement,
            ReportDiagnostic severity,
            ImmutableArray<Location> additionalLocations,
            string kind)
        {
            var tree = syntaxContext.Node.SyntaxTree;

            var properties = ImmutableDictionary<string, string?>.Empty.Add(
                Constants.Kind, kind);

            var previousToken = expressionStatement.GetFirstToken().GetPreviousToken();
            var nextToken = expressionStatement.GetLastToken().GetNextToken();

            // Fade out the code up to the expression statement.
            var fadeLocation = Location.Create(tree, TextSpan.FromBounds(firstStatement.SpanStart, previousToken.Span.End));
            syntaxContext.ReportDiagnostic(DiagnosticHelper.CreateWithLocationTags(
                Descriptor,
                fadeLocation,
                ReportDiagnostic.Default,
                additionalLocations,
                additionalUnnecessaryLocations: ImmutableArray.Create(fadeLocation),
                properties));

            // Put a diagnostic with the appropriate severity on the expression-statement itself.
            syntaxContext.ReportDiagnostic(DiagnosticHelper.Create(
                Descriptor,
                expressionStatement.GetLocation(),
                severity,
                additionalLocations, properties));

            // If the if-statement extends past the expression statement, then fade out the rest.
            if (nextToken.Span.Start < ifStatement.Span.End)
            {
                fadeLocation = Location.Create(tree, TextSpan.FromBounds(nextToken.Span.Start, ifStatement.Span.End));
                syntaxContext.ReportDiagnostic(DiagnosticHelper.CreateWithLocationTags(
                    Descriptor,
                    fadeLocation,
                    ReportDiagnostic.Default,
                    additionalLocations,
                    additionalUnnecessaryLocations: ImmutableArray.Create(fadeLocation),
                    properties));
            }
        }

        private bool TryCheckVariableAndIfStatementForm(
            SyntaxNodeAnalysisContext syntaxContext,
            IfStatementSyntax ifStatement,
            BinaryExpressionSyntax condition,
            ExpressionStatementSyntax expressionStatement,
            InvocationExpressionSyntax invocationExpression,
            ReportDiagnostic severity)
        {
            var cancellationToken = syntaxContext.CancellationToken;
            cancellationToken.ThrowIfCancellationRequested();

            // look for the form "if (a != null)" or "if (null != a)"
            if (!ifStatement.Parent.IsKind(SyntaxKind.Block))
            {
                return false;
            }

            if (!IsNullCheckExpression(condition.Left, condition.Right) &&
                !IsNullCheckExpression(condition.Right, condition.Left))
            {
                return false;
            }

            var invocationName = invocationExpression.Expression switch
            {
                IdentifierNameSyntax identifier => identifier,
                MemberAccessExpressionSyntax
                {
                    Name: IdentifierNameSyntax { Identifier.ValueText: nameof(Action.Invoke) },
                    Expression: IdentifierNameSyntax identifier
                } => identifier,
                _ => null
            };

            if (invocationName is null)
                return false;

            var conditionName = condition.Left is IdentifierNameSyntax
                ? (IdentifierNameSyntax)condition.Left
                : (IdentifierNameSyntax)condition.Right;

            if (!Equals(conditionName.Identifier.ValueText, invocationName.Identifier.ValueText))
            {
                return false;
            }

            // Now make sure the previous statement is "var a = ..."
            var parentBlock = (BlockSyntax)ifStatement.Parent;
            var ifIndex = parentBlock.Statements.IndexOf(ifStatement);
            if (ifIndex == 0)
            {
                return false;
            }

            var previousStatement = parentBlock.Statements[ifIndex - 1];
            if (previousStatement is not LocalDeclarationStatementSyntax localDeclarationStatement)
            {
                return false;
            }

            var variableDeclaration = localDeclarationStatement.Declaration;

            if (variableDeclaration.Variables.Count != 1)
            {
                return false;
            }

            var declarator = variableDeclaration.Variables[0];
            if (declarator.Initializer == null)
            {
                return false;
            }

            cancellationToken.ThrowIfCancellationRequested();
            if (!Equals(declarator.Identifier.ValueText, conditionName.Identifier.ValueText))
            {
                return false;
            }

            // Syntactically this looks good.  Now make sure that the local is a delegate type.
            var semanticModel = syntaxContext.SemanticModel;

            // The initializer can't be inlined if it's an actual lambda/method reference.
            // These cannot be invoked with `?.` (only delegate *values* can be).
            var initializer = declarator.Initializer.Value.WalkDownParentheses();
            if (initializer.IsAnyLambdaOrAnonymousMethod())
            {
                return false;
            }

            var initializerSymbol = semanticModel.GetSymbolInfo(initializer, cancellationToken).GetAnySymbol();
            if (initializerSymbol is IMethodSymbol)
            {
                return false;
            }

            var localSymbol = (ILocalSymbol)semanticModel.GetRequiredDeclaredSymbol(declarator, cancellationToken);

            // Ok, we made a local just to check it for null and invoke it.  Looks like something
            // we can suggest an improvement for!
            // But first make sure we're only using the local only within the body of this if statement.
            var analysis = semanticModel.AnalyzeDataFlow(localDeclarationStatement, ifStatement);
            if (analysis == null || analysis.ReadOutside.Contains(localSymbol) || analysis.WrittenOutside.Contains(localSymbol))
                return false;

            // Looks good!
            var tree = semanticModel.SyntaxTree;
            var additionalLocations = ImmutableArray.Create(
                Location.Create(tree, localDeclarationStatement.Span),
                Location.Create(tree, ifStatement.Span),
                Location.Create(tree, expressionStatement.Span));

            ReportDiagnostics(syntaxContext,
                localDeclarationStatement, ifStatement, expressionStatement,
                severity, additionalLocations, Constants.VariableAndIfStatementForm);

            return true;
        }

        private static bool IsNullCheckExpression(ExpressionSyntax left, ExpressionSyntax right)
            => left.IsKind(SyntaxKind.IdentifierName) && right.IsKind(SyntaxKind.NullLiteralExpression);

        public override DiagnosticAnalyzerCategory GetAnalyzerCategory()
            => DiagnosticAnalyzerCategory.SemanticSpanAnalysis;
    }
}
