package require

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"syscall"
	"testing"

	js "github.com/projectdiscovery/goja"
)

func mapFileSystemSourceLoader(files map[string]string) SourceLoader {
	return func(p string) ([]byte, error) {
		s, ok := files[filepath.ToSlash(p)]
		if !ok {
			return nil, ModuleFileDoesNotExistError
		}
		return []byte(s), nil
	}
}

func TestRequireNativeModule(t *testing.T) {
	const SCRIPT = `
	var m = require("test/m");
	m.test();
	`

	vm := js.New()

	registry := new(Registry)
	registry.Enable(vm)

	RegisterNativeModule("test/m", func(runtime *js.Runtime, module *js.Object) {
		o := module.Get("exports").(*js.Object)
		o.Set("test", func(call js.FunctionCall) js.Value {
			return runtime.ToValue("passed")
		})
	})

	v, err := vm.RunString(SCRIPT)
	if err != nil {
		t.Fatal(err)
	}

	if !v.StrictEquals(vm.ToValue("passed")) {
		t.Fatalf("Unexpected result: %v", v)
	}
}

func TestRegisterCoreModule(t *testing.T) {
	vm := js.New()

	registry := new(Registry)
	registry.Enable(vm)

	RegisterCoreModule("coremod", func(runtime *js.Runtime, module *js.Object) {
		o := module.Get("exports").(*js.Object)
		o.Set("test", func(call js.FunctionCall) js.Value {
			return runtime.ToValue("passed")
		})
	})

	RegisterCoreModule("coremod1", func(runtime *js.Runtime, module *js.Object) {
		o := module.Get("exports").(*js.Object)
		o.Set("test", func(call js.FunctionCall) js.Value {
			return runtime.ToValue("passed1")
		})
	})

	RegisterCoreModule("node:test1", func(runtime *js.Runtime, module *js.Object) {
		o := module.Get("exports").(*js.Object)
		o.Set("test", func(call js.FunctionCall) js.Value {
			return runtime.ToValue("test1 passed")
		})
	})

	registry.RegisterNativeModule("bob", func(runtime *js.Runtime, module *js.Object) {

	})

	_, err := vm.RunString(`
	const m1 = require("coremod");
	const m2 = require("node:coremod");
	if (m1 !== m2) {
		throw new Error("Modules are not equal");
	}
	if (m1.test() !== "passed") {
		throw new Error("m1.test() has failed");
	}

	const m3 = require("node:coremod1");
	const m4 = require("coremod1");
	if (m3 !== m4) {
		throw new Error("Modules are not equal (1)");
	}
	if (m3.test() !== "passed1") {
		throw new Error("m3.test() has failed");
	}

	try {
		require("node:bob");
	} catch (e) {
		if (!e.message.includes("No such built-in module")) {
			throw e;
		}
	}
	require("bob");

	try {
		require("test1");
		throw new Error("Expected exception");
	} catch (e) {
		if (!e.message.includes("Invalid module")) {
			throw e;
		}
	}

	if (require("node:test1").test() !== "test1 passed") {
		throw new Error("test1.test() has failed");
	}
	`)

	if err != nil {
		t.Fatal(err)
	}
}

func TestRequireRegistryNativeModule(t *testing.T) {
	const SCRIPT = `
	var log = require("test/log");
	log.print('passed');
	`

	logWithOutput := func(w io.Writer, prefix string) ModuleLoader {
		return func(vm *js.Runtime, module *js.Object) {
			o := module.Get("exports").(*js.Object)
			o.Set("print", func(call js.FunctionCall) js.Value {
				fmt.Fprint(w, prefix, call.Argument(0).String())
				return js.Undefined()
			})
		}
	}

	vm1 := js.New()
	buf1 := &bytes.Buffer{}

	registry1 := new(Registry)
	registry1.Enable(vm1)

	registry1.RegisterNativeModule("test/log", logWithOutput(buf1, "vm1 "))

	vm2 := js.New()
	buf2 := &bytes.Buffer{}

	registry2 := new(Registry)
	registry2.Enable(vm2)

	registry2.RegisterNativeModule("test/log", logWithOutput(buf2, "vm2 "))

	_, err := vm1.RunString(SCRIPT)
	if err != nil {
		t.Fatal(err)
	}

	s := buf1.String()
	if s != "vm1 passed" {
		t.Fatalf("vm1: Unexpected result: %q", s)
	}

	_, err = vm2.RunString(SCRIPT)
	if err != nil {
		t.Fatal(err)
	}

	s = buf2.String()
	if s != "vm2 passed" {
		t.Fatalf("vm2: Unexpected result: %q", s)
	}
}

