// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package cache

import (
	"context"
	"errors"
	"os"
	"strings"
	"testing"

	"golang.org/x/mod/modfile"
	"golang.org/x/tools/internal/lsp/fake"
	"golang.org/x/tools/internal/lsp/source"
	"golang.org/x/tools/internal/span"
)

// osFileSource is a fileSource that just reads from the operating system.
type osFileSource struct {
	overlays map[span.URI]fakeOverlay
}

type fakeOverlay struct {
	source.VersionedFileHandle
	uri     span.URI
	content string
	err     error
	saved   bool
}

func (o fakeOverlay) Saved() bool { return o.saved }

func (o fakeOverlay) Read() ([]byte, error) {
	if o.err != nil {
		return nil, o.err
	}
	return []byte(o.content), nil
}

func (o fakeOverlay) URI() span.URI {
	return o.uri
}

// change updates the file source with the given file content. For convenience,
// empty content signals a deletion. If saved is true, these changes are
// persisted to disk.
func (s *osFileSource) change(ctx context.Context, uri span.URI, content string, saved bool) (*fileChange, error) {
	if content == "" {
		delete(s.overlays, uri)
		if saved {
			if err := os.Remove(uri.Filename()); err != nil {
				return nil, err
			}
		}
		fh, err := s.GetFile(ctx, uri)
		if err != nil {
			return nil, err
		}
		data, err := fh.Read()
		return &fileChange{exists: err == nil, content: data, fileHandle: &closedFile{fh}}, nil
	}
	if s.overlays == nil {
		s.overlays = map[span.URI]fakeOverlay{}
	}
	s.overlays[uri] = fakeOverlay{uri: uri, content: content, saved: saved}
	return &fileChange{
		exists:     content != "",
		content:    []byte(content),
		fileHandle: s.overlays[uri],
	}, nil
}

func (s *osFileSource) GetFile(ctx context.Context, uri span.URI) (source.FileHandle, error) {
	if overlay, ok := s.overlays[uri]; ok {
		return overlay, nil
	}
	fi, statErr := os.Stat(uri.Filename())
	if statErr != nil {
		return &fileHandle{
			err: statErr,
			uri: uri,
		}, nil
	}
	fh, err := readFile(ctx, uri, fi)
	if err != nil {
		return nil, err
	}
	return fh, nil
}

type wsState struct {
	source  workspaceSource
	modules []string
	dirs    []string
	sum     string
}

type wsChange struct {
	content string
	saved   bool
}

