using Jint.Native;
using Jint.Runtime.Debugger;

namespace Jint.Tests.Runtime.Debugger
{
    public class ScopeTests
    {
        private static JsValue AssertOnlyScopeContains(DebugScopes scopes, string name, DebugScopeType scopeType)
        {
            var containingScope = Assert.Single(scopes, s => s.ScopeType == scopeType && s.BindingNames.Contains(name));
            Assert.DoesNotContain(scopes, s => s != containingScope && s.BindingNames.Contains(name));

            return containingScope.GetBindingValue(name);
        }

        private static void AssertScope(DebugScope actual, DebugScopeType expectedType, params string[] expectedBindingNames)
        {
            Assert.Equal(expectedType, actual.ScopeType);
            // Global scope will have a number of intrinsic bindings that are outside the scope [no pun] of these tests
            if (actual.ScopeType != DebugScopeType.Global)
            {
                Assert.Equal(expectedBindingNames.Length, actual.BindingNames.Count);
            }
            foreach (string expectedName in expectedBindingNames)
            {
                Assert.Contains(expectedName, actual.BindingNames);
            }
        }

        [Fact]
        public void AllowsInspectionOfUninitializedGlobalBindings()
        {
            string script = @"
                debugger;
                const globalConstant = 'test';
                let globalLet = 'test';
            ";

            TestHelpers.TestAtBreak(script, info =>
            {
                // Uninitialized global block scoped ("script scoped") bindings return null (and, just as importantly, don't throw):
                Assert.Null(info.CurrentScopeChain[0].GetBindingValue("globalConstant"));
                Assert.Null(info.CurrentScopeChain[0].GetBindingValue("globalLet"));
            });
        }

        [Fact]
        public void AllowsInspectionOfUninitializedBlockBindings()
        {
            string script = @"
                function test()
                {
                    debugger;
                    const globalConstant = 'test';
                    let globalLet = 'test';
                }
                test();
            ";

            TestHelpers.TestAtBreak(script, info =>
            {
                // Uninitialized block scoped bindings return null (and, just as importantly, don't throw):
                Assert.Null(info.CurrentScopeChain[0].GetBindingValue("globalConstant"));
                Assert.Null(info.CurrentScopeChain[0].GetBindingValue("globalLet"));
            });
        }

        [Fact]
        public void ScriptScopeIncludesGlobalConst()
        {
            string script = @"
                const globalConstant = 'test';
                debugger;
            ";

            TestHelpers.TestAtBreak(script, info =>
            {
                var value = AssertOnlyScopeContains(info.CurrentScopeChain, "globalConstant", DebugScopeType.Script);
                Assert.Equal("test", value.AsString());
            });
        }

        [Fact]
        public void ScriptScopeIncludesGlobalLet()
        {
            string script = @"
                let globalLet = 'test';
                debugger;";

            TestHelpers.TestAtBreak(script, info =>
            {
                var value = AssertOnlyScopeContains(info.CurrentScopeChain, "globalLet", DebugScopeType.Script);
                Assert.Equal("test", value.AsString());
            });
        }

        [Fact]
        public void GlobalScopeIncludesGlobalVar()
        {
            string script = @"
                var globalVar = 'test';
                debugger;";

            TestHelpers.TestAtBreak(script, info =>
            {
                var value = AssertOnlyScopeContains(info.CurrentScopeChain, "globalVar", DebugScopeType.Global);
                Assert.Equal("test", value.AsString());
            });
        }

