"use strict";

// Note the globalThisValue and globalObject do not need to be the same.
const globalThisValue = this;
const globalObject = (0, eval)("this");

// These tests are done in global state to ensure that is possible
const globalArrow = () => {
    expect(this).toBe(globalThisValue);
    return this;
};

function globalFunction() {
    expect(this).toBe(undefined);

    expect(globalArrow()).toBe(globalThisValue);

    const arrowInGlobalFunction = () => this;
    expect(arrowInGlobalFunction()).toBe(undefined);

    return arrowInGlobalFunction;
}

expect(globalArrow()).toBe(globalThisValue);
expect(globalFunction()()).toBe(undefined);

const arrowFromGlobalFunction = globalFunction();

const customThisValue = {
    isCustomThis: true,
    variant: 0,
};

const otherCustomThisValue = {
    isCustomThis: true,
    variant: 1,
};

describe("describe with arrow function", () => {
    expect(this).toBe(globalThisValue);

    test("nested test with normal function should get global object", function () {
        expect(this).toBe(undefined);
    });

    test("nested test with arrow function should get same this value as enclosing function", () => {
        expect(this).toBe(globalThisValue);
    });
});

describe("describe with normal function", function () {
    expect(this).toBe(undefined);
    test("nested test with normal function should get global object", function () {
        expect(this).toBe(undefined);
    });

    test("nested test with arrow function should get same this value as enclosing function", () => {
        expect(this).toBe(undefined);
    });
});

describe("basic behavior", () => {
    expect(this).toBe(globalThisValue);

    expect(arrowFromGlobalFunction()).toBeUndefined();

    expect(customThisValue).not.toBe(otherCustomThisValue);

    test("binding arrow function does not influence this value", () => {
        const boundGlobalArrow = globalArrow.bind({ shouldNotBeHere: true });

        expect(boundGlobalArrow()).toBe(globalThisValue);
    });

    function functionInArrow() {
        expect(arrowFromGlobalFunction()).toBeUndefined();
        return this;
    }

    function functionWithArrow() {
        expect(arrowFromGlobalFunction()).toBeUndefined();
        return () => {
            expect(arrowFromGlobalFunction()).toBeUndefined();
            return this;
        };
    }

    function strictFunction() {
        "use strict";
        return this;
    }

    test("functions get globalObject as this value", () => {
        expect(functionInArrow()).toBeUndefined();
        expect(functionWithArrow()()).toBeUndefined();
    });

    test("strict functions get undefined as this value", () => {
        expect(strictFunction()).toBeUndefined();
    });

    test("bound function gets overwritten this value", () => {
        const boundFunction = functionInArrow.bind(customThisValue);
        expect(boundFunction()).toBe(customThisValue);

        const boundFunctionWithArrow = functionWithArrow.bind(customThisValue);
        expect(boundFunctionWithArrow()()).toBe(customThisValue);

        // However we cannot bind the arrow function itself
        const failingArrowBound = boundFunctionWithArrow().bind(otherCustomThisValue);
        expect(failingArrowBound()).toBe(customThisValue);

        const boundStrictFunction = strictFunction.bind(customThisValue);
        expect(boundStrictFunction()).toBe(customThisValue);
    });
});

describe("functions on created objects", () => {
    const obj = {
        func: function () {
            expect(arrowFromGlobalFunction()).toBeUndefined();
            return this;
        },

        funcWithArrow: function () {
            expect(arrowFromGlobalFunction()).toBeUndefined();
            return () => this;
        },

        arrow: () => {
            expect(arrowFromGlobalFunction()).toBeUndefined();
            return this;
        },
        otherProperty: "yes",
    };

    test("function get this value of associated object", () => {
        expect(obj.func()).toBe(obj);
    });

    test("arrow function on object get above this value", () => {
        expect(obj.arrow()).toBe(globalThisValue);
    });

    test("arrow function from normal function from object has object as this value", () => {
        expect(obj.funcWithArrow()()).toBe(obj);
    });

    test("bound overwrites value of normal object function", () => {
        const boundFunction = obj.func.bind(customThisValue);
        expect(boundFunction()).toBe(customThisValue);

        const boundFunctionWithArrow = obj.funcWithArrow.bind(customThisValue);
        expect(boundFunctionWithArrow()()).toBe(customThisValue);

        const boundArrowFunction = obj.arrow.bind(customThisValue);
        expect(boundArrowFunction()).toBe(globalThisValue);
    });

    test("also works for object defined in function", () => {
        (function () {
            expect(arrowFromGlobalFunction()).toBeUndefined();

            // It is bound below
            expect(this).toBe(customThisValue);

            const obj2 = {
                func: function () {
                    expect(arrowFromGlobalFunction()).toBeUndefined();
                    return this;
                },

                arrow: () => {
                    expect(arrowFromGlobalFunction()).toBeUndefined();
                    return this;
                },
                otherProperty: "also",
            };

            expect(obj2.func()).toBe(obj2);
            expect(obj2.arrow()).toBe(customThisValue);
        }.bind(customThisValue)());
    });
});

describe("behavior with classes", () => {
    class Basic {
        constructor(value) {
            expect(this).toBeInstanceOf(Basic);
            this.arrowFunctionInClass = () => {
                return this;
            };

            this.value = value;

            expect(arrowFromGlobalFunction()).toBeUndefined();
        }

        func() {
            expect(arrowFromGlobalFunction()).toBeUndefined();
            return this;
        }
    }

    const basic = new Basic(14);
    const basic2 = new Basic(457);

    expect(basic).not.toBe(basic2);

    test("calling functions on class should give instance as this value", () => {
        expect(basic.func()).toBe(basic);
        expect(basic2.func()).toBe(basic2);
    });

    test("calling arrow function created in constructor should give instance as this value", () => {
        expect(basic.arrowFunctionInClass()).toBe(basic);
        expect(basic2.arrowFunctionInClass()).toBe(basic2);
    });

    test("can bind function in class", () => {
        const boundFunction = basic.func.bind(customThisValue);
        expect(boundFunction()).toBe(customThisValue);

        const boundFunction2 = basic2.func.bind(otherCustomThisValue);
        expect(boundFunction2()).toBe(otherCustomThisValue);
    });
});