func TestWorkspaceModule(t *testing.T) {
	tests := []struct {
		desc         string
		initial      string // txtar-encoded
		legacyMode   bool
		initialState wsState
		updates      map[string]wsChange
		wantChanged  bool
		wantReload   bool
		finalState   wsState
	}{
		{
			desc: "legacy mode",
			initial: `
-- go.mod --
module mod.com
-- go.sum --
golang.org/x/mod v0.3.0 h1:deadbeef
-- a/go.mod --
module moda.com`,
			legacyMode: true,
			initialState: wsState{
				modules: []string{"./go.mod"},
				source:  legacyWorkspace,
				dirs:    []string{"."},
				sum:     "golang.org/x/mod v0.3.0 h1:deadbeef\n",
			},
		},
		{
			desc: "nested module",
			initial: `
-- go.mod --
module mod.com
-- a/go.mod --
module moda.com`,
			initialState: wsState{
				modules: []string{"./go.mod", "a/go.mod"},
				source:  fileSystemWorkspace,
				dirs:    []string{".", "a"},
			},
		},
		{
			desc: "removing module",
			initial: `
-- a/go.mod --
module moda.com
-- a/go.sum --
golang.org/x/mod v0.3.0 h1:deadbeef
-- b/go.mod --
module modb.com
-- b/go.sum --
golang.org/x/mod v0.3.0 h1:beefdead`,
			initialState: wsState{
				modules: []string{"a/go.mod", "b/go.mod"},
				source:  fileSystemWorkspace,
				dirs:    []string{".", "a", "b"},
				sum:     "golang.org/x/mod v0.3.0 h1:beefdead\ngolang.org/x/mod v0.3.0 h1:deadbeef\n",
			},
			updates: map[string]wsChange{
				"gopls.mod": {`module gopls-workspace

require moda.com v0.0.0-goplsworkspace
replace moda.com => $SANDBOX_WORKDIR/a`, true},
			},
			wantChanged: true,
			wantReload:  true,
			finalState: wsState{
				modules: []string{"a/go.mod"},
				source:  goplsModWorkspace,
				dirs:    []string{".", "a"},
				sum:     "golang.org/x/mod v0.3.0 h1:deadbeef\n",
			},
		},
		{
			desc: "adding module",
			initial: `
-- gopls.mod --
require moda.com v0.0.0-goplsworkspace
replace moda.com => $SANDBOX_WORKDIR/a
-- a/go.mod --
module moda.com
-- b/go.mod --
module modb.com`,
			initialState: wsState{
				modules: []string{"a/go.mod"},
				source:  goplsModWorkspace,
				dirs:    []string{".", "a"},
			},
			updates: map[string]wsChange{
				"gopls.mod": {`module gopls-workspace

require moda.com v0.0.0-goplsworkspace
require modb.com v0.0.0-goplsworkspace

replace moda.com => $SANDBOX_WORKDIR/a
replace modb.com => $SANDBOX_WORKDIR/b`, true},
			},
			wantChanged: true,
			wantReload:  true,
			finalState: wsState{
				modules: []string{"a/go.mod", "b/go.mod"},
				source:  goplsModWorkspace,
				dirs:    []string{".", "a", "b"},
			},
		},
		{
			desc: "deleting gopls.mod",
			initial: `
-- gopls.mod --
module gopls-workspace

require moda.com v0.0.0-goplsworkspace
replace moda.com => $SANDBOX_WORKDIR/a
-- a/go.mod --
module moda.com
-- b/go.mod --
module modb.com`,
			initialState: wsState{
				modules: []string{"a/go.mod"},
				source:  goplsModWorkspace,
				dirs:    []string{".", "a"},
			},
			updates: map[string]wsChange{
				"gopls.mod": {"", true},
			},
			wantChanged: true,
			wantReload:  true,
			finalState: wsState{
				modules: []string{"a/go.mod", "b/go.mod"},
				source:  fileSystemWorkspace,
				dirs:    []string{".", "a", "b"},
			},
		},
		{
			desc: "broken module parsing",
			initial: `
-- a/go.mod --
module moda.com

require gopls.test v0.0.0-goplsworkspace
replace gopls.test => ../../gopls.test // (this path shouldn't matter)
-- b/go.mod --
module modb.com`,
			initialState: wsState{
				modules: []string{"a/go.mod", "b/go.mod"},
				source:  fileSystemWorkspace,
				dirs:    []string{".", "a", "b", "../gopls.test"},
			},
			updates: map[string]wsChange{
				"a/go.mod": {`modul moda.com

require gopls.test v0.0.0-goplsworkspace
replace gopls.test => ../../gopls.test2`, false},
			},
			wantChanged: true,
			wantReload:  false,
			finalState: wsState{
				modules: []string{"a/go.mod", "b/go.mod"},
				source:  fileSystemWorkspace,
				// finalDirs should be unchanged: we should preserve dirs in the presence
				// of a broken modfile.
				dirs: []string{".", "a", "b", "../gopls.test"},
			},
		},
	}

	for _, test := range tests {
		t.Run(test.desc, func(t *testing.T) {
			ctx := context.Background()
			dir, err := fake.Tempdir(fake.UnpackTxt(test.initial))
			if err != nil {
				t.Fatal(err)
			}
			defer os.RemoveAll(dir)
			root := span.URIFromPath(dir)

			fs := &osFileSource{}
			excludeNothing := func(string) bool { return false }
			w, err := newWorkspace(ctx, root, fs, excludeNothing, false, !test.legacyMode)
			if err != nil {
				t.Fatal(err)
			}
			rel := fake.RelativeTo(dir)
			checkState(ctx, t, fs, rel, w, test.initialState)

			// Apply updates.
			if test.updates != nil {
				changes := make(map[span.URI]*fileChange)
				for k, v := range test.updates {
					content := strings.ReplaceAll(v.content, "$SANDBOX_WORKDIR", string(rel))
					uri := span.URIFromPath(rel.AbsPath(k))
					changes[uri], err = fs.change(ctx, uri, content, v.saved)
					if err != nil {
						t.Fatal(err)
					}
				}
				got, gotChanged, gotReload := w.invalidate(ctx, changes, fs)
				if gotChanged != test.wantChanged {
					t.Errorf("w.invalidate(): got changed %t, want %t", gotChanged, test.wantChanged)
				}
				if gotReload != test.wantReload {
					t.Errorf("w.invalidate(): got reload %t, want %t", gotReload, test.wantReload)
				}
				checkState(ctx, t, fs, rel, got, test.finalState)
			}
		})
	}
}