func TestRequire(t *testing.T) {
	absPath, err := filepath.Abs("./testdata/m.js")
	if err != nil {
		t.Fatal(err)
	}

	isWindows := runtime.GOOS == "windows"

	tests := []struct {
		path string
		ok   bool
	}{
		{
			"./testdata/m.js",
			true,
		},
		{
			"../require/testdata/m.js",
			true,
		},
		{
			absPath,
			true,
		},
		{
			`.\testdata\m.js`,
			isWindows,
		},
		{
			`..\require\testdata\m.js`,
			isWindows,
		},
	}

	const SCRIPT = `
	var m = require(testPath);
	m.test();
	`

	for _, test := range tests {
		t.Run(test.path, func(t *testing.T) {
			vm := js.New()
			vm.Set("testPath", test.path)

			registry := new(Registry)
			registry.Enable(vm)

			v, err := vm.RunString(SCRIPT)

			ok := err == nil

			if ok != test.ok {
				t.Fatalf("Expected ok to be %v, got %v (%v)", test.ok, ok, err)
			}

			if !ok {
				return
			}

			if !v.StrictEquals(vm.ToValue("passed")) {
				t.Fatalf("Unexpected result: %v", v)
			}
		})
	}
}

func TestSourceLoader(t *testing.T) {
	const SCRIPT = `
	var m = require("m.js");
	m.test();
	`

	const MODULE = `
	function test() {
		return "passed1";
	}

	exports.test = test;
	`

	vm := js.New()

	registry := NewRegistry(WithGlobalFolders("."), WithLoader(func(name string) ([]byte, error) {
		if name == "m.js" {
			return []byte(MODULE), nil
		}
		return nil, errors.New("Module does not exist")
	}))
	registry.Enable(vm)

	v, err := vm.RunString(SCRIPT)
	if err != nil {
		t.Fatal(err)
	}

	if !v.StrictEquals(vm.ToValue("passed1")) {
		t.Fatalf("Unexpected result: %v", v)
	}
}

func TestStrictModule(t *testing.T) {
	const SCRIPT = `
	var m = require("m.js");
	m.test();
	`

	const MODULE = `
	"use strict";

	function test() {
		var a = "passed1";
		eval("var a = 'not passed'");
		return a;
	}

	exports.test = test;
	`

	vm := js.New()

	registry := NewRegistry(WithGlobalFolders("."), WithLoader(func(name string) ([]byte, error) {
		if name == "m.js" {
			return []byte(MODULE), nil
		}
		return nil, errors.New("Module does not exist")
	}))
	registry.Enable(vm)

	v, err := vm.RunString(SCRIPT)
	if err != nil {
		t.Fatal(err)
	}

	if !v.StrictEquals(vm.ToValue("passed1")) {
		t.Fatalf("Unexpected result: %v", v)
	}
}

