// Copyright 2018 The Bazel Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package bazel

import (
	"bytes"
	"errors"
	"fmt"
	"io/ioutil"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"sync"
)

const (
	RUNFILES_MANIFEST_FILE = "RUNFILES_MANIFEST_FILE"
	RUNFILES_DIR           = "RUNFILES_DIR"
)

// Runfile returns an absolute path to the file named by "path", which
// should be a relative path from the workspace root to the file within
// the bazel workspace.
//
// Runfile may be called from tests invoked with 'bazel test' and
// binaries invoked with 'bazel run'. On Windows,
// only tests invoked with 'bazel test' are supported.
//
// Deprecated: Use github.com/bazelbuild/rules_go/go/runfiles instead for
// cross-platform support matching the behavior of the Bazel-provided runfiles
// libraries.
func Runfile(path string) (string, error) {
	// Search in working directory
	if _, err := os.Stat(path); err == nil {
		return filepath.Abs(path)
	}

	if err := ensureRunfiles(); err != nil {
		return "", err
	}

	// Search manifest if we have one.
	if entry, ok := runfiles.index.GetIgnoringWorkspace(path); ok {
		return entry.Path, nil
	}

	if strings.HasPrefix(path, "../") || strings.HasPrefix(path, "external/") {
		pathParts := strings.Split(path, "/")
		if len(pathParts) >= 3 {
			workspace := pathParts[1]
			pathInsideWorkspace := strings.Join(pathParts[2:], "/")
			if path := runfiles.index.Get(workspace, pathInsideWorkspace); path != "" {
				return path, nil
			}
		}
	}

	// Search the main workspace.
	if runfiles.workspace != "" {
		mainPath := filepath.Join(runfiles.dir, runfiles.workspace, path)
		if _, err := os.Stat(mainPath); err == nil {
			return mainPath, nil
		}
	}

	// Search other workspaces.
	for _, w := range runfiles.workspaces {
		workPath := filepath.Join(runfiles.dir, w, path)
		if _, err := os.Stat(workPath); err == nil {
			return workPath, nil
		}
	}

	return "", fmt.Errorf("Runfile %s: could not locate file", path)
}

// FindBinary returns an absolute path to the binary built from a go_binary
// rule in the given package with the given name. FindBinary is similar to
// Runfile, but it accounts for varying configurations and file extensions,
// which may cause the binary to have different paths on different platforms.
//
// FindBinary may be called from tests invoked with 'bazel test' and
// binaries invoked with 'bazel run'. On Windows,
// only tests invoked with 'bazel test' are supported.
func FindBinary(pkg, name string) (string, bool) {
	if err := ensureRunfiles(); err != nil {
		return "", false
	}

	// If we've gathered a list of runfiles, either by calling ListRunfiles or
	// parsing the manifest on Windows, just use that instead of searching
	// directories. Return the first match. The manifest on Windows may contain
	// multiple entries for the same file.
	if runfiles.list != nil {
		if runtime.GOOS == "windows" {
			name += ".exe"
		}
		for _, entry := range runfiles.list {
			if path.Base(entry.ShortPath) != name {
				continue
			}
			pkgDir := path.Dir(path.Dir(entry.ShortPath))
			if pkgDir == "." {
				pkgDir = ""
			}
			if pkgDir != pkg {
				continue
			}
			return entry.Path, true
		}
		return "", false
	}

	dir, err := Runfile(pkg)
	if err != nil {
		return "", false
	}
	var found string
	stopErr := errors.New("stop")
	err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		base := filepath.Base(path)
		stem := strings.TrimSuffix(base, ".exe")
		if stem != name {
			return nil
		}
		if runtime.GOOS != "windows" {
			if st, err := os.Stat(path); err != nil {
				return err
			} else if st.Mode()&0111 == 0 {
				return nil
			}
		}
		if stem == name {
			found = path
			return stopErr
		}
		return nil
	})
	if err == stopErr {
		return found, true
	} else {
		return "", false
	}
}

// A RunfileEntry describes a runfile.
type RunfileEntry struct {
	// Workspace is the bazel workspace the file came from. For example,
	// this would be "io_bazel_rules_go" for a file in rules_go.
	Workspace string

	// ShortPath is a relative, slash-separated path from the workspace root
	// to the file. For non-binary files, this may be passed to Runfile
	// to locate a file.
	ShortPath string

	// Path is an absolute path to the file.
	Path string
}

