// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Build.Collections;
using Microsoft.Build.Construction;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Exceptions;
using Microsoft.Build.Execution;
using Microsoft.Build.Shared.FileSystem;
using Xunit;
using Xunit.Abstractions;

#nullable disable

namespace Microsoft.Build.UnitTests
{
    public class ExpressionTest : IDisposable
    {
        private readonly ITestOutputHelper output;


        private static readonly string[] FilesWithExistenceChecks = { "a", "c", "a;b", "a'b", ";", "'" };

        private readonly Expander<ProjectPropertyInstance, ProjectItemInstance> _expander;

        public static readonly IEnumerable<object[]> TrueTests = new[]
        {
            "true or (SHOULDNOTEVALTHIS)", // short circuit
            "(true and false) or true",
            "false or true or false",
            "(true) and (true)",
            "false or !false",
            "($(a) or true)",
            "('$(c)'==1 and (!false))",
            "@(z -> '%(filename).z', '$')=='xxx.z$yyy.z'",
            "@(w -> '%(definingprojectname).barproj') == 'foo.barproj'",
            "false or (false or (false or (false or (false or (true)))))",
            "!(true and false)",
            "$(and)=='and'",
            "0x1==1.0",
            "0xa==10",
            "0<0.1",
            "+4>-4",
            "'-$(c)'==-1",
            "$(a)==faLse",
            "$(a)==oFF",
            "$(a)==no",
            "$(a)!=true",
            "$(b)== True",
            "$(b)==on",
            "$(b)==yes",
            "$(b)!=1",
            "$(c)==1",
            "$(d)=='xxx'",
            "$(d)==$(e)",
            "$(d)=='$(e)'",
            "@(y)==$(d)",
            "'@(z)'=='xxx;yyy'",
            "$(a)==$(a)",
            "'1'=='1'",
            "'1'==1",
            "1\n==1",
            "1\t==\t\r\n1",
            "123=='0123.0'",
            "123==123",
            "123==0123",
            "123==0123.0",
            "123!=0123.01",
            "1.2.3<=1.2.3.0",
            "12.23.34==12.23.34",
            "0.8.0.0<8.0.0",
            "1.1.2>1.0.1.2",
            "8.1>8.0.16.23",
            "8.0.0>=8",
            "6<=6.0.0.1",
            "7>6.8.2",
            "4<5.9.9135.4",
            "3!=3.0.0",
            "1.2.3.4.5.6.7==1.2.3.4.5.6.7",
            "00==0",
            "0==0.0",
            "1\n\t==1",
            "+4==4",
            "44==+44.0 and -44==-44.0",
            "false==no",
            "true==yes",
            "true==!false",
            "yes!=no",
            "false!=1",
            "$(c)>0",
            "!$(a)",
            "$(b)",
            "($(d)==$(e))",
            "!true==false",
            "a_a==a_a",
            "a_a=='a_a'",
            "_a== _a",
            "@(y -> '%(filename)')=='xxx'",
            "@(z -> '%(filename)', '!')=='xxx!yyy'",
            "'xxx!yyy'==@(z -> '%(filename)', '!')",
            "'$(a)'==(false)",
            "('$(a)'==(false))",
            "1>0",
            "2<=2",
            "2<=3",
            "1>=1",
            "1>=-1",
            "-1==-1",
            "-1  <  0",
            "(1==1)and('a'=='a')",
            "(true) and ($(a)==off)",
            "(true) and ($(d)==xxx)",
            "(false)     or($(d)==xxx)",
            "!(false)and!(false)",
            "'and'=='AND'",
            "$(d)=='XxX'",
            "true or true or false",
            "false or true or !true or'1'",
            "$(a) or $(b)",
            "$(a) or true",
            "!!true",
            "'$(e)1@(y)'=='xxx1xxx'",
            "0x11==17",
            "0x01a==26",
            "0xa==0x0A",
            "@(x)",
            "'%77'=='w'",
            "'%zz'=='%zz'",
            "true or 1",
            "true==!false",
            "(!(true))=='off'",
            "@(w)>0",
            "1<=@(w)",
            "%(culture)=='FRENCH'",
            "'%(culture) fries' == 'FRENCH FRIES' ",
            @"'%(HintPath)' == ''",
            @"%(HintPath) != 'c:\myassemblies\foo.dll'",
            "exists('a')",
            "exists(a)",
            "exists('a%3bb')", /* semicolon */
            "exists('a%27b')", /* apostrophe */
            "exists('a;c')", /* items */
            "exists($(a_semi_c))",
            "exists($(a_escapedsemi_b))",
            "exists('$(a_escapedsemi_b)')",
            "exists($(a_escapedapos_b))",
            "exists('$(a_escapedapos_b)')",
            "exists($(a_apos_b))",
            "exists('$(a_apos_b)')",
            "exists(@(v))",
            "exists('@(v)')",
            "exists('%3b')",
            "exists('%27')",
            "exists('@(v);@(nonexistent)')",
            @"HASTRAILINGSLASH('foo\')",
            @"!HasTrailingSlash('foo')",
            @"HasTrailingSlash('foo/')",
            @"HasTrailingSlash($(has_trailing_slash))",
            "'59264.59264' == '59264.59264'",
            "1" + new String('0', 500) + "==" + "1" + new String('0', 500), /* too big for double, eval as string */
            "'1" + new String('0', 500) + "'=='" + "1" + new String('0', 500) + "'" /* too big for double, eval as string */
        }.Select(s => new[] { s });