func TestResolve(t *testing.T) {
	testRequire := func(src, fpath string, globalFolders []string, fs map[string]string) (*js.Runtime, js.Value, error) {
		vm := js.New()
		r := NewRegistry(WithGlobalFolders(globalFolders...), WithLoader(mapFileSystemSourceLoader(fs)))
		r.Enable(vm)
		t.Logf("Require(%s)", fpath)
		ret, err := vm.RunScript(path.Join(src, "test.js"), fmt.Sprintf("require('%s')", fpath))
		if err != nil {
			return nil, nil, err
		}
		return vm, ret, nil
	}

	globalFolders := []string{
		"/usr/lib/node_modules",
		"/home/src/.node_modules",
	}

	fs := map[string]string{
		"/home/src/app/app.js":                   `exports.name = "app"`,
		"/home/src/app2/app2.json":               `{"name": "app2"}`,
		"/home/src/app3/index.js":                `exports.name = "app3"`,
		"/home/src/app4/index.json":              `{"name": "app4"}`,
		"/home/src/app5/package.json":            `{"main": "app5.js"}`,
		"/home/src/app5/app5.js":                 `exports.name = "app5"`,
		"/home/src/app6/package.json":            `{"main": "."}`,
		"/home/src/app6/index.js":                `exports.name = "app6"`,
		"/home/src/app7/package.json":            `{"main": "./a/b/c/file.js"}`,
		"/home/src/app7/a/b/c/file.js":           `exports.name = "app7"`,
		"/usr/lib/node_modules/app8":             `exports.name = "app8"`,
		"/home/src/app9/app9.js":                 `exports.name = require('./a/file.js').name`,
		"/home/src/app9/a/file.js":               `exports.name = require('./b/file.js').name`,
		"/home/src/app9/a/b/file.js":             `exports.name = require('./c/file.js').name`,
		"/home/src/app9/a/b/c/file.js":           `exports.name = "app9"`,
		"/home/src/.node_modules/app10":          `exports.name = "app10"`,
		"/home/src/app11/app11.js":               `exports.name = require('d/file.js').name`,
		"/home/src/app11/a/b/c/app11.js":         `exports.name = require('d/file.js').name`,
		"/home/src/app11/node_modules/d/file.js": `exports.name = "app11"`,
		"/app12.js":                              `exports.name = require('a/file.js').name`,
		"/node_modules/a/file.js":                `exports.name = "app12"`,
		"/app13/app13.js":                        `exports.name = require('b/file.js').name`,
		"/node_modules/b/file.js":                `exports.name = "app13"`,
		"node_modules/app14/index.js":            `exports.name = "app14"`,
		"../node_modules/app15/index.js":         `exports.name = "app15"`,
	}

	for i, tc := range []struct {
		src   string
		path  string
		ok    bool
		field string
		value string
	}{
		{"/home/src", "./app/app", true, "name", "app"},
		{"/home/src", "./app/app.js", true, "name", "app"},
		{"/home/src", "./app/bad.js", false, "", ""},
		{"/home/src", "./app2/app2", true, "name", "app2"},
		{"/home/src", "./app2/app2.json", true, "name", "app2"},
		{"/home/src", "./app/bad.json", false, "", ""},
		{"/home/src", "./app3", true, "name", "app3"},
		{"/home/src", "./appbad", false, "", ""},
		{"/home/src", "./app4", true, "name", "app4"},
		{"/home/src", "./appbad", false, "", ""},
		{"/home/src", "./app5", true, "name", "app5"},
		{"/home/src", "./app6", true, "name", "app6"},
		{"/home/src", "./app7", true, "name", "app7"},
		{"/home/src", "app8", true, "name", "app8"},
		{"/home/src", "./app9/app9", true, "name", "app9"},
		{"/home/src", "app10", true, "name", "app10"},
		{"/home/src", "./app11/app11.js", true, "name", "app11"},
		{"/home/src", "./app11/a/b/c/app11.js", true, "name", "app11"},
		{"/", "./app12", true, "name", "app12"},
		{"/", "./app13/app13", true, "name", "app13"},
		{".", "app14", true, "name", "app14"},
		{"..", "nonexistent", false, "", ""},
	} {
		vm, mod, err := testRequire(tc.src, tc.path, globalFolders, fs)
		if err != nil {
			if tc.ok {
				t.Errorf("%d: require() failed: %v", i, err)
			}
			continue
		} else {
			if !tc.ok {
				t.Errorf("%d: expected to fail, but did not", i)
				continue
			}
		}
		f := mod.ToObject(vm).Get(tc.field)
		if f == nil {
			t.Errorf("%v: field %q not found", i, tc.field)
			continue
		}
		value := f.String()
		if value != tc.value {
			t.Errorf("%v: got %q expected %q", i, value, tc.value)
		}
	}
}

func TestRequireCycle(t *testing.T) {
	vm := js.New()
	r := NewRegistry(WithLoader(mapFileSystemSourceLoader(map[string]string{
		"a.js": `var b = require('./b.js'); exports.done = true;`,
		"b.js": `var a = require('./a.js'); exports.done = true;`,
	})))
	r.Enable(vm)
	res, err := vm.RunString(`
	var a = require('./a.js');
	var b = require('./b.js');
	a.done && b.done;
	`)
	if err != nil {
		t.Fatal(err)
	}
	if v := res.Export(); v != true {
		t.Fatalf("Unexpected result: %v", v)
	}
}