// ListRunfiles returns a list of available runfiles.
func ListRunfiles() ([]RunfileEntry, error) {
	if err := ensureRunfiles(); err != nil {
		return nil, err
	}

	if runfiles.list == nil && runfiles.dir != "" {
		runfiles.listOnce.Do(func() {
			var list []RunfileEntry
			haveWorkspaces := strings.HasSuffix(runfiles.dir, ".runfiles") && runfiles.workspace != ""

			err := filepath.Walk(runfiles.dir, func(path string, info os.FileInfo, err error) error {
				if err != nil {
					return err
				}
				rel, _ := filepath.Rel(runfiles.dir, path)
				rel = filepath.ToSlash(rel)
				if rel == "." {
					return nil
				}

				var workspace, shortPath string
				if haveWorkspaces {
					if i := strings.IndexByte(rel, '/'); i < 0 {
						return nil
					} else {
						workspace, shortPath = rel[:i], rel[i+1:]
					}
				} else {
					workspace, shortPath = "", rel
				}

				list = append(list, RunfileEntry{Workspace: workspace, ShortPath: shortPath, Path: path})
				return nil
			})
			if err != nil {
				runfiles.err = err
				return
			}
			runfiles.list = list
		})
	}
	return runfiles.list, runfiles.err
}

// TestWorkspace returns the name of the Bazel workspace for this test.
// TestWorkspace returns an error if the TEST_WORKSPACE environment variable
// was not set or SetDefaultTestWorkspace was not called.
func TestWorkspace() (string, error) {
	if err := ensureRunfiles(); err != nil {
		return "", err
	}
	if runfiles.workspace != "" {
		return runfiles.workspace, nil
	}
	return "", errors.New("TEST_WORKSPACE not set and SetDefaultTestWorkspace not called")
}

// SetDefaultTestWorkspace allows you to set a fake value for the
// environment variable TEST_WORKSPACE if it is not defined. This is useful
// when running tests on the command line and not through Bazel.
func SetDefaultTestWorkspace(w string) {
	ensureRunfiles()
	runfiles.workspace = w
}

// RunfilesPath return the path to the runfiles tree.
// It will return an error if there is no runfiles tree, for example because
// the executable is run on Windows or was not invoked with 'bazel test'
// or 'bazel run'.
func RunfilesPath() (string, error) {
	if err := ensureRunfiles(); err != nil {
		return "", err
	}
	if runfiles.dir == "" {
		if runtime.GOOS == "windows" {
			return "", errors.New("RunfilesPath: no runfiles directory on windows")
		} else {
			return "", errors.New("could not locate runfiles directory")
		}
	}
	if runfiles.workspace == "" {
		return "", errors.New("could not locate runfiles workspace")
	}
	return filepath.Join(runfiles.dir, runfiles.workspace), nil
}

var runfiles = struct {
	once, listOnce sync.Once

	// list is a list of known runfiles, either loaded from the manifest
	// or discovered by walking the runfile directory.
	list []RunfileEntry

	// index maps runfile short paths to absolute paths.
	index index

	// dir is a path to the runfile directory. Typically this is a directory
	// named <target>.runfiles, with a subdirectory for each workspace.
	dir string

	// workspace is workspace where the binary or test was built.
	workspace string

	// workspaces is a list of other workspace names.
	workspaces []string

	// err is set when there is an error loading runfiles, for example,
	// parsing the manifest.
	err error
}{}

type index struct {
	indexWithWorkspace     map[indexKey]*RunfileEntry
	indexIgnoringWorksapce map[string]*RunfileEntry
}

func newIndex() index {
	return index{
		indexWithWorkspace:     make(map[indexKey]*RunfileEntry),
		indexIgnoringWorksapce: make(map[string]*RunfileEntry),
	}
}

func (i *index) Put(entry *RunfileEntry) {
	i.indexWithWorkspace[indexKey{
		workspace: entry.Workspace,
		shortPath: entry.ShortPath,
	}] = entry
	i.indexIgnoringWorksapce[entry.ShortPath] = entry
}

func (i *index) Get(workspace string, shortPath string) string {
	entry := i.indexWithWorkspace[indexKey{
		workspace: workspace,
		shortPath: shortPath,
	}]
	if entry == nil {
		return ""
	}
	return entry.Path
}