        public static readonly IEnumerable<object[]> FalseTests = new[] {
            "false and SHOULDNOTEVALTHIS", // short circuit
            "$(a)!=no",
            "$(b)==1.1",
            "$(c)==$(a)",
            "$(d)!=$(e)",
            "!$(b)",
            "false or false or false",
            "false and !((true and false))",
            "on and off",
            "(true) and (false)",
            "false or (false or (false or (false or (false or (false)))))",
            "!$(b)and true",
            "1==a",
            "!($(d)==$(e))",
            "$(a) and true",
            "true==1",
            "false==0",
            "(!(true))=='x'",
            "oops==false",
            "oops==!false",
            "%(culture) == 'english'",
            "'%(culture) fries' == 'english fries' ",
            @"'%(HintPath)' == 'c:\myassemblies\foo.dll'",
            @"%(HintPath) == 'c:\myassemblies\foo.dll'",
            "exists('')",
            "exists(' ')",
            "exists($(nonexistent))",  // DDB #141195
            "exists('$(nonexistent)')",  // DDB #141195
            "exists(@(nonexistent))",  // DDB #141195
            "exists('@(nonexistent)')",  // DDB #141195
            "exists('\t')",
            "exists('@(u)')",
            "exists('$(foo_apos_foo)')",
            "!exists('a')",
            "!exists('a;c')",
            "!exists('$(a_semi_c)')",
            "exists('a;b;c')", /* a and c exist but b does not */
            "exists('b;c;a')",
            "exists('$(a_semi_c);b')",
            "!!!exists(a)",
            "exists('|||||')",
            @"hastrailingslash('foo')",
            @"hastrailingslash('')",
            @"HasTrailingSlash($(nonexistent))",
            "'59264.59264' == '59264.59265'",
            "1.2.0==1.2",
            "$(f)!=$(f)",
            "1.3.5.8>1.3.6.8",
            "0.8.0.0>=1.0",
            "8.0.0<=8.0",
            "8.1.2<8",
            "1" + new String('0', 500) + "==2", /* too big for double, eval as string */
            "'1" + new String('0', 500) + "'=='2'", /* too big for double, eval as string */
            "'1" + new String('0', 500) + "'=='01" + new String('0', 500) + "'" /* too big for double, eval as string */
        }.Select(s => new[] { s });

