// 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 fake

import (
	"context"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"
	"sync"

	"golang.org/x/tools/internal/gocommand"
	"golang.org/x/tools/internal/lsp/protocol"
	"golang.org/x/tools/internal/span"
	"golang.org/x/tools/txtar"
)

// FileEvent wraps the protocol.FileEvent so that it can be associated with a
// workspace-relative path.
type FileEvent struct {
	Path          string
	ProtocolEvent protocol.FileEvent
}

// The Workspace type represents a temporary workspace to use for editing Go
// files in tests.
type Workspace struct {
	name    string
	gopath  string
	workdir string

	watcherMu sync.Mutex
	watchers  []func(context.Context, []FileEvent)
}

// NewWorkspace creates a named workspace populated by the txtar-encoded
// content given by txt. It creates temporary directories for the workspace
// content and for GOPATH.
func NewWorkspace(name string, txt []byte) (_ *Workspace, err error) {
	w := &Workspace{name: name}
	defer func() {
		// Clean up if we fail at any point in this constructor.
		if err != nil {
			w.removeAll()
		}
	}()
	dir, err := ioutil.TempDir("", fmt.Sprintf("goplstest-ws-%s-", name))
	if err != nil {
		return nil, fmt.Errorf("creating temporary workdir: %v", err)
	}
	w.workdir = dir
	gopath, err := ioutil.TempDir("", fmt.Sprintf("goplstest-gopath-%s-", name))
	if err != nil {
		return nil, fmt.Errorf("creating temporary gopath: %v", err)
	}
	w.gopath = gopath
	archive := txtar.Parse(txt)
	for _, f := range archive.Files {
		if err := w.writeFileData(f.Name, string(f.Data)); err != nil {
			return nil, err
		}
	}
	return w, nil
}

// RootURI returns the root URI for this workspace.
func (w *Workspace) RootURI() protocol.DocumentURI {
	return toURI(w.workdir)
}

// GOPATH returns the value that GOPATH should be set to for this workspace.
func (w *Workspace) GOPATH() string {
	return w.gopath
}

// AddWatcher registers the given func to be called on any file change.
func (w *Workspace) AddWatcher(watcher func(context.Context, []FileEvent)) {
	w.watcherMu.Lock()
	w.watchers = append(w.watchers, watcher)
	w.watcherMu.Unlock()
}

// filePath returns the absolute filesystem path to a the workspace-relative
// path.
func (w *Workspace) filePath(path string) string {
	fp := filepath.FromSlash(path)
	if filepath.IsAbs(fp) {
		return fp
	}
	return filepath.Join(w.workdir, filepath.FromSlash(path))
}

// URI returns the URI to a the workspace-relative path.
func (w *Workspace) URI(path string) protocol.DocumentURI {
	return toURI(w.filePath(path))
}

// URIToPath converts a uri to a workspace-relative path (or an absolute path,
// if the uri is outside of the workspace).
func (w *Workspace) URIToPath(uri protocol.DocumentURI) string {
	root := w.RootURI().SpanURI().Filename()
	path := uri.SpanURI().Filename()
	if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") {
		return filepath.ToSlash(rel)
	}
	return filepath.ToSlash(path)
}

func toURI(fp string) protocol.DocumentURI {
	return protocol.DocumentURI(span.URIFromPath(fp))
}

// ReadFile reads a text file specified by a workspace-relative path.
func (w *Workspace) ReadFile(path string) (string, error) {
	b, err := ioutil.ReadFile(w.filePath(path))
	if err != nil {
		return "", err
	}
	return string(b), nil
}

// RegexpSearch searches the file
func (w *Workspace) RegexpSearch(path string, re string) (Pos, error) {
	content, err := w.ReadFile(path)
	if err != nil {
		return Pos{}, err
	}
	start, _, err := regexpRange(content, re)
	return start, err
}

// RemoveFile removes a workspace-relative file path.
func (w *Workspace) RemoveFile(ctx context.Context, path string) error {
	fp := w.filePath(path)
	if err := os.Remove(fp); err != nil {
		return fmt.Errorf("removing %q: %v", path, err)
	}
	evts := []FileEvent{{
		Path: path,
		ProtocolEvent: protocol.FileEvent{
			URI:  w.URI(path),
			Type: protocol.Deleted,
		},
	}}
	w.sendEvents(ctx, evts)
	return nil
}

// RunGoCommand executes a go command in the workspace.
func (w *Workspace) RunGoCommand(ctx context.Context, verb string, args ...string) error {
	inv := gocommand.Invocation{
		Verb:       verb,
		Args:       args,
		WorkingDir: w.workdir,
	}
	_, stderr, _, err := inv.RunRaw(ctx)
	if err != nil {
		return err
	}
	// Hardcoded "file watcher": If the command executed was "go mod init",
	// send a file creation event for a go.mod in the working directory.
	if strings.HasPrefix(stderr.String(), "go: creating new go.mod") {
		modpath := filepath.Join(w.workdir, "go.mod")
		w.sendEvents(ctx, []FileEvent{{
			Path: modpath,
			ProtocolEvent: protocol.FileEvent{
				URI:  toURI(modpath),
				Type: protocol.Created,
			},
		}})
	}
	return nil
}

func (w *Workspace) sendEvents(ctx context.Context, evts []FileEvent) {
	w.watcherMu.Lock()
	watchers := make([]func(context.Context, []FileEvent), len(w.watchers))
	copy(watchers, w.watchers)
	w.watcherMu.Unlock()
	for _, w := range watchers {
		go w(ctx, evts)
	}
}

// WriteFile writes text file content to a workspace-relative path.
func (w *Workspace) WriteFile(ctx context.Context, path, content string) error {
	fp := w.filePath(path)
	_, err := os.Stat(fp)
	if err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("checking if %q exists: %v", path, err)
	}
	var changeType protocol.FileChangeType
	if os.IsNotExist(err) {
		changeType = protocol.Created
	} else {
		changeType = protocol.Changed
	}
	if err := w.writeFileData(path, content); err != nil {
		return err
	}
	evts := []FileEvent{{
		Path: path,
		ProtocolEvent: protocol.FileEvent{
			URI:  w.URI(path),
			Type: changeType,
		},
	}}
	w.sendEvents(ctx, evts)
	return nil
}

func (w *Workspace) writeFileData(path string, content string) error {
	fp := w.filePath(path)
	if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
		return fmt.Errorf("creating nested directory: %v", err)
	}
	if err := ioutil.WriteFile(fp, []byte(content), 0644); err != nil {
		return fmt.Errorf("writing %q: %v", path, err)
	}
	return nil
}

func (w *Workspace) removeAll() error {
	var werr, perr error
	if w.workdir != "" {
		werr = os.RemoveAll(w.workdir)
	}
	if w.gopath != "" {
		perr = os.RemoveAll(w.gopath)
	}
	if werr != nil || perr != nil {
		return fmt.Errorf("error(s) cleaning workspace: removing workdir: %v; removing gopath: %v", werr, perr)
	}
	return nil
}

// Close removes all state associated with the workspace.
func (w *Workspace) Close() error {
	return w.removeAll()
}
