// Copyright 2021 Google LLC
//
// 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.

// +build linux darwin

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	osexec "os/exec"
	"path"
	"strings"
)

// TODO(noahdietz): remove this once the fix in golang.org/x/tools is released.
// https://github.com/golang/go/issues/44796
const ignored = "- MaxPublishRequestBytes: value changed from 0.000582077 to 10000000"
const rootMod = "cloud.google.com/go"

var repoMetadataPath string
var verbose bool

func init() {
	flag.StringVar(&repoMetadataPath, "repo-metadata", "", "path to a repo-metadata-full JSON file [required]")
	flag.BoolVar(&verbose, "verbose", false, "enable verbose command logging")
}

func main() {
	flag.Parse()
	if repoMetadataPath == "" {
		log.Fatalln("Missing required flag: -repo-metadata")
	}

	head, err := exec("git", "log", "-2")
	if err != nil {
		log.Fatalln(err)
	}
	if checkAllowBreakingChange(head) {
		return
	}

	root, err := os.Getwd()
	if err != nil {
		log.Fatalln(err)
	}

	f, err := os.Open(repoMetadataPath)
	if err != nil {
		log.Fatalln(err)
	}
	defer f.Close()

	var m manifest
	if err := json.NewDecoder(f).Decode(&m); err != nil {
		log.Fatalln(err)
	}

	_, err = exec("go", "install", "golang.org/x/exp/cmd/apidiff@latest")
	if err != nil {
		log.Fatalln(err)
	}

	temp, err := ioutil.TempDir("/tmp", "google-cloud-go-*")
	if err != nil {
		log.Fatalln(err)
	}
	defer os.RemoveAll(temp)

	_, err = exec("git", "clone", "https://github.com/googleapis/google-cloud-go", temp)
	if err != nil {
		log.Fatalln(err)
	}

	diffs, diffingErrs, err := diffModules(root, temp, m)
	if err != nil {
		log.Fatalln(err)
	}

	if len(diffingErrs) > 0 {
		fmt.Fprintln(os.Stderr, "The following packages encountered errors:")
		for imp, err := range diffingErrs {
			fmt.Fprintf(os.Stderr, "%s: %s\n", imp, err)
		}
	}

	if len(diffs) > 0 {
		fmt.Fprintln(os.Stderr, "The following breaking changes were found:")
		for imp, d := range diffs {
			fmt.Fprintf(os.Stderr, "%s:\n%s\n", imp, d)
		}
		os.Exit(1)
	}
}

// manifestEntry is used for JSON marshaling in manifest.
// Copied from internal/gapicgen/generator/gapics.go.
type manifestEntry struct {
	DistributionName  string `json:"distribution_name"`
	Description       string `json:"description"`
	Language          string `json:"language"`
	ClientLibraryType string `json:"client_library_type"`
	DocsURL           string `json:"docs_url"`
	ReleaseLevel      string `json:"release_level"`
}

type manifest map[string]manifestEntry

func diffModules(root, baseDir string, m manifest) (map[string]string, map[string]error, error) {
	diffs := map[string]string{}
	issues := map[string]error{}

	for imp, entry := range m {
		// Prepare module directory paths relative to the repo root.
		pkg := strings.TrimPrefix(imp, rootMod+"/")
		baseModDir := baseDir
		modDir := root

		// Manual clients are also submodules, so we need to run apidiff in the
		// submodule.
		if entry.ClientLibraryType == "manual" {
			baseModDir = path.Join(baseModDir, pkg)
			modDir = path.Join(modDir, pkg)
		}

		// Create apidiff base from repo remote HEAD.
		base, err := writeBase(m, baseModDir, imp, pkg)
		if err != nil {
			issues[imp] = err
			continue
		}

		// Diff the current checked out change against remote HEAD base.
		out, err := diff(m, modDir, imp, pkg, base)
		if err != nil {
			issues[imp] = err
			continue
		}

		if out != "" && out != ignored {
			diffs[imp] = out
		}
	}

	return diffs, issues, nil
}

func writeBase(m manifest, baseModDir, imp, pkg string) (string, error) {
	if err := cd(baseModDir); err != nil {
		return "", err
	}

	base := path.Join(baseModDir, "pkg.master")
	out, err := exec("apidiff", "-w", base, imp)
	if err != nil && !isSubModErr(out) {
		return "", err
	}

	// If there was an issue with loading a submodule, change into that
	// submodule directory and try again.
	if isSubModErr(out) {
		parent := manualParent(m, imp)
		if parent == pkg {
			return "", fmt.Errorf("unable to find parent module for %q", imp)
		}
		if err := cd(parent); err != nil {
			return "", err
		}
		out, err := exec("apidiff", "-w", base, imp)
		if err != nil {
			return "", fmt.Errorf("%s: %s", err, out)
		}
	}
	return base, nil
}

func diff(m manifest, modDir, imp, pkg, base string) (string, error) {
	if err := cd(modDir); err != nil {
		return "", err
	}
	out, err := exec("apidiff", "-incompatible", base, imp)
	if err != nil && !isSubModErr(out) {
		return "", err
	}
	if isSubModErr(out) {
		parent := manualParent(m, imp)
		if parent == pkg {
			return "", fmt.Errorf("unable to find parent module for %q", imp)
		}
		if err := cd(parent); err != nil {
			return "", err
		}
		out, err = exec("apidiff", "-w", base, imp)
		if err != nil {
			return "", fmt.Errorf("%s: %s", err, out)
		}
	}

	return out, err
}

func checkAllowBreakingChange(commit string) bool {
	if strings.Contains(commit, "BREAKING CHANGE:") {
		log.Println("Not running apidiff because description contained tag BREAKING_CHANGE.")
		return true
	}

	split := strings.Split(commit, "\n")
	for _, s := range split {
		if strings.Contains(s, "!:") || strings.Contains(s, "!(") {
			log.Println("Not running apidiff because description contained breaking change indicator '!'.")
			return true
		}
	}

	return false
}

func manualParent(m manifest, imp string) string {
	pkg := strings.TrimPrefix(imp, rootMod)
	split := strings.Split(pkg, "/")

	mod := rootMod
	for _, seg := range split {
		mod = path.Join(mod, seg)
		if parent, ok := m[mod]; ok && parent.ClientLibraryType == "manual" {
			return strings.TrimPrefix(mod, rootMod+"/")
		}
	}

	return pkg
}

func isSubModErr(msg string) bool {
	return strings.Contains(msg, "missing") || strings.Contains(msg, "required")
}

func cd(dir string) error {
	if verbose {
		log.Printf("+ cd %s\n", dir)
	}
	return os.Chdir(dir)
}

func exec(cmd string, args ...string) (string, error) {
	if verbose {
		log.Printf("+ %s %s\n", cmd, strings.Join(args, " "))
	}
	out, err := osexec.Command(cmd, args...).CombinedOutput()
	return strings.TrimSpace(string(out)), err
}
