// Copyright 2021 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"
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"

	"github.com/google/go-github/v36/github"
	"golang.org/x/mod/semver"
	"golang.org/x/oauth2"
)

var prepareCmd = command{
	name:        "prepare",
	description: "prepares a GitHub release with notes and attached archive",
	help: `prepare -rnotes=file -version=version -githubtoken=token [-mirror]

'prepare' performs most tasks related to a rules_go release. It does everything
except publishing and tagging the release, which must be done manually,
with review. Specifically, prepare does the following:

* Creates the release branch if it doesn't exist locally. Release branches
  have names like "release-X.Y" where X and Y are the major and minor version
  numbers.
* Checks that RULES_GO_VERSION is set in go/def.bzl on the local release branch
  for the minor version being released. RULES_GO_VERSION must be a sematic
  version without the "v" prefix that Go uses, like "1.2.4". It must match
  the -version flag, which does require the "v" prefix.
* Creates an archive zip file from the tip of the local release branch.
* Creates or updates a draft GitHub release with the given release notes.
  http_archive boilerplate is generated and appended to the release notes.
* Uploads and attaches the release archive to the GitHub release.
* Uploads the release archive to mirror.bazel.build. If the file already exists,
  it may be manually removed with 'gsutil rm gs://bazel-mirror/<github-url>'
  or manually updated with 'gsutil cp <file> gs://bazel-mirror/<github-url>'.
  This step may be skipped by setting -mirror=false.

After these steps are completed successfully, 'prepare' prompts the user to
check that CI passes, then review and publish the release.

Note that 'prepare' does not update boilerplate in WORKSPACE or README.rst for
either rules_go or Gazelle.
`,
}

func init() {
	// break init cycle
	prepareCmd.run = runPrepare
}

