/* 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 proto

import (
	"flag"
	"fmt"
	"log"
	"path"
	"strings"

	"github.com/bazelbuild/bazel-gazelle/config"
	"github.com/bazelbuild/bazel-gazelle/pathtools"
	"github.com/bazelbuild/bazel-gazelle/rule"
)

// ProtoConfig contains configuration values related to protos.
//
// This type is public because other languages need to generate rules based
// on protos, so this configuration may be relevant to them.
type ProtoConfig struct {
	// Mode determines how rules are generated for protos.
	Mode Mode

	// ModeExplicit indicates whether the proto mode was set explicitly.
	ModeExplicit bool

	// GoPrefix is the current Go prefix (the Go extension may set this in the
	// root directory only). Used to generate proto rule names in the root
	// directory when there are no proto files or the proto package name
	// can't be determined.
	// TODO(jayconrod): deprecate and remove Go-specific behavior.
	GoPrefix string

	// groupOption is an option name that Gazelle will use to group .proto
	// files into proto_library rules. If unset, the proto package name is used.
	groupOption string

	// StripImportPrefix The prefix to strip from the paths of the .proto files.
	// If set, Gazelle will apply this value to the strip_import_prefix attribute
	// within the proto_library_rule.
	StripImportPrefix string

	// ImportPrefix The prefix to add to the paths of the .proto files.
	// If set, Gazelle will apply this value to the import_prefix attribute
	// within the proto_library_rule.
	ImportPrefix string

	// protoSearch is a list of rules for transforming import strings into
	// repo-root-relative directory paths where the proto might be found.
	protoSearch []protoSearch
}

// GetProtoConfig returns the proto language configuration. If the proto
// extension was not run, it will return nil.
func GetProtoConfig(c *config.Config) *ProtoConfig {
	pc := c.Exts[protoName]
	if pc == nil {
		return nil
	}
	return pc.(*ProtoConfig)
}

// Mode determines how proto rules are generated.
type Mode int

const (
	// DefaultMode generates proto_library rules. Other languages should generate
	// library rules based on these (e.g., go_proto_library) and should ignore
	// checked-in generated files (e.g., .pb.go files) when there is a .proto
	// file with a similar name.
	DefaultMode Mode = iota

	// DisableMode ignores .proto files and generates empty proto_library rules.
	// Checked-in generated files (e.g., .pb.go files) should be treated as
	// normal sources.
	DisableMode

	// DisableGlobalMode is similar to DisableMode, but it also prevents
	// the use of special cases in dependency resolution for well known types
	// and Google APIs.
	DisableGlobalMode

	// LegacyMode generates filegroups for .proto files if .pb.go files are
	// present in the same directory.
	LegacyMode

	// PackageMode generates a proto_library for each set of .proto files with
	// the same package name in each directory.
	PackageMode

	// FileMode generates a proto_library for each .proto file.
	FileMode
)

func ModeFromString(s string) (Mode, error) {
	switch s {
	case "default":
		return DefaultMode, nil
	case "disable":
		return DisableMode, nil
	case "disable_global":
		return DisableGlobalMode, nil
	case "legacy":
		return LegacyMode, nil
	case "package":
		return PackageMode, nil
	case "file":
		return FileMode, nil
	default:
		return 0, fmt.Errorf("unrecognized proto mode: %q", s)
	}
}

func (m Mode) String() string {
	switch m {
	case DefaultMode:
		return "default"
	case DisableMode:
		return "disable"
	case DisableGlobalMode:
		return "disable_global"
	case LegacyMode:
		return "legacy"
	case PackageMode:
		return "package"
	case FileMode:
		return "file"
	default:
		log.Panicf("unknown mode %d", m)
		return ""
	}
}

func (m Mode) ShouldGenerateRules() bool {
	switch m {
	case DisableMode, DisableGlobalMode, LegacyMode:
		return false
	default:
		return true
	}
}

func (m Mode) ShouldIncludePregeneratedFiles() bool {
	switch m {
	case DisableMode, DisableGlobalMode, LegacyMode:
		return true
	default:
		return false
	}
}

func (m Mode) ShouldUseKnownImports() bool {
	return m != DisableGlobalMode
}

// protoSearch is a rule that transforms an import string into a
// repo-root-relative directory path where the proto might be found.
type protoSearch struct {
	stripImportPrefix, importPrefix string
}

type modeFlag struct {
	mode *Mode
}

func (f *modeFlag) Set(value string) error {
	if mode, err := ModeFromString(value); err != nil {
		return err
	} else {
		*f.mode = mode
		return nil
	}
}

func (f *modeFlag) String() string {
	var mode Mode
	if f != nil && f.mode != nil {
		mode = *f.mode
	}
	return mode.String()
}

func (*protoLang) RegisterFlags(fs *flag.FlagSet, cmd string, c *config.Config) {
	pc := &ProtoConfig{}
	c.Exts[protoName] = pc

	// Note: the -proto flag does not set the ModeExplicit flag. We want to
	// be able to switch to DisableMode in vendor directories, even when
	// this is set for compatibility with older versions.
	fs.Var(&modeFlag{&pc.Mode}, "proto", "default: generates a proto_library rule for one package\n\tpackage: generates a proto_library rule for for each package\n\tdisable: does not touch proto rules\n\tdisable_global: does not touch proto rules and does not use special cases for protos in dependency resolution")
	fs.StringVar(&pc.groupOption, "proto_group", "", "option name used to group .proto files into proto_library rules")
	fs.StringVar(&pc.ImportPrefix, "proto_import_prefix", "", "When set, .proto source files in the srcs attribute of the rule are accessible at their path with this prefix appended on.")
}

func (*protoLang) CheckFlags(fs *flag.FlagSet, c *config.Config) error {
	return nil
}

func (*protoLang) KnownDirectives() []string {
	return []string{"proto", "proto_group", "proto_strip_import_prefix", "proto_import_prefix", "proto_search"}
}

func (*protoLang) Configure(c *config.Config, rel string, f *rule.File) {
	pc := &ProtoConfig{}
	*pc = *GetProtoConfig(c)
	c.Exts[protoName] = pc
	if f != nil {
		for _, d := range f.Directives {
			switch d.Key {
			case "proto":
				mode, err := ModeFromString(d.Value)
				if err != nil {
					log.Print(err)
					continue
				}
				pc.Mode = mode
				pc.ModeExplicit = true
			case "proto_group":
				pc.groupOption = d.Value
			case "proto_strip_import_prefix":
				pc.StripImportPrefix = d.Value
				if err := checkStripImportPrefix(pc.StripImportPrefix, rel); err != nil {
					log.Print(err)
				}
			case "proto_import_prefix":
				pc.ImportPrefix = d.Value
			case "proto_search":
				// Special syntax (empty value) to reset directive.
				if d.Value == "" {
					pc.protoSearch = nil
				} else {
					args := strings.Fields(d.Value)
					if len(args) != 2 {
						log.Printf("# gazelle:proto_search: got %d arguments, expected 2, stripImportPrefix and importPrefix", len(args))
						continue
					}
					stripImportPrefix := args[0]
					importPrefix := args[1]
					pc.protoSearch = append(pc.protoSearch, protoSearch{stripImportPrefix: stripImportPrefix, importPrefix: importPrefix})
				}
			}
		}
	}
	inferProtoMode(c, rel, f)
}

// inferProtoMode sets ProtoConfig.Mode based on the directory name and the
// contents of f. If the proto mode is set explicitly, this function does not
// change it. If this is a vendor directory, or go_proto_library is loaded from
// another file, proto rule generation is disabled.
//
// TODO(jayconrod): this logic is archaic, now that rules are generated by
// separate language extensions. Proto rule generation should be independent
// from Go.
func inferProtoMode(c *config.Config, rel string, f *rule.File) {
	pc := GetProtoConfig(c)
	if pc.Mode != DefaultMode || pc.ModeExplicit {
		return
	}
	if pc.GoPrefix == wellKnownTypesGoPrefix {
		pc.Mode = LegacyMode
		return
	}
	if path.Base(rel) == "vendor" {
		pc.Mode = DisableMode
		return
	}
	if f == nil {
		return
	}
	mode := DefaultMode
	// Resolve the rules_go module name to check if go_proto_library is loaded from foreign rules
	rulesGo := c.ModuleToApparentName("rules_go")
	if rulesGo == "" {
		rulesGo = "io_bazel_rules_go"
	}
	rulesGoProtoDef := fmt.Sprintf("@%s//proto:def.bzl", rulesGo)
	rulesGoProtoGoProtoLibrary := fmt.Sprintf("@%s//proto:go_proto_library.bzl", rulesGo)
outer:
	for _, l := range f.Loads {
		name := l.Name()
		if name == rulesGoProtoDef {
			break
		}
		if name == rulesGoProtoGoProtoLibrary {
			mode = LegacyMode
			break
		}
		for _, sym := range l.Symbols() {
			if sym == "go_proto_library" {
				mode = DisableMode
				break outer
			}
		}
	}
	if mode == DefaultMode || pc.Mode == mode || c.ShouldFix && mode == LegacyMode {
		return
	}
	pc.Mode = mode
}

func checkStripImportPrefix(prefix, rel string) error {
	if prefix == "" {
		return nil
	}
	if !strings.HasPrefix(prefix, "/") {
		return fmt.Errorf("proto_strip_import_prefix should start with '/' for a prefix relative to the repository root")
	}
	if rel != "" && !pathtools.HasPrefix(rel, prefix[1:]) {
		return fmt.Errorf("proto_strip_import_prefix %q not in directory %s", prefix, rel)
	}
	return nil
}
