/* Copyright 2018 The Bazel Authors. All rights reserved.

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 main

import (
	"bytes"
	"errors"
	"flag"
	"fmt"
	"log"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/bazelbuild/bazel-gazelle/internal/wspace"
	"github.com/bazelbuild/bazel-gazelle/label"
	"github.com/bazelbuild/bazel-gazelle/pathtools"
	"github.com/bazelbuild/buildtools/build"
)

const usageMessage = `usage: move_labels [-repo_root=root] [-from=dir] -to=dir

move_labels updates Bazel labels in a tree containing build files after the
tree has been moved to a new location. This is useful for vendoring
repositories that already have Bazel build files.

`

func main() {
	log.SetPrefix("move_labels: ")
	log.SetFlags(0)
	if err := run(os.Args[1:]); err != nil {
		log.Fatal(err)
	}
}

func run(args []string) error {
	c, err := newConfiguration(args)
	if err != nil {
		return err
	}

	files, err := moveLabelsInDir(c)
	if err != nil {
		return err
	}

	var errs errorList
	for _, file := range files {
		content := build.Format(file)
		if err := os.WriteFile(file.Path, content, 0o666); err != nil {
			errs = append(errs, err)
		}
	}
	if len(errs) > 0 {
		return errs
	}
	return nil
}

func moveLabelsInDir(c *configuration) ([]*build.File, error) {
	toRel, err := filepath.Rel(c.repoRoot, c.to)
	if err != nil {
		return nil, err
	}
	toRel = filepath.ToSlash(toRel)

	var files []*build.File
	var errors errorList
	err = filepath.Walk(c.to, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if name := info.Name(); name != "BUILD" && name != "BUILD.bazel" {
			return nil
		}
		content, err := os.ReadFile(path)
		if err != nil {
			errors = append(errors, err)
			return nil
		}
		file, err := build.Parse(path, content)
		if err != nil {
			errors = append(errors, err)
			return nil
		}
		moveLabelsInFile(file, c.from, toRel)
		files = append(files, file)
		return nil
	})
	if err != nil {
		return nil, err
	}
	if len(errors) > 0 {
		return nil, errors
	}
	return files, nil
}

func moveLabelsInFile(file *build.File, from, to string) {
	build.Edit(file, func(x build.Expr, _ []build.Expr) build.Expr {
		str, ok := x.(*build.StringExpr)
		if !ok {
			return nil
		}
		label := str.Value
		var moved string
		if strings.Contains(label, "$(location") {
			moved = moveLocations(from, to, label)
		} else {
			moved = moveLabel(from, to, label)
		}
		if moved == label {
			return nil
		}
		return &build.StringExpr{Value: moved}
	})
}

func moveLabel(from, to, str string) string {
	l, err := label.Parse(str)
	if err != nil {
		return str
	}
	if l.Relative || l.Repo != "" ||
		l.Pkg == "visibility" || l.Pkg == "conditions" ||
		pathtools.HasPrefix(l.Pkg, to) || !pathtools.HasPrefix(l.Pkg, from) {
		return str
	}
	l.Pkg = path.Join(to, pathtools.TrimPrefix(l.Pkg, from))
	return l.String()
}

var locationsRegexp = regexp.MustCompile(`\$\(locations?\s*([^)]*)\)`)

// moveLocations fixes labels within $(location) and $(locations) expansions.
func moveLocations(from, to, str string) string {
	matches := locationsRegexp.FindAllStringSubmatchIndex(str, -1)
	buf := new(bytes.Buffer)
	pos := 0
	for _, match := range matches {
		buf.WriteString(str[pos:match[2]])
		label := str[match[2]:match[3]]
		moved := moveLabel(from, to, label)
		buf.WriteString(moved)
		buf.WriteString(str[match[3]:match[1]])
		pos = match[1]
	}
	buf.WriteString(str[pos:])
	return buf.String()
}

type configuration struct {
	// repoRoot is the repository root directory, formatted as an absolute
	// file system path.
	repoRoot string

	// from is the original location of the build files within their repository,
	// formatted as a slash-separated relative path from the original
	// repository root.
	from string

	// to is the new location of the build files, formatted as an absolute
	// file system path.
	to string
}

func newConfiguration(args []string) (*configuration, error) {
	var err error
	c := &configuration{}
	fs := flag.NewFlagSet("move_labels", flag.ContinueOnError)
	fs.Usage = func() {}
	fs.StringVar(&c.repoRoot, "repo_root", "", "repository root directory; inferred to be parent directory containing WORKSPACE file")
	fs.StringVar(&c.from, "from", "", "original location of build files, formatted as a slash-separated relative path from the original repository root")
	fs.StringVar(&c.to, "to", "", "new location of build files, formatted as a file system path")
	if err := fs.Parse(args); err != nil {
		if err == flag.ErrHelp {
			fmt.Fprint(os.Stderr, usageMessage)
			fs.PrintDefaults()
			os.Exit(0)
		}
		// flag already prints an error; don't print again.
		return nil, errors.New("Try -help for more information")
	}

	if c.repoRoot == "" {
		c.repoRoot, err = findRepoRoot()
		if err != nil {
			return nil, err
		}
	}
	c.repoRoot, err = filepath.Abs(c.repoRoot)
	if err != nil {
		return nil, err
	}

	if c.to == "" {
		return nil, errors.New("-to must be specified. Try -help for more information.")
	}
	c.to, err = filepath.Abs(c.to)
	if err != nil {
		return nil, err
	}

	if len(fs.Args()) != 0 {
		return nil, errors.New("No positional arguments expected. Try -help for more information.")
	}

	return c, nil
}

func findRepoRoot() (string, error) {
	dir, err := os.Getwd()
	if err != nil {
		return "", err
	}
	root, err := wspace.FindRepoRoot(dir)
	if err != nil {
		return "", fmt.Errorf("could not find WORKSPACE file. -repo_root must be set explicitly")
	}
	return root, nil
}

type errorList []error

func (e errorList) Error() string {
	buf := new(bytes.Buffer)
	for _, err := range e {
		fmt.Fprintln(buf, err.Error())
	}
	return buf.String()
}