func workspaceFromTxtar(t *testing.T, files string) (*workspace, func(), error) {
	ctx := context.Background()
	dir, err := fake.Tempdir(fake.UnpackTxt(files))
	if err != nil {
		return nil, func() {}, err
	}
	cleanup := func() {
		os.RemoveAll(dir)
	}
	root := span.URIFromPath(dir)

	fs := &osFileSource{}
	excludeNothing := func(string) bool { return false }
	workspace, err := newWorkspace(ctx, root, fs, excludeNothing, false, false)
	return workspace, cleanup, err
}

func TestWorkspaceParseError(t *testing.T) {
	w, cleanup, err := workspaceFromTxtar(t, `
-- go.work --
go 1.18

usa ./typo
-- typo/go.mod --
module foo
`)
	defer cleanup()
	if err != nil {
		t.Fatalf("error creating workspace: %v; want no error", err)
	}
	w.buildMu.Lock()
	built, buildErr := w.built, w.buildErr
	w.buildMu.Unlock()
	if !built || buildErr == nil {
		t.Fatalf("built, buildErr: got %v, %v; want true, non-nil", built, buildErr)
	}
	var errList modfile.ErrorList
	if !errors.As(buildErr, &errList) {
		t.Fatalf("expected error to be an errorlist; got %v", buildErr)
	}
	if len(errList) != 1 {
		t.Fatalf("expected errorList to have one element; got %v elements", len(errList))
	}
	parseErr := errList[0]
	if parseErr.Pos.Line != 3 {
		t.Fatalf("expected error to be on line 3; got %v", parseErr.Pos.Line)
	}
}

func TestWorkspaceMissingModFile(t *testing.T) {
	w, cleanup, err := workspaceFromTxtar(t, `
-- go.work --
go 1.18

use ./missing
`)
	defer cleanup()
	if err != nil {
		t.Fatalf("error creating workspace: %v; want no error", err)
	}
	w.buildMu.Lock()
	built, buildErr := w.built, w.buildErr
	w.buildMu.Unlock()
	if !built || buildErr == nil {
		t.Fatalf("built, buildErr: got %v, %v; want true, non-nil", built, buildErr)
	}
}

func checkState(ctx context.Context, t *testing.T, fs source.FileSource, rel fake.RelativeTo, got *workspace, want wsState) {
	t.Helper()
	if got.moduleSource != want.source {
		t.Errorf("module source = %v, want %v", got.moduleSource, want.source)
	}
	modules := make(map[span.URI]struct{})
	for k := range got.getActiveModFiles() {
		modules[k] = struct{}{}
	}
	for _, modPath := range want.modules {
		path := rel.AbsPath(modPath)
		uri := span.URIFromPath(path)
		if _, ok := modules[uri]; !ok {
			t.Errorf("missing module %q", uri)
		}
		delete(modules, uri)
	}
	for remaining := range modules {
		t.Errorf("unexpected module %q", remaining)
	}
	gotDirs := got.dirs(ctx, fs)
	gotM := make(map[span.URI]bool)
	for _, dir := range gotDirs {
		gotM[dir] = true
	}
	for _, dir := range want.dirs {
		path := rel.AbsPath(dir)
		uri := span.URIFromPath(path)
		if !gotM[uri] {
			t.Errorf("missing dir %q", uri)
		}
		delete(gotM, uri)
	}
	for remaining := range gotM {
		t.Errorf("unexpected dir %q", remaining)
	}
	gotSumBytes, err := got.sumFile(ctx, fs)
	if err != nil {
		t.Fatal(err)
	}
	if gotSum := string(gotSumBytes); gotSum != want.sum {
		t.Errorf("got final sum %q, want %q", gotSum, want.sum)
	}
}
