package scope

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"reflect"
	"testing"

	"github.com/hashicorp/go-hclog"
	"github.com/pyr-sh/dag"
	"github.com/vercel/turbo/cli/internal/context"
	"github.com/vercel/turbo/cli/internal/fs"
	"github.com/vercel/turbo/cli/internal/lockfile"
	"github.com/vercel/turbo/cli/internal/packagemanager"
	"github.com/vercel/turbo/cli/internal/turbopath"
	"github.com/vercel/turbo/cli/internal/ui"
	"github.com/vercel/turbo/cli/internal/util"
	"github.com/vercel/turbo/cli/internal/workspace"
)

type mockSCM struct {
	changed  []string
	contents map[string][]byte
}

func (m *mockSCM) ChangedFiles(_fromCommit string, _toCommit string, _relativeTo string) ([]string, error) {
	return m.changed, nil
}

func (m *mockSCM) PreviousContent(fromCommit string, filePath string) ([]byte, error) {
	contents, ok := m.contents[filePath]
	if !ok {
		return nil, fmt.Errorf("No contents found")
	}
	return contents, nil
}

type mockLockfile struct {
	globalChange bool
	versions     map[string]string
	allDeps      map[string]map[string]string
}

func (m *mockLockfile) ResolvePackage(workspacePath turbopath.AnchoredUnixPath, name string, version string) (lockfile.Package, error) {
	resolvedVersion, ok := m.versions[name]
	if ok {
		key := fmt.Sprintf("%s%s", name, version)
		return lockfile.Package{Key: key, Version: resolvedVersion, Found: true}, nil
	}
	return lockfile.Package{Found: false}, nil
}

func (m *mockLockfile) AllDependencies(key string) (map[string]string, bool) {
	deps, ok := m.allDeps[key]
	return deps, ok
}

func (m *mockLockfile) Encode(w io.Writer) error {
	return nil
}

func (m *mockLockfile) GlobalChange(other lockfile.Lockfile) bool {
	return m.globalChange || (other != nil && other.(*mockLockfile).globalChange)
}

func (m *mockLockfile) Patches() []turbopath.AnchoredUnixPath {
	return nil
}

func (m *mockLockfile) Subgraph(workspaces []turbopath.AnchoredSystemPath, packages []string) (lockfile.Lockfile, error) {
	return nil, nil
}

var _ (lockfile.Lockfile) = (*mockLockfile)(nil)