        public static readonly IEnumerable<object[]> ErrorTests = new[] {
            "$",
            "$(",
            "$()",
            "@",
            "@(",
            "@()",
            "%",
            "%(",
            "%()",
            "exists",
            "exists(",
            "exists()",
            "exists( )",
            "exists(,)",
            "@(x->'",
            "@(x->''",
            "@(x-",
            "@(x->'x','",
            "@(x->'x',''",
            "@(x->'x','')",
            "-1>x",
            "%00",
            "\n",
            "\t",
            "+-1==1",
            "1==-+1",
            "1==+0xa",
            "!$(c)",
            "'a'==('a'=='a')",
            "'a'!=('a'=='a')",
            "('a'=='a')!=a",
            "('a'=='a')==a",
            "!'x'",
            "!'$(d)'",
            "ab#==ab#",
            "#!=#",
            "$(d)$(e)=='xxxxxx'",
            "1=1=1",
            "'a'=='a'=='a'",
            "1 > 'x'",
            "x1<=1",
            "1<=x",
            "1>x",
            "x<x",
            "@(x)<x",
            "x>x",
            "x>=x",
            "x<=x",
            "x>1",
            "x>=1",
            "1>=x",
            "@(y)<=1",
            "1<=@(z)",
            "1>$(d)",
            "$(c)@(y)>1",
            "'$(c)@(y)'>1",
            "$(d)>=1",
            "1>=$(b)",
            "1> =0",
            "or true",
            "1 and",
            "and",
            "or",
            "not",
            "not true",
            "()",
            "(a)",
            "!",
            "or=or",
            "1==",
            "1= =1",
            "=",
            "'true",
            "'false''",
            "'a'=='a",
            "('a'=='a'",
            "('a'=='a'))",
            "'a'=='a')",
            "!and",
            "@(a)@(x)!=1",
            "@(a) @(x)!=1",
            "$(a==off",
            "=='x'",
            "==",
            "!0",
            ">",
            "true!=false==",
            "true!=false==true",
            "()",
            "!1",
            "1==(2",
            "$(a)==x>1==2",
            "'a'>'a'",
            "0",
            "$(a)>0",
            "!$(e)",
            "1<=1<=1",
            "true $(and) true",
            "--1==1",
            "$(and)==and",
            "!@#$%^&*",
            "-($(c))==-1",
            "a==b or $(d)",
            "false or $()",
            "$(d) or true",
            "%(Culture) or true",
            "@(nonexistent) and true",
            "$(nonexistent) and true",
            "@(nonexistent)",
            "$(nonexistent)",
            "@(z) and true",
            "@() and true",
            "@()",
            "$()",
            "1",
            "1 or true",
            "false or 1",
            "1 and true",
            "true and 1",
            "!1",
            "false or !1",
            "false or 'aa'",
            "true blah",
            "existsX",
            "!",
            "nonexistentfunction('xyz')",
            "exists(@(v)x)",
            "exists(@(v)$(nonexistent))",
            "exists('@(v)$(a)')",
            "exists(|||||)",
            "HasTrailingSlash(a,'b')",
            "HasTrailingSlash(,,)",
            "1.2.3==1,2,3"
        }.Select(s => new[] { s });