func runPrepare(ctx context.Context, stderr io.Writer, args []string) error {
	// Parse arguments.
	flags := flag.NewFlagSet("releaser prepare", flag.ContinueOnError)
	var rnotesPath, version string
	var githubToken githubTokenFlag
	var uploadToMirror bool
	flags.Var(&githubToken, "githubtoken", "GitHub personal access token or path to a file containing it")
	flags.BoolVar(&uploadToMirror, "mirror", false, "whether to upload dependency archives to mirror.bazel.build")
	flags.StringVar(&rnotesPath, "rnotes", "", "Name of file containing release notes in Markdown")
	flags.StringVar(&version, "version", "", "Version to release")
	if err := flags.Parse(args); err != nil {
		return err
	}
	if flags.NArg() > 0 {
		return usageErrorf(&prepareCmd, "No arguments expected")
	}
	if githubToken == "" {
		return usageErrorf(&prepareCmd, "-githubtoken must be set")
	}
	if rnotesPath == "" {
		return usageErrorf(&prepareCmd, "-rnotes must be set")
	}
	if version == "" {
		return usageErrorf(&prepareCmd, "-version must be set")
	}
	if semver.Canonical(version) != version || semver.Prerelease(version) != "" || semver.Build(version) != "" {
		return usageErrorf(&prepareCmd, "-version must be a canonical version, like v1.2.3")
	}

	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: string(githubToken)})
	tc := oauth2.NewClient(ctx, ts)
	gh := &githubClient{Client: github.NewClient(tc)}

	// Get the GitHub release.
	fmt.Fprintf(stderr, "checking if release %s exists...\n", version)
	release, err := gh.getReleaseByTagIncludingDraft(ctx, "bazelbuild", "rules_go", version)
	if err != nil && !errors.Is(err, errReleaseNotFound) {
		return err
	}
	if release != nil && !release.GetDraft() {
		return fmt.Errorf("release %s was already published", version)
	}

	// Check that RULES_GO_VERSION is set correctly on the release branch.
	// If this is a minor release (x.y.0), create the release branch if it
	// does not exist.
	fmt.Fprintf(stderr, "checking RULES_GO_VERSION...\n")
	rootDir, err := repoRoot()
	if err != nil {
		return err
	}
	if err := checkNoGitChanges(ctx, rootDir); err != nil {
		return err
	}
	majorMinor := semver.MajorMinor(version)
	isMinorRelease := semver.Canonical(majorMinor) == version
	branchName := "release-" + majorMinor[len("v"):]
	if !gitBranchExists(ctx, rootDir, branchName) {
		if !isMinorRelease {
			return fmt.Errorf("release branch %q does not exist locally. Fetch it, set RULES_GO_VERSION, add commits, and run this command again.")
		}
		if err := checkRulesGoVersion(ctx, rootDir, "HEAD", version); err != nil {
			return err
		}
		fmt.Fprintf(stderr, "creating branch %s...\n", branchName)
		if err := gitCreateBranch(ctx, rootDir, branchName, "HEAD"); err != nil {
			return err
		}
	} else {
		if err := checkRulesGoVersion(ctx, rootDir, branchName, version); err != nil {
			return err
		}
	}

	// Create an archive.
	fmt.Fprintf(stderr, "creating archive...\n")
	arcFile, err := os.CreateTemp("", "rules_go-%s-*.zip")
	if err != nil {
		return err
	}
	arcName := arcFile.Name()
	arcFile.Close()
	defer func() {
		if rerr := os.Remove(arcName); err == nil && rerr != nil {
			err = rerr
		}
	}()
	if err := gitCreateArchive(ctx, rootDir, branchName, arcName); err != nil {
		return err
	}
	arcSum, err := sha256SumFile(arcName)
	if err != nil {
		return err
	}

	// Read release notes, append boilerplate.
	rnotesData, err := os.ReadFile(rnotesPath)
	if err != nil {
		return err
	}
	rnotesData = bytes.TrimSpace(rnotesData)
	goVersion, err := findLatestGoVersion()
	if err != nil {
		return err
	}
	boilerplate := genBoilerplate(version, arcSum, goVersion)
	rnotesStr := string(rnotesData) + "\n\n## `WORKSPACE` code\n\n```\n" + boilerplate + "\n```\n"

	// Push the release branch.
	fmt.Fprintf(stderr, "pushing branch %s to origin...\n", branchName)
	if err := gitPushBranch(ctx, rootDir, branchName); err != nil {
		return err
	}

	// Upload to mirror.bazel.build.
	arcGHURLWithoutScheme := fmt.Sprintf("github.com/bazelbuild/rules_go/releases/download/%[1]s/rules_go-%[1]s.zip", version)
	if uploadToMirror {
		fmt.Fprintf(stderr, "uploading archive to mirror.bazel.build...\n")
		if err := copyFileToMirror(ctx, arcGHURLWithoutScheme, arcName); err != nil {
			return err
		}
	}

	// Create or update the GitHub release.
	if release == nil {
		fmt.Fprintf(stderr, "creating draft release...\n")
		draft := true
		release = &github.RepositoryRelease{
			TagName:         &version,
			TargetCommitish: &branchName,
			Name:            &version,
			Body:            &rnotesStr,
			Draft:           &draft,
		}
		if release, _, err = gh.Repositories.CreateRelease(ctx, "bazelbuild", "rules_go", release); err != nil {
			return err
		}
	} else {
		fmt.Fprintf(stderr, "updating release...\n")
		release.Body = &rnotesStr
		if release, _, err = gh.Repositories.EditRelease(ctx, "bazelbuild", "rules_go", release.GetID(), release); err != nil {
			return err
		}
		for _, asset := range release.Assets {
			if _, err := gh.Repositories.DeleteReleaseAsset(ctx, "bazelbuild", "rules_go", asset.GetID()); err != nil {
				return err
			}
		}
	}
	arcFile, err = os.Open(arcName)
	if err != nil {
		return err
	}
	defer arcFile.Close()
	uploadOpts := &github.UploadOptions{
		Name:      "rules_go-" + version + ".zip",
		MediaType: "application/zip",
	}
	if _, _, err := gh.Repositories.UploadReleaseAsset(ctx, "bazelbuild", "rules_go", release.GetID(), uploadOpts, arcFile); err != nil {
		return err
	}

	testURL := fmt.Sprintf("https://buildkite.com/bazel/rules-go-golang/builds?branch=%s", branchName)
	fmt.Fprintf(stderr, `
Release %s has been prepared and uploaded.

* Ensure that all tests pass in CI at %s.
* Review and publish the release at %s.
* Update README.rst and WORKSPACE if necessary.
`, version, testURL, release.GetHTMLURL())

	return nil
}

func checkRulesGoVersion(ctx context.Context, dir, refName, version string) error {
	data, err := gitCatFile(ctx, dir, refName, "go/def.bzl")
	if err != nil {
		return err
	}
	rulesGoVersionStr := []byte(fmt.Sprintf(`RULES_GO_VERSION = "%s"`, version[len("v"):]))
	if !bytes.Contains(data, rulesGoVersionStr) {
		return fmt.Errorf("RULES_GO_VERSION was not set to %q in go/def.bzl. Set it, add commits, and run this command again.")
	}
	return nil
}