describe("derived classes behavior", () => {
    class Base {
        baseFunction() {
            expect(arrowFromGlobalFunction()).toBeUndefined();

            return this;
        }
    }

    class Derived extends Base {
        constructor(value) {
            expect(arrowFromGlobalFunction()).toBeUndefined();
            const arrowMadeBeforeSuper = () => {
                expect(this).toBeInstanceOf(Derived);
                return this;
            };
            super();
            expect(arrowMadeBeforeSuper()).toBe(this);

            this.arrowMadeBeforeSuper = arrowMadeBeforeSuper;
            this.arrowMadeAfterSuper = () => {
                expect(this).toBeInstanceOf(Derived);
                return this;
            };
            this.value = value;
        }

        derivedFunction() {
            expect(arrowFromGlobalFunction()).toBeUndefined();
            return this;
        }
    }

    test("can create derived with arrow functions using this before super", () => {
        const testDerived = new Derived(-89);
        expect(testDerived.arrowMadeBeforeSuper()).toBe(testDerived);
        expect(testDerived.arrowMadeAfterSuper()).toBe(testDerived);
    });

    test("base and derived functions get correct this values", () => {
        const derived = new Derived(12);

        expect(derived.derivedFunction()).toBe(derived);
        expect(derived.baseFunction()).toBe(derived);
    });

    test("can bind derived and base functions", () => {
        const derived = new Derived(846);

        const boundDerivedFunction = derived.derivedFunction.bind(customThisValue);
        expect(boundDerivedFunction()).toBe(customThisValue);

        const boundBaseFunction = derived.baseFunction.bind(otherCustomThisValue);
        expect(boundBaseFunction()).toBe(otherCustomThisValue);
    });
});

describe("proxy behavior", () => {
    test("with no handler it makes no difference", () => {
        const globalArrowProxyNoHandler = new Proxy(globalArrow, {});
        expect(globalArrowProxyNoHandler()).toBe(globalThisValue);
    });

    test("proxy around global arrow still gives correct this value", () => {
        let lastThisArg = null;

        const handler = {
            apply(target, thisArg, argArray) {
                expect(target).toBe(globalArrow);
                lastThisArg = thisArg;
                expect(this).toBe(handler);

                return target(...argArray);
            },
        };

        const globalArrowProxy = new Proxy(globalArrow, handler);
        expect(globalArrowProxy()).toBe(globalThisValue);
        expect(lastThisArg).toBeUndefined();

        const boundProxy = globalArrowProxy.bind(customThisValue);
        expect(boundProxy()).toBe(globalThisValue);
        expect(lastThisArg).toBe(customThisValue);

        expect(globalArrowProxy.call(15)).toBe(globalThisValue);
        expect(lastThisArg).toBe(15);
    });
});

describe("derived classes which access this before super should fail", () => {
    class Base {}

    test("direct access of this should throw reference error", () => {
        class IncorrectConstructor extends Base {
            constructor() {
                this.something = "this will fail";
                super();
            }
        }

        expect(() => {
            new IncorrectConstructor();
        }).toThrowWithMessage(ReferenceError, "|this| has not been initialized");
    });

    test("access of this via a arrow function", () => {
        class IncorrectConstructor extends Base {
            constructor() {
                const arrow = () => this;
                arrow();
                super();
            }
        }

        expect(() => {
            new IncorrectConstructor();
        }).toThrowWithMessage(ReferenceError, "|this| has not been initialized");
    });

    test("access of this via a eval", () => {
        class IncorrectConstructor extends Base {
            constructor() {
                eval("this.foo = 'bar'");
                super();
            }
        }

        expect(() => {
            new IncorrectConstructor();
        }).toThrowWithMessage(ReferenceError, "|this| has not been initialized");
    });

    test("access of this via a eval in arrow function", () => {
        class IncorrectConstructor extends Base {
            constructor() {
                const arrow = () => eval("() => this")();
                arrow();
                super();
            }
        }

        expect(() => {
            new IncorrectConstructor();
        }).toThrowWithMessage(ReferenceError, "|this| has not been initialized");
    });

    test("access of this via arrow function even if bound with something else", () => {
        class IncorrectConstructor extends Base {
            constructor() {
                const arrow = () => this;
                const boundArrow = arrow.bind(customThisValue);
                boundArrow();
                super();
            }
        }

        expect(() => {
            new IncorrectConstructor();
        }).toThrowWithMessage(ReferenceError, "|this| has not been initialized");
    });
});

describe("in strict mode primitive this values are not converted to objects", () => {
    const array = [true, false];

    // Technically the comma is implementation defined here. (Also for tests below.)
    expect(array.toLocaleString()).toBe("true,false");

    test("directly overwriting toString", () => {
        let count = 0;
        Boolean.prototype.toString = function () {
            count++;
            return typeof this;
        };

        expect(array.toLocaleString()).toBe("boolean,boolean");
        expect(count).toBe(2);
    });

    test("overwriting toString with a getter", () => {
        let count = 0;

        Object.defineProperty(Boolean.prototype, "toString", {
            get() {
                count++;
                const that = typeof this;
                return function () {
                    return that;
                };
            },
        });

        expect(array.toLocaleString()).toBe("boolean,boolean");
        expect(count).toBe(2);
    });
});