func (i *index) GetIgnoringWorkspace(shortPath string) (*RunfileEntry, bool) {
	entry, ok := i.indexIgnoringWorksapce[shortPath]
	return entry, ok
}

type indexKey struct {
	workspace string
	shortPath string
}

func ensureRunfiles() error {
	runfiles.once.Do(initRunfiles)
	return runfiles.err
}

func initRunfiles() {
	manifest := os.Getenv("RUNFILES_MANIFEST_FILE")
	if manifest != "" {
		// On Windows, Bazel doesn't create a symlink tree of runfiles because
		// Windows doesn't support symbolic links by default. Instead, runfile
		// locations are written to a manifest file.
		runfiles.index = newIndex()
		data, err := ioutil.ReadFile(manifest)
		if err != nil {
			runfiles.err = err
			return
		}
		lineno := 0
		for len(data) > 0 {
			i := bytes.IndexByte(data, '\n')
			var line []byte
			if i < 0 {
				line = data
				data = nil
			} else {
				line = data[:i]
				data = data[i+1:]
			}
			lineno++

			// Only TrimRight newlines. Do not TrimRight() completely, because that would remove spaces too.
			// This is necessary in order to have at least one space in every manifest line.
			// Some manifest entries don't have any path after this space, namely the "__init__.py" entries.
			// original comment sourced from: https://github.com/bazelbuild/bazel/blob/09c621e4cf5b968f4c6cdf905ab142d5961f9ddc/src/test/py/bazel/runfiles_test.py#L225
			line = bytes.TrimRight(line, "\r\n")
			if len(line) == 0 {
				continue
			}

			spaceIndex := bytes.IndexByte(line, ' ')
			if spaceIndex < 0 {
				runfiles.err = fmt.Errorf(
					"error parsing runfiles manifest: %s:%d: no space: '%s'", manifest, lineno, line)
				return
			}
			shortPath := string(line[0:spaceIndex])
			abspath := ""
			if len(line) > spaceIndex+1 {
				abspath = string(line[spaceIndex+1:])
			}

			entry := RunfileEntry{ShortPath: shortPath, Path: abspath}
			if i := strings.IndexByte(entry.ShortPath, '/'); i >= 0 {
				entry.Workspace = entry.ShortPath[:i]
				entry.ShortPath = entry.ShortPath[i+1:]
			}
			if strings.HasPrefix(entry.ShortPath, "external/") {
				entry.ShortPath = entry.ShortPath[len("external/"):]
				if i := strings.IndexByte(entry.ShortPath, '/'); i >= 0 {
					entry.Workspace = entry.ShortPath[:i]
					entry.ShortPath = entry.ShortPath[i+1:]
				}
			}
			if strings.HasPrefix(entry.ShortPath, "../") {
				entry.ShortPath = entry.ShortPath[len("../"):]
				if i := strings.IndexByte(entry.ShortPath, '/'); i >= 0 {
					entry.Workspace = entry.ShortPath[:i]
					entry.ShortPath = entry.ShortPath[i+1:]
				}
			}

			runfiles.list = append(runfiles.list, entry)
			runfiles.index.Put(&entry)
		}
	}

	runfiles.workspace = os.Getenv("TEST_WORKSPACE")

	if dir := os.Getenv("RUNFILES_DIR"); dir != "" {
		runfiles.dir = dir
	} else if dir = os.Getenv("TEST_SRCDIR"); dir != "" {
		runfiles.dir = dir
	} else if runtime.GOOS != "windows" {
		dir, err := os.Getwd()
		if err != nil {
			runfiles.err = fmt.Errorf("error locating runfiles dir: %v", err)
			return
		}

		parent := filepath.Dir(dir)
		if strings.HasSuffix(parent, ".runfiles") {
			runfiles.dir = parent
			if runfiles.workspace == "" {
				runfiles.workspace = filepath.Base(dir)
			}
		} else {
			runfiles.err = errors.New("could not locate runfiles directory")
			return
		}
	}

	if runfiles.dir != "" {
		fis, err := ioutil.ReadDir(runfiles.dir)
		if err != nil {
			runfiles.err = fmt.Errorf("could not open runfiles directory: %v", err)
			return
		}
		for _, fi := range fis {
			if fi.IsDir() {
				runfiles.workspaces = append(runfiles.workspaces, fi.Name())
			}
		}
		sort.Strings(runfiles.workspaces)
	}
}