func TestErrorPropagation(t *testing.T) {
	vm := js.New()
	r := NewRegistry(WithLoader(mapFileSystemSourceLoader(map[string]string{
		"m.js": `throw 'test passed';`,
	})))
	rr := r.Enable(vm)
	_, err := rr.Require("./m")
	if err == nil {
		t.Fatal("Expected an error")
	}
	if ex, ok := err.(*js.Exception); ok {
		if !ex.Value().StrictEquals(vm.ToValue("test passed")) {
			t.Fatalf("Unexpected Exception: %v", ex)
		}
	} else {
		t.Fatal(err)
	}
}

func TestSourceMapLoader(t *testing.T) {
	vm := js.New()
	r := NewRegistry(WithLoader(func(p string) ([]byte, error) {
		switch filepath.ToSlash(p) {
		case "dir/m.js":
			return []byte(`throw 'test passed';
//# sourceMappingURL=m.js.map`), nil
		case "dir/m.js.map":
			return []byte(`{"version":3,"file":"m.js","sourceRoot":"","sources":["m.ts"],"names":[],"mappings":";AAAA"}
`), nil
		}
		return nil, ModuleFileDoesNotExistError
	}))

	rr := r.Enable(vm)
	_, err := rr.Require("./dir/m")
	if err == nil {
		t.Fatal("Expected an error")
	}
	if ex, ok := err.(*js.Exception); ok {
		if !ex.Value().StrictEquals(vm.ToValue("test passed")) {
			t.Fatalf("Unexpected Exception: %v", ex)
		}
	} else {
		t.Fatal(err)
	}
}

func testsetup() (string, func(), error) {
	name, err := os.MkdirTemp("", "goja-nodejs-require-test")
	if err != nil {
		return "", nil, err
	}
	return name, func() {
		os.RemoveAll(name)
	}, nil
}

func TestDefaultModuleLoader(t *testing.T) {
	workdir, teardown, err := testsetup()
	if err != nil {
		t.Fatal(err)
	}
	defer teardown()

	err = os.Chdir(workdir)
	if err != nil {
		t.Fatal(err)
	}
	err = os.Mkdir("module", 0755)
	if err != nil {
		t.Fatal(err)
	}
	err = os.WriteFile("module/index.js", []byte(`throw 'test passed';`), 0644)
	if err != nil {
		t.Fatal(err)
	}
	vm := js.New()
	r := NewRegistry()
	rr := r.Enable(vm)
	_, err = rr.Require("./module")
	if err == nil {
		t.Fatal("Expected an error")
	}
	if ex, ok := err.(*js.Exception); ok {
		if !ex.Value().StrictEquals(vm.ToValue("test passed")) {
			t.Fatalf("Unexpected Exception: %v", ex)
		}
	} else {
		t.Fatal(err)
	}
}

func TestDefaultPathResolver(t *testing.T) {
	workdir, teardown, err := testsetup()
	if err != nil {
		t.Fatal(err)
	}
	defer teardown()

	err = os.Chdir(workdir)
	if err != nil {
		t.Fatal(err)
	}
	err = os.Mkdir("node_modules", 0755)
	if err != nil {
		t.Fatal(err)
	}
	err = os.Mkdir("node_modules/a", 0755)
	if err != nil {
		t.Fatal(err)
	}
	err = os.Mkdir("node_modules/a/node_modules", 0755)
	if err != nil {
		t.Fatal(err)
	}
	err = os.Mkdir("node_modules/b", 0755)
	if err != nil {
		t.Fatal(err)
	}
	err = os.Symlink("../../b", "node_modules/a/node_modules/b")
	if err != nil {
		if runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1314)) { // ERROR_PRIVILEGE_NOT_HELD
			t.Skip("Creating symlinks on Windows requires admin privileges")
		}
		t.Fatal(err)
	}

	err = os.WriteFile("node_modules/b/index.js", []byte(``), 0644)
	if err != nil {
		t.Fatal(err)
	}
	err = os.WriteFile("node_modules/a/index.js", []byte(`require('b')`), 0644)
	if err != nil {
		t.Fatal(err)
	}
	vm := js.New()
	r := NewRegistry()
	rr := r.Enable(vm)
	_, err = rr.Require("a")
	if err != nil {
		t.Fatal(err)
	}
}