func TestResolvePackages(t *testing.T) {
	cwd, err := os.Getwd()
	if err != nil {
		t.Fatalf("cwd: %v", err)
	}
	root, err := fs.GetCwd(cwd)
	if err != nil {
		t.Fatalf("cwd: %v", err)
	}
	tui := ui.Default()
	logger := hclog.Default()
	// Dependency graph:
	//
	// app0 -
	//        \
	// app1 -> libA
	//              \
	//                > libB -> libD
	//              /
	//       app2 <
	//              \
	//                > libC
	//              /
	//     app2-a <
	//
	// Filesystem layout:
	//
	// app/
	//   app0
	//   app1
	//   app2
	//   app2-a
	// libs/
	//   libA
	//   libB
	//   libC
	//   libD
	graph := dag.AcyclicGraph{}
	graph.Add("app0")
	graph.Add("app1")
	graph.Add("app2")
	graph.Add("app2-a")
	graph.Add("libA")
	graph.Add("libB")
	graph.Add("libC")
	graph.Add("libD")
	graph.Connect(dag.BasicEdge("libA", "libB"))
	graph.Connect(dag.BasicEdge("libB", "libD"))
	graph.Connect(dag.BasicEdge("app0", "libA"))
	graph.Connect(dag.BasicEdge("app1", "libA"))
	graph.Connect(dag.BasicEdge("app2", "libB"))
	graph.Connect(dag.BasicEdge("app2", "libC"))
	graph.Connect(dag.BasicEdge("app2-a", "libC"))
	workspaceInfos := workspace.Catalog{
		PackageJSONs: map[string]*fs.PackageJSON{
			"//": {
				Dir:                    turbopath.AnchoredSystemPath("").ToSystemPath(),
				UnresolvedExternalDeps: map[string]string{"global": "2"},
				TransitiveDeps:         []lockfile.Package{{Key: "global2", Version: "2", Found: true}},
			},
			"app0": {
				Dir:                    turbopath.AnchoredUnixPath("app/app0").ToSystemPath(),
				Name:                   "app0",
				UnresolvedExternalDeps: map[string]string{"app0-dep": "2"},
				TransitiveDeps: []lockfile.Package{
					{Key: "app0-dep2", Version: "2", Found: true},
					{Key: "app0-util2", Version: "2", Found: true},
				},
			},
			"app1": {
				Dir:  turbopath.AnchoredUnixPath("app/app1").ToSystemPath(),
				Name: "app1",
			},
			"app2": {
				Dir:  turbopath.AnchoredUnixPath("app/app2").ToSystemPath(),
				Name: "app2",
			},
			"app2-a": {
				Dir:  turbopath.AnchoredUnixPath("app/app2-a").ToSystemPath(),
				Name: "app2-a",
			},
			"libA": {
				Dir:  turbopath.AnchoredUnixPath("libs/libA").ToSystemPath(),
				Name: "libA",
			},
			"libB": {
				Dir:                    turbopath.AnchoredUnixPath("libs/libB").ToSystemPath(),
				Name:                   "libB",
				UnresolvedExternalDeps: map[string]string{"external": "1"},
				TransitiveDeps: []lockfile.Package{
					{Key: "external-dep-a1", Version: "1", Found: true},
					{Key: "external-dep-b1", Version: "1", Found: true},
					{Key: "external1", Version: "1", Found: true},
				},
			},
			"libC": {
				Dir:  turbopath.AnchoredUnixPath("libs/libC").ToSystemPath(),
				Name: "libC",
			},
			"libD": {
				Dir:  turbopath.AnchoredUnixPath("libs/libD").ToSystemPath(),
				Name: "libD",
			},
		},
	}
	packageNames := []string{}
	for name := range workspaceInfos.PackageJSONs {
		packageNames = append(packageNames, name)
	}

	// global -> globalDep
	// app0-dep -> app0-dep :)

	makeLockfile := func(f func(*mockLockfile)) *mockLockfile {
		l := mockLockfile{
			globalChange: false,
			versions: map[string]string{
				"global":         "2",
				"app0-dep":       "2",
				"app0-util":      "2",
				"external":       "1",
				"external-dep-a": "1",
				"external-dep-b": "1",
			},
			allDeps: map[string]map[string]string{
				"global2": map[string]string{},
				"app0-dep2": map[string]string{
					"app0-util": "2",
				},
				"app0-util2": map[string]string{},
				"external1": map[string]string{
					"external-dep-a": "1",
					"external-dep-b": "1",
				},
				"external-dep-a1": map[string]string{},
				"external-dep-b1": map[string]string{},
			},
		}
		if f != nil {
			f(&l)
		}
		return &l
	}

	testCases := []struct {
		name                string
		changed             []string
		expected            []string
		expectAllPackages   bool
		scope               []string
		since               string
		ignore              string
		globalDeps          []string
		includeDependencies bool
		includeDependents   bool
		lockfile            string
		currLockfile        *mockLockfile
		prevLockfile        *mockLockfile
		inferPkgPath        string
	}{
		{
			name:                "Just scope and dependencies",
			changed:             []string{},
			includeDependencies: true,
			scope:               []string{"app2"},
			expected:            []string{"app2", "libB", "libC", "libD"},
		},
		{
			name:                "Only turbo.json changed",
			changed:             []string{"turbo.json"},
			expected:            []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			since:               "dummy",
			includeDependencies: true,
		},
		{
			name:                "Only root package.json changed",
			changed:             []string{"package.json"},
			expected:            []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			since:               "dummy",
			includeDependencies: true,
		},
		{
			name:                "Only package-lock.json changed",
			changed:             []string{"package-lock.json"},
			expected:            []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			since:               "dummy",
			includeDependencies: true,
			lockfile:            "package-lock.json",
		},
		{
			name:                "Only yarn.lock changed",
			changed:             []string{"yarn.lock"},
			expected:            []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			since:               "dummy",
			includeDependencies: true,
			lockfile:            "yarn.lock",
		},
		{
			name:                "Only pnpm-lock.yaml changed",
			changed:             []string{"pnpm-lock.yaml"},
			expected:            []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			since:               "dummy",
			includeDependencies: true,
			lockfile:            "pnpm-lock.yaml",
		},
		{
			name:     "One package changed",
			changed:  []string{"libs/libB/src/index.ts"},
			expected: []string{"libB"},
			since:    "dummy",
		},
		{
			name:     "One package manifest changed",
			changed:  []string{"libs/libB/package.json"},
			expected: []string{"libB"},
			since:    "dummy",
		},
		{
			name:     "An ignored package changed",
			changed:  []string{"libs/libB/src/index.ts"},
			expected: []string{},
			since:    "dummy",
			ignore:   "libs/libB/**/*.ts",
		},
		{
			// nothing in scope depends on the change
			name:                "unrelated library changed",
			changed:             []string{"libs/libC/src/index.ts"},
			expected:            []string{},
			since:               "dummy",
			scope:               []string{"app1"},
			includeDependencies: true, // scope implies include-dependencies
		},
		{
			// a dependent lib changed, scope implies include-dependencies,
			// so all deps of app1 get built
			name:                "dependency of scope changed",
			changed:             []string{"libs/libA/src/index.ts"},
			expected:            []string{"libA", "libB", "libD", "app1"},
			since:               "dummy",
			scope:               []string{"app1"},
			includeDependencies: true, // scope implies include-dependencies
		},
		{
			// a dependent lib changed, user explicitly asked to not build dependencies.
			// Since the package matching the scope had a changed dependency, we run it.
			// We don't include its dependencies because the user asked for no dependencies.
			// note: this is not yet supported by the CLI, as you cannot specify --include-dependencies=false
			name:                "dependency of scope changed, user asked to not include depedencies",
			changed:             []string{"libs/libA/src/index.ts"},
			expected:            []string{"app1"},
			since:               "dummy",
			scope:               []string{"app1"},
			includeDependencies: false,
		},
		{
			// a nested dependent lib changed, user explicitly asked to not build dependencies
			// note: this is not yet supported by the CLI, as you cannot specify --include-dependencies=false
			name:                "nested dependency of scope changed, user asked to not include dependencies",
			changed:             []string{"libs/libB/src/index.ts"},
			expected:            []string{"app1"},
			since:               "dummy",
			scope:               []string{"app1"},
			includeDependencies: false,
		},
		{
			name:       "global dependency changed, even though it was ignored, forcing a build of everything",
			changed:    []string{"libs/libB/src/index.ts"},
			expected:   []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			since:      "dummy",
			ignore:     "libs/libB/**/*.ts",
			globalDeps: []string{"libs/**/*.ts"},
		},
		{
			name:                "an app changed, user asked for dependencies to build",
			changed:             []string{"app/app2/src/index.ts"},
			since:               "dummy",
			includeDependencies: true,
			expected:            []string{"app2", "libB", "libC", "libD"},
		},
		{
			name:              "a library changed, user asked for dependents to be built",
			changed:           []string{"libs/libB"},
			since:             "dummy",
			includeDependents: true,
			expected:          []string{"app0", "app1", "app2", "libA", "libB"},
		},
		{
			// no changes, no base to compare against, defaults to everything
			name:              "no changes or scope specified, build everything",
			since:             "",
			expected:          []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			expectAllPackages: true,
		},
		{
			// a dependent library changed, no deps beyond the scope are build
			// "libB" is still built because it is a dependent within the scope, but libB's dependents
			// are skipped
			name:                "a dependent library changed, build up to scope",
			changed:             []string{"libs/libD/src/index.ts"},
			since:               "dummy",
			scope:               []string{"libB"},
			expected:            []string{"libB", "libD"},
			includeDependencies: true, // scope implies include-dependencies
		},
		{
			name:              "library change, no scope",
			changed:           []string{"libs/libA/src/index.ts"},
			expected:          []string{"libA", "app0", "app1"},
			includeDependents: true,
			since:             "dummy",
		},
		{
			// make sure multiple apps with the same prefix are handled separately.
			// prevents this issue: https://github.com/vercel/turbo/issues/1528
			name:     "Two apps with an overlapping prefix changed",
			changed:  []string{"app/app2/src/index.js", "app/app2-a/src/index.js"},
			expected: []string{"app2", "app2-a"},
			since:    "dummy",
		},
		{
			name:         "Global lockfile change invalidates all packages",
			changed:      []string{"dummy.lock"},
			expected:     []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			lockfile:     "dummy.lock",
			currLockfile: makeLockfile(nil),
			prevLockfile: makeLockfile(func(ml *mockLockfile) {
				ml.globalChange = true
			}),
			since: "dummy",
		},
		{
			name:         "Dependency of workspace root change invalidates all packages",
			changed:      []string{"dummy.lock"},
			expected:     []string{"//", "app0", "app1", "app2", "app2-a", "libA", "libB", "libC", "libD"},
			lockfile:     "dummy.lock",
			currLockfile: makeLockfile(nil),
			prevLockfile: makeLockfile(func(ml *mockLockfile) {
				ml.versions["global"] = "3"
				ml.allDeps["global3"] = map[string]string{}
			}),
			since: "dummy",
		},
		{
			name:         "Version change invalidates package",
			changed:      []string{"dummy.lock"},
			expected:     []string{"//", "app0"},
			lockfile:     "dummy.lock",
			currLockfile: makeLockfile(nil),
			prevLockfile: makeLockfile(func(ml *mockLockfile) {
				ml.versions["app0-util"] = "3"
				ml.allDeps["app0-dep2"] = map[string]string{"app0-util": "3"}
				ml.allDeps["app0-util3"] = map[string]string{}
			}),
			since: "dummy",
		},
		{
			name:         "Transitive dep invalidates package",
			changed:      []string{"dummy.lock"},
			expected:     []string{"//", "libB"},
			lockfile:     "dummy.lock",
			currLockfile: makeLockfile(nil),
			prevLockfile: makeLockfile(func(ml *mockLockfile) {
				ml.versions["external-dep-a"] = "2"
				ml.allDeps["external1"] = map[string]string{"external-dep-a": "2", "external-dep-b": "1"}
				ml.allDeps["external-dep-a2"] = map[string]string{}
			}),
			since: "dummy",
		},
		{
			name:              "Transitive dep invalidates package and dependents",
			changed:           []string{"dummy.lock"},
			expected:          []string{"//", "app0", "app1", "app2", "libA", "libB"},
			lockfile:          "dummy.lock",
			includeDependents: true,
			currLockfile:      makeLockfile(nil),
			prevLockfile: makeLockfile(func(ml *mockLockfile) {
				ml.versions["external-dep-a"] = "2"
				ml.allDeps["external1"] = map[string]string{"external-dep-a": "2", "external-dep-b": "1"}
				ml.allDeps["external-dep-a2"] = map[string]string{}
			}),
			since: "dummy",
		},
		{
			name:         "Infer app2 from directory",
			inferPkgPath: "app/app2",
			expected:     []string{"app2"},
		},
		{
			name:         "Infer app2 from a subdirectory",
			inferPkgPath: "app/app2/src",
			expected:     []string{"app2"},
		},
		{
			name:         "Infer from a directory with no packages",
			inferPkgPath: "wrong",
			expected:     []string{},
		},
		{
			name:         "Infer from a parent directory",
			inferPkgPath: "app",
			expected:     []string{"app0", "app1", "app2", "app2-a"},
		},
		{
			name:         "library change, no scope, inferred libs",
			changed:      []string{"libs/libA/src/index.ts"},
			expected:     []string{"libA"},
			since:        "dummy",
			inferPkgPath: "libs",
		},
		{
			name:         "library change, no scope, inferred app",
			changed:      []string{"libs/libA/src/index.ts"},
			expected:     []string{},
			since:        "dummy",
			inferPkgPath: "app",
		},
	}
	for i, tc := range testCases {
		t.Run(fmt.Sprintf("test #%v %v", i, tc.name), func(t *testing.T) {
			// Convert test data to system separators.
			systemSeparatorChanged := make([]string, len(tc.changed))
			for index, path := range tc.changed {
				systemSeparatorChanged[index] = filepath.FromSlash(path)
			}
			scm := &mockSCM{
				changed:  systemSeparatorChanged,
				contents: make(map[string][]byte, len(systemSeparatorChanged)),
			}
			for _, path := range systemSeparatorChanged {
				scm.contents[path] = nil
			}
			readLockfile := func(contents []byte) (lockfile.Lockfile, error) {
				return tc.prevLockfile, nil
			}
			pkgs, isAllPackages, err := ResolvePackages(&Opts{
				LegacyFilter: LegacyFilter{
					Entrypoints:         tc.scope,
					Since:               tc.since,
					IncludeDependencies: tc.includeDependencies,
					SkipDependents:      !tc.includeDependents,
				},
				IgnorePatterns:       []string{tc.ignore},
				GlobalDepPatterns:    tc.globalDeps,
				PackageInferenceRoot: tc.inferPkgPath,
			}, root, scm, &context.Context{
				WorkspaceInfos: workspaceInfos,
				WorkspaceNames: packageNames,
				PackageManager: &packagemanager.PackageManager{Lockfile: tc.lockfile, UnmarshalLockfile: readLockfile},
				WorkspaceGraph: graph,
				RootNode:       "root",
				Lockfile:       tc.currLockfile,
			}, tui, logger)
			if err != nil {
				t.Errorf("expected no error, got %v", err)
			}
			expected := make(util.Set)
			for _, pkg := range tc.expected {
				expected.Add(pkg)
			}
			if !reflect.DeepEqual(pkgs, expected) {
				t.Errorf("ResolvePackages got %v, want %v", pkgs, expected)
			}
			if isAllPackages != tc.expectAllPackages {
				t.Errorf("isAllPackages got %v, want %v", isAllPackages, tc.expectAllPackages)
			}
		})
	}
}
