
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Management.Automation.Language;
using System.Text;
using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;

namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
{

    /// <summary>
    /// Rule to diagnose syntactic incompatibilities with scripts
    /// across PowerShell versions.
    /// </summary>
#if !CORECLR
    [System.ComponentModel.Composition.Export(typeof(IScriptRule))]
#endif
    public class UseCompatibleSyntax : ConfigurableRule
    {
        private static readonly Version s_v3 = new Version(3,0);

        private static readonly Version s_v4 = new Version(4,0);

        private static readonly Version s_v5 = new Version(5,0);

        private static readonly Version s_v6 = new Version(6,0);

        private static readonly Version s_v7 = new Version(7,0);

        private static readonly IReadOnlyList<Version> s_targetableVersions = new []
        {
            s_v3,
            s_v4,
            s_v5,
            s_v6,
            s_v7,
        };

        /// <summary>
        /// The versions of PowerShell for the rule to target.
        /// </summary>
        [ConfigurableRuleProperty(new string[]{})]
        public string[] TargetVersions { get; set; }

        /// <summary>
        /// The severity of diagnostics generated by this rule.
        /// </summary>
        public DiagnosticSeverity Severity => DiagnosticSeverity.Error;

        /// <summary>
        /// Analyze the given PowerShell AST for incompatible syntax usage.
        /// </summary>
        /// <param name="ast">The PowerShell AST to analyze.</param>
        /// <param name="fileName">The name of the PowerShell file being analyzed.</param>
        /// <returns>Diagnostics for any issues found while analyzing the given AST.</returns>
        public override IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName)
        {
            HashSet<Version> targetVersions = GetTargetedVersions(TargetVersions);

            var visitor = new SyntaxCompatibilityVisitor(this, fileName, targetVersions);
            ast.Visit(visitor);
            return visitor.GetDiagnosticRecords();
        }

        /// <summary>
        /// Get the common name of this rule.
        /// </summary>
        public override string GetCommonName()
        {
            return string.Format(
                CultureInfo.CurrentCulture,
                Strings.UseCompatibleSyntaxCommonName);
        }

        /// <summary>
        /// Get the description of this rule.
        /// </summary>
        public override string GetDescription()
        {
            return string.Format(
                CultureInfo.CurrentCulture,
                Strings.UseCompatibleSyntaxDescription);
        }

        /// <summary>
        /// Get the localized name of this rule.
        /// </summary>
        public override string GetName()
        {
            return string.Format(
                CultureInfo.CurrentCulture,
                Strings.NameSpaceFormat,
                GetSourceName(),
                Strings.UseCompatibleSyntaxName);
        }

        /// <summary>
        /// Get the severity of this rule.
        /// </summary>
        public override RuleSeverity GetSeverity()
        {
            return RuleSeverity.Error;
        }

        /// <summary>
        /// Get the name of the source of this rule.
        /// </summary>
        public override string GetSourceName()
        {
            return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
        }

        /// <summary>
        /// Get the type of the source of this rule.
        /// </summary>
        public override SourceType GetSourceType()
        {
            return SourceType.Builtin;
        }

        private static HashSet<Version> GetTargetedVersions(string[] versionSettings)
        {
            if (versionSettings == null || versionSettings.Length <= 0)
            {
                return new HashSet<Version>(){ s_v5, s_v6, s_v7 };
            }

            var targetVersions = new HashSet<Version>();
            foreach (string versionStr in versionSettings)
            {
                if (!Version.TryParse(versionStr, out Version version))
                {
                    throw new ArgumentException($"Invalid version string: {versionStr}");
                }

                foreach (Version targetableVersion in s_targetableVersions)
                {
                    if (version.Major == targetableVersion.Major)
                    {
                        targetVersions.Add(targetableVersion);
                        break;
                    }
                }
            }
            return targetVersions;
        }

#if !(PSV3 || PSV4)
        private class SyntaxCompatibilityVisitor : AstVisitor2
#else
        private class SyntaxCompatibilityVisitor : AstVisitor
#endif
        {
            private readonly UseCompatibleSyntax _rule;

            private readonly string _analyzedFilePath;

            private readonly HashSet<Version> _targetVersions;

            private readonly List<DiagnosticRecord> _diagnosticAccumulator;

            public SyntaxCompatibilityVisitor(
                UseCompatibleSyntax rule,
                string analyzedScriptPath,
                HashSet<Version> targetVersions)
            {
                _diagnosticAccumulator = new List<DiagnosticRecord>();
                _rule = rule;
                _analyzedFilePath = analyzedScriptPath;
                _targetVersions = targetVersions;
            }

            public IEnumerable<DiagnosticRecord> GetDiagnosticRecords()
            {
                return _diagnosticAccumulator;
            }

            public override AstVisitAction VisitInvokeMemberExpression(InvokeMemberExpressionAst methodCallAst)
            {
                // Look for [typename]::new(...) and [typename]::$dynamicMethodName syntax

#if PSV7
                if (!TargetsNonPS7())
                {
                    return AstVisitAction.Continue;
                }

                if (methodCallAst.NullConditional)
                {
                    AddDiagnostic(
                        methodCallAst,
                        "null-conditional method invocation",
                        "${x}?.Method()",
                        "3,4,5,6");
                }
#endif

                if (!_targetVersions.Contains(s_v3) && !_targetVersions.Contains(s_v4))
                {
                    return AstVisitAction.Continue;
                }

                if (_targetVersions.Contains(s_v3) && methodCallAst.Member is VariableExpressionAst)
                {
                    AddDiagnostic(
                        methodCallAst,
                        "dynamic method invocation",
                        "$x.$method()",
                        "3");
                }

                if (!(methodCallAst.Expression is TypeExpressionAst typeExpressionAst))
                {
                    return AstVisitAction.Continue;
                }

                if (!(methodCallAst.Member is StringConstantExpressionAst stringConstantAst))
                {
                    return AstVisitAction.Continue;
                }

                if (stringConstantAst.Value.Equals("new", StringComparison.OrdinalIgnoreCase))
                {
                    string typeName = typeExpressionAst.TypeName.FullName;

                    CorrectionExtent suggestedCorrection = CreateNewObjectCorrection(
                        _analyzedFilePath,
                        methodCallAst.Extent,
                        typeName,
                        methodCallAst.Arguments);

                    AddDiagnostic(methodCallAst, "constructor", "[type]::new()", "3,4", suggestedCorrection);

                    return AstVisitAction.Continue;
                }

                return AstVisitAction.Continue;
            }

            public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst functionDefinitionAst)
            {
                // Look for PowerShell workflows in the script

                if (!(_targetVersions.Contains(s_v6) || _targetVersions.Contains(s_v7)))
                {
                    return AstVisitAction.Continue;
                }

                if (!functionDefinitionAst.IsWorkflow)
                {
                    return AstVisitAction.Continue;
                }

                AddDiagnostic(functionDefinitionAst, "workflow", "workflow { ... }", "6,7");

                return AstVisitAction.Continue;
            }

#if !(PSV3 || PSV4)
            public override AstVisitAction VisitUsingStatement(UsingStatementAst usingStatementAst)
            {
                // Look for 'using ...;' at the top of scripts

                if (!_targetVersions.Contains(s_v3) && !_targetVersions.Contains(s_v4))
                {
                    return AstVisitAction.Continue;
                }

                AddDiagnostic(
                    usingStatementAst,
                    "using statement",
                    "using ...;",
                    "3,4");

                return AstVisitAction.Continue;
            }

            public override AstVisitAction VisitTypeDefinition(TypeDefinitionAst typeDefinitionAst)
            {
                // Look for 'class MyClass { ... }' and 'enum MyEnum { ... }' definitions

                if (!_targetVersions.Contains(s_v3) && !_targetVersions.Contains(s_v4))
                {
                    return AstVisitAction.Continue;
                }

                string message = string.Format(
                    CultureInfo.CurrentCulture,
                    Strings.UseCompatibleSyntaxError,
                    "type definition",
                    "class MyClass { ... } | enum MyEnum { ... }",
                    "3,4");

                _diagnosticAccumulator.Add(
                    new DiagnosticRecord(
                        message,
                        typeDefinitionAst.Extent,
                        _rule.GetName(),
                        _rule.Severity,
                        _analyzedFilePath
                    ));

                return AstVisitAction.Continue;
            }
#endif

#if PSV7
            public override AstVisitAction VisitMemberExpression(MemberExpressionAst memberExpressionAst)
            {
                if (!TargetsNonPS7())
                {
                    return AstVisitAction.Continue;
                }

                if (memberExpressionAst.NullConditional)
                {
                    AddDiagnostic(
                        memberExpressionAst,
                        "null-conditional member access",
                        "${x}?.Member",
                        "3,4,5,6");
                }

                return AstVisitAction.Continue;
            }

            public override AstVisitAction VisitAssignmentStatement(AssignmentStatementAst assignmentStatementAst)
            {
                if (!TargetsNonPS7())
                {
                    return AstVisitAction.Continue;
                }

                if (assignmentStatementAst.Operator == TokenKind.QuestionQuestionEquals)
                {
                    AddDiagnostic(assignmentStatementAst, "null-conditional assignment", "$x ??= $y", "3,4,5,6");
                }

                return AstVisitAction.Continue;
            }

            public override AstVisitAction VisitBinaryExpression(BinaryExpressionAst binaryExpressionAst)
            {
                if (!TargetsNonPS7())
                {
                    return AstVisitAction.Continue;
                }

                if (binaryExpressionAst.Operator == TokenKind.QuestionQuestion)
                {
                    AddDiagnostic(
                        binaryExpressionAst,
                        "null-coalescing operator",
                        "$x ?? $y",
                        "3,4,5,6");
                }

                return AstVisitAction.Continue;
            }

            public override AstVisitAction VisitTernaryExpression(TernaryExpressionAst ternaryExpressionAst)
            {
                if (!TargetsNonPS7())
                {
                    return AstVisitAction.Continue;
                }

                var correction = new CorrectionExtent(
                    ternaryExpressionAst.Extent,
                    $"if ({ternaryExpressionAst.Condition.Extent.Text}) {{ {ternaryExpressionAst.IfTrue.Extent.Text} }} else {{ {ternaryExpressionAst.IfFalse.Extent.Text} }}",
                    _analyzedFilePath);

                AddDiagnostic(
                    ternaryExpressionAst,
                    "ternary expression",
                    "<test> ? <exp1> : <exp2>",
                    "3,4,5,6",
                    correction);

                return AstVisitAction.Continue;
            }

            public override AstVisitAction VisitPipelineChain(PipelineChainAst statementChain)
            {
                if (!TargetsNonPS7())
                {
                    return AstVisitAction.Continue;
                }

                AddDiagnostic(
                    statementChain,
                    "pipeline chain",
                    "<pipeline1> && <pipeline2> OR <pipeline1> || <pipeline2>",
                    "3,4,5,6");

                return AstVisitAction.Continue;
            }

            private bool TargetsNonPS7()
            {
                return _targetVersions.Contains(s_v3)
                    || _targetVersions.Contains(s_v4)
                    || _targetVersions.Contains(s_v5)
                    || _targetVersions.Contains(s_v6);
            }
#endif

            private void AddDiagnostic(
                Ast offendingAst,
                string syntaxName,
                string syntaxExample,
                string unsupportedVersions,
                CorrectionExtent correction = null)
            {
                string message = string.Format(
                    CultureInfo.CurrentCulture,
                    Strings.UseCompatibleSyntaxError,
                    syntaxName,
                    syntaxExample,
                    unsupportedVersions);

                if (correction == null)
                {
                    _diagnosticAccumulator.Add(
                        new DiagnosticRecord(
                            message,
                            offendingAst.Extent,
                            _rule.GetName(),
                            _rule.Severity,
                            _analyzedFilePath));

                    return;
                }

                _diagnosticAccumulator.Add(
                    new DiagnosticRecord(
                        message,
                        offendingAst.Extent,
                        _rule.GetName(),
                        _rule.Severity,
                        _analyzedFilePath,
                        ruleId: null,
                        new[] { correction }));
            }

            private static CorrectionExtent CreateNewObjectCorrection(
                string filePath,
                IScriptExtent offendingExtent,
                string typeName,
                IReadOnlyList<ExpressionAst> argumentAsts)
            {
                var sb = new StringBuilder("New-Object ")
                    .Append('\'')
                    .Append(typeName)
                    .Append('\'');

                if (argumentAsts != null && argumentAsts.Count > 0)
                {
                    sb.Append(" @(");
                    int i = 0;
                    for (; i < argumentAsts.Count - 1; i++)
                    {
                        ExpressionAst argAst = argumentAsts[i];
                        sb.Append(argAst.Extent.Text).Append(", ");
                    }
                    sb.Append(argumentAsts[i].Extent.Text).Append(")");
                }

                return new CorrectionExtent(
                    offendingExtent,
                    sb.ToString(),
                    filePath,
                    string.Format(
                        CultureInfo.CurrentCulture,
                        Strings.UseCompatibleSyntaxCorrection,
                        "New-Object @($arg1, $arg2, ...)",
                        "3,4"));
            }
        }
    }
}