        /// <summary>
        /// Set up expression tests by creating files for existence checks.
        /// </summary>
        public ExpressionTest(ITestOutputHelper output)
        {
            this.output = output;

            ItemDictionary<ProjectItemInstance> itemBag = new ItemDictionary<ProjectItemInstance>();

            // Dummy project instance to own the items.
            ProjectRootElement xml = ProjectRootElement.Create();
            xml.FullPath = @"c:\abc\foo.proj";

            ProjectInstance parentProject = new ProjectInstance(xml);

            itemBag.Add(new ProjectItemInstance(parentProject, "u", "a'b;c", parentProject.FullPath));
            itemBag.Add(new ProjectItemInstance(parentProject, "v", "a", parentProject.FullPath));
            itemBag.Add(new ProjectItemInstance(parentProject, "w", "1", parentProject.FullPath));
            itemBag.Add(new ProjectItemInstance(parentProject, "x", "true", parentProject.FullPath));
            itemBag.Add(new ProjectItemInstance(parentProject, "y", "xxx", parentProject.FullPath));
            itemBag.Add(new ProjectItemInstance(parentProject, "z", "xxx", parentProject.FullPath));
            itemBag.Add(new ProjectItemInstance(parentProject, "z", "yyy", parentProject.FullPath));

            PropertyDictionary<ProjectPropertyInstance> propertyBag = new PropertyDictionary<ProjectPropertyInstance>();

            propertyBag.Set(ProjectPropertyInstance.Create("a", "no"));
            propertyBag.Set(ProjectPropertyInstance.Create("b", "true"));
            propertyBag.Set(ProjectPropertyInstance.Create("c", "1"));
            propertyBag.Set(ProjectPropertyInstance.Create("d", "xxx"));
            propertyBag.Set(ProjectPropertyInstance.Create("e", "xxx"));
            propertyBag.Set(ProjectPropertyInstance.Create("f", "1.9.5"));
            propertyBag.Set(ProjectPropertyInstance.Create("and", "and"));
            propertyBag.Set(ProjectPropertyInstance.Create("a_semi_c", "a;c"));
            propertyBag.Set(ProjectPropertyInstance.Create("a_apos_b", "a'b"));
            propertyBag.Set(ProjectPropertyInstance.Create("foo_apos_foo", "foo'foo"));
            propertyBag.Set(ProjectPropertyInstance.Create("a_escapedsemi_b", "a%3bb"));
            propertyBag.Set(ProjectPropertyInstance.Create("a_escapedapos_b", "a%27b"));
            propertyBag.Set(ProjectPropertyInstance.Create("has_trailing_slash", @"foo\"));

            Dictionary<string, string> metadataDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
            metadataDictionary["Culture"] = "french";
            StringMetadataTable itemMetadata = new StringMetadataTable(metadataDictionary);

            _expander = new Expander<ProjectPropertyInstance, ProjectItemInstance>(propertyBag, itemBag, itemMetadata, FileSystems.Default);

            foreach (string file in FilesWithExistenceChecks)
            {
                using (File.CreateText(file)) { }
            }
        }

        /// <summary>
        /// Clean up files created for these tests.
        /// </summary>
        public void Dispose()
        {
            foreach (string file in FilesWithExistenceChecks)
            {
                if (File.Exists(file))
                {
                    File.Delete(file);
                }
            }
        }

        /// <summary>
        /// A whole bunch of conditionals that should be true
        /// (many coincidentally like existing QA tests) to give breadth coverage.
        /// Please add more cases as they arise.
        /// </summary>
        [Theory]
        [MemberData(nameof(TrueTests))]
        public void EvaluateAVarietyOfTrueExpressions(string expression)
        {
            Parser p = new Parser();
            GenericExpressionNode tree = p.Parse(expression, ParserOptions.AllowAll, ElementLocation.EmptyLocation);
            ConditionEvaluator.IConditionEvaluationState state =
                new ConditionEvaluator.ConditionEvaluationState<ProjectPropertyInstance, ProjectItemInstance>(
                    expression,
                    _expander,
                    ExpanderOptions.ExpandAll,
                    null,
                    Directory.GetCurrentDirectory(),
                    ElementLocation.EmptyLocation,
                    FileSystems.Default);

            Assert.True(tree.Evaluate(state), "expected true from '" + expression + "'");
        }

        /// <summary>
        /// A whole bunch of conditionals that should be false
        /// (many coincidentally like existing QA tests) to give breadth coverage.
        /// Please add more cases as they arise.
        /// </summary>
        [Theory]
        [MemberData(nameof(FalseTests))]
        public void EvaluateAVarietyOfFalseExpressions(string expression)
        {
            Parser p = new Parser();
            GenericExpressionNode tree = p.Parse(expression, ParserOptions.AllowAll, ElementLocation.EmptyLocation);
            ConditionEvaluator.IConditionEvaluationState state =
                new ConditionEvaluator.ConditionEvaluationState<ProjectPropertyInstance, ProjectItemInstance>(
                    expression,
                    _expander,
                    ExpanderOptions.ExpandAll,
                    null,
                    Directory.GetCurrentDirectory(),
                    ElementLocation.EmptyLocation,
                    FileSystems.Default);

            Assert.False(tree.Evaluate(state), "expected false from '" + expression + "' and got true");
        }

        /// <summary>
        /// A whole bunch of conditionals that should produce errors
        /// (many coincidentally like existing QA tests) to give breadth coverage.
        /// Please add more cases as they arise.
        /// </summary>
        [Theory]
        [MemberData(nameof(ErrorTests))]
        public void EvaluateAVarietyOfErrorExpressions(string expression)
        {
            // If an expression is invalid,
            //      - Parse may throw, or
            //      - Evaluate may throw, or
            //      - Evaluate may return false causing its caller EvaluateCondition to throw
            bool caughtException = false;
            try
            {
                Parser p = new Parser();
                var tree = p.Parse(expression, ParserOptions.AllowAll, ElementLocation.EmptyLocation);

                ConditionEvaluator.IConditionEvaluationState state =
                    new ConditionEvaluator.ConditionEvaluationState<ProjectPropertyInstance, ProjectItemInstance>(
                        expression,
                        _expander,
                        ExpanderOptions.ExpandAll,
                        null,
                        Directory.GetCurrentDirectory(),
                        ElementLocation.EmptyLocation,
                        FileSystems.Default);

                tree.Evaluate(state);
            }
            catch (InvalidProjectFileException ex)
            {
                output.WriteLine(expression + " caused '" + ex.Message + "'");
                caughtException = true;
            }
            Assert.True(caughtException,
                "expected '" + expression + "' to not parse or not be evaluated");
        }
    }
}