        [Fact]
        public void TopLevelBlockScopeIsIdentified()
        {
            string script = @"
                function test()
                {
                    const localConst = 'test';
                    debugger;
                }
                test();";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Equal(3, info.CurrentScopeChain.Count);
                Assert.Equal(DebugScopeType.Block, info.CurrentScopeChain[0].ScopeType);
                Assert.True(info.CurrentScopeChain[0].IsTopLevel);
            });
        }

        [Fact]
        public void NonTopLevelBlockScopeIsIdentified()
        {
            string script = @"
                function test()
                {
                    {
                        const localConst = 'test';
                        debugger;
                    }
                }
                test();";

            TestHelpers.TestAtBreak(script, info =>
            {
                // We only have 3 scopes, because the function top level block scope is empty.
                Assert.Equal(3, info.CurrentScopeChain.Count);
                Assert.Equal(DebugScopeType.Block, info.CurrentScopeChain[0].ScopeType);
                Assert.False(info.CurrentScopeChain[0].IsTopLevel);
            });
        }

        [Fact]
        public void BlockScopeIncludesLocalConst()
        {
            string script = @"
                function test()
                {
                    {
                        const localConst = 'test';
                        debugger;
                    }
                }
                test();";

            TestHelpers.TestAtBreak(script, info =>
            {
                var value = AssertOnlyScopeContains(info.CurrentScopeChain, "localConst", DebugScopeType.Block);
                Assert.Equal("test", value.AsString());
            });
        }
        [Fact]
        public void BlockScopeIncludesLocalLet()
        {
            string script = @"
                function test()
                {
                    {
                        let localLet = 'test';
                        debugger;
                    }
                }
                test();";

            TestHelpers.TestAtBreak(script, info =>
            {
                var value = AssertOnlyScopeContains(info.CurrentScopeChain, "localLet", DebugScopeType.Block);
                Assert.Equal("test", value.AsString());
            });
        }

        [Fact]
        public void LocalScopeIncludesLocalVar()
        {
            string script = @"
                function test()
                {
                    var localVar = 'test';
                    debugger;
                }
                test();";

            TestHelpers.TestAtBreak(script, info =>
            {
                AssertOnlyScopeContains(info.CurrentScopeChain, "localVar", DebugScopeType.Local);
            });
        }

        [Fact]
        public void LocalScopeIncludesBlockVar()
        {
            string script = @"
                function test()
                {
                    debugger;
                    {
                        var localVar = 'test';
                    }
                }
                test();";

            TestHelpers.TestAtBreak(script, info =>
            {
                AssertOnlyScopeContains(info.CurrentScopeChain, "localVar", DebugScopeType.Local);
            });
        }

        [Fact]
        public void BlockScopedConstIsVisibleInsideBlock()
        {
            string script = @"
            'dummy statement';
            {
                const blockConst = 'block';
                debugger; // const isn't initialized until declaration
            }";

            TestHelpers.TestAtBreak(script, info =>
            {
                AssertOnlyScopeContains(info.CurrentScopeChain, "blockConst", DebugScopeType.Block);
            });
        }

        [Fact]
        public void BlockScopedLetIsVisibleInsideBlock()
        {
            string script = @"
            'dummy statement';
            {
                let blockLet = 'block';
                debugger; // let isn't initialized until declaration
            }";

            TestHelpers.TestAtBreak(script, info =>
            {
                AssertOnlyScopeContains(info.CurrentScopeChain, "blockLet", DebugScopeType.Block);
            });
        }

        [Fact]
        public void HasCorrectScopeChainForFunction()
        {
            string script = @"
            function add(a, b)
            {
                debugger;
                return a + b;
            }
            const x = 1;
            const y = 2;
            const z = add(x, y);";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Local, "arguments", "a", "b"),
                    scope => AssertScope(scope, DebugScopeType.Script, "x", "y", "z"),
                    scope => AssertScope(scope, DebugScopeType.Global, "add"));
            });
        }

        [Fact]
        public void HasCorrectScopeChainForNestedFunction()
        {
            string script = @"
            function add(a, b)
            {
                function power(a)
                {
                    debugger;
                    return a * a;
                }
                return power(a) + b;
            }
            const x = 1;
            const y = 2;
            const z = add(x, y);";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Local, "arguments", "a"),
                    // a, arguments shadowed by local - but still exist in this scope
                    scope => AssertScope(scope, DebugScopeType.Closure, "a", "arguments", "b", "power"),
                    scope => AssertScope(scope, DebugScopeType.Script, "x", "y", "z"),
                    scope => AssertScope(scope, DebugScopeType.Global, "add"));
            });
        }

        [Fact]
        public void HasCorrectScopeChainForBlock()
        {
            string script = @"
            function add(a, b)
            {
                if (a > 0)
                {
                    const y = b / a;
                    debugger;
                }
                return a + b;
            }
            const x = 1;
            const y = 2;
            const z = add(x, y);";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Block, "y"),
                    scope => AssertScope(scope, DebugScopeType.Local, "arguments", "a", "b"),
                    scope => AssertScope(scope, DebugScopeType.Script, "x", "y", "z"), // y is shadowed, but still in the scope
                    scope => AssertScope(scope, DebugScopeType.Global, "add"));
            });
        }

        [Fact]
        public void HasCorrectScopeChainForModule()
        {
            string imported = @"
            function add(a, b)
            {
                debugger;
                return a + b;
            }
            
            export { add };";

            string main = @"
            import { add } from 'imported-module';
            const x = 1;
            const y = 2;
            add(x, y);";
            TestHelpers.TestAtBreak(engine =>
            {
                engine.Modules.Add("imported-module", imported);
                engine.Modules.Add("main", main);
                engine.Modules.Import("main");
            },
            info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Local, "arguments", "a", "b"),
                    scope => AssertScope(scope, DebugScopeType.Module, "add"),
                    scope => AssertScope(scope, DebugScopeType.Global));
            });
        }

        [Fact]
        public void HasCorrectScopeChainForNestedBlock()
        {
            string script = @"
            function add(a, b)
            {
                if (a > 0)
                {
                    const y = b / a;
                    if (y > 0)
                    {
                        const x = b / y;
                        debugger;
                    }
                }
                return a + b;
            }
            const x = 1;
            const y = 2;
            const z = add(x, y);";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Block, "x"),
                    scope => AssertScope(scope, DebugScopeType.Block, "y"),
                    scope => AssertScope(scope, DebugScopeType.Local, "arguments", "a", "b"),
                    scope => AssertScope(scope, DebugScopeType.Script, "x", "y", "z"), // x, y are shadowed, but still in the scope
                    scope => AssertScope(scope, DebugScopeType.Global, "add"));
            });
        }

        [Fact]
        public void HasCorrectScopeChainForCatch()
        {
            string script = @"
            function func()
            {
                let a = 1;
                try
                {
                    throw new Error('test');
                }
                catch (error)
                {
                    debugger;
                }
            }
            func();";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Catch, "error"),
                    scope => AssertScope(scope, DebugScopeType.Block, "a"),
                    scope => AssertScope(scope, DebugScopeType.Local, "arguments"),
                    scope => AssertScope(scope, DebugScopeType.Global, "func"));
            });
        }

        [Fact]
        public void HasCorrectScopeChainForWith()
        {
            string script = @"
            const obj = { a: 2, b: 4 };
            with (obj)
            {
                const x = a;
                debugger;
            };";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Block, "x"),
                    scope => AssertScope(scope, DebugScopeType.With, "a", "b"),
                    scope => AssertScope(scope, DebugScopeType.Script, "obj"),
                    scope => AssertScope(scope, DebugScopeType.Global));
            });
        }

        [Fact]
        public void ScopeChainIncludesNonEmptyScopes()
        {
            string script = @"
            const x = 2;
            if (x > 0)
            {
                const y = x;
                if (x > 1)
                {
                    const z = x;
                    debugger;
                }
            }";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Block, "z"),
                    scope => AssertScope(scope, DebugScopeType.Block, "y"),
                    scope => AssertScope(scope, DebugScopeType.Script, "x"),
                    scope => AssertScope(scope, DebugScopeType.Global));
            });
        }

        [Fact]
        public void ScopeChainExcludesEmptyScopes()
        {
            string script = @"
            const x = 2;
            if (x > 0)
            {
                if (x > 1)
                {
                    const z = x;
                    debugger;
                }
            }";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CurrentScopeChain,
                    scope => AssertScope(scope, DebugScopeType.Block, "z"),
                    scope => AssertScope(scope, DebugScopeType.Script, "x"),
                    scope => AssertScope(scope, DebugScopeType.Global));
            });
        }

        [Fact]
        public void ResolvesScopeChainsUpTheCallStack()
        {
            string script = @"
            const x = 1;
            function foo(a, c)
            {
                debugger;
            }
            
            function bar(b)
            {
                foo(b, 2);
            }

            bar(x);";

            TestHelpers.TestAtBreak(script, info =>
            {
                Assert.Collection(info.CallStack,
                    frame => Assert.Collection(frame.ScopeChain,
                        // in foo()
                        scope => AssertScope(scope, DebugScopeType.Local, "arguments", "a", "c"),
                        scope => AssertScope(scope, DebugScopeType.Script, "x"),
                        scope => AssertScope(scope, DebugScopeType.Global, "foo", "bar")
                    ),
                    frame => Assert.Collection(frame.ScopeChain,
                        // in bar()
                        scope => AssertScope(scope, DebugScopeType.Local, "arguments", "b"),
                        scope => AssertScope(scope, DebugScopeType.Script, "x"),
                        scope => AssertScope(scope, DebugScopeType.Global, "foo", "bar")
                    ),
                    frame => Assert.Collection(frame.ScopeChain,
                        // in global
                        scope => AssertScope(scope, DebugScopeType.Script, "x"),
                        scope => AssertScope(scope, DebugScopeType.Global, "foo", "bar")
                    )
                );
            });
        }

        [Fact]
        public void InspectsModuleScopedBindings()
        {
            string main = @"const x = 1; debugger;";
            TestHelpers.TestAtBreak(engine =>
            {
                engine.Modules.Add("main", main);
                engine.Modules.Import("main");
            },
            info =>
            {
                // No need for imports - main module is module scoped too, duh.
                var value = AssertOnlyScopeContains(info.CurrentScopeChain, "x", DebugScopeType.Module);
                Assert.Equal(1, value.AsInteger());
            });
        }
    }
}
