package main

import (
	"context"
	"encoding/json"
	"fmt"
	"html/template"
	"os"
	"os/exec"
	"os/signal"
	"syscall"
	"time"

	github "github.com/google/go-github/v68/github"
)

// -------------------------------------------------------------------------- \\
// ---------------------------- File description ---------------------------- \\
//
// #update-knative-components/main.go -- This file takes care of updating
// knative components programatically.
//
// Files interacted with:
// 1) The source-of-truth file and its content can be found at
// root/hack/component-versions.json
// 2) autogenerated script in root/hack/component-versions.sh (2 directories up)
//
// How to use this file:
// Most of the time this file will be used in a workflow that will run
// on scheduled basis checking if a new latest version of corresponding
// components exists (check components in 'Versions' struct). Please note that
// KindNode is NOT being updated programatically at this time.
// When new latest version is detected, the program will create a PR in
// knative/func repository with the latest changes allowing the CI/CD workflows
// to run automatically before using the latest in main branch.
// Alternative use: You can run this file from hack/ directory to locally
// regenerate 2 files mentioned above (if you made some changes etc.) - you can
// use the root Makefile for your convenience -- 'make regenerate-kn-components'
//
// -------------------------------------------------------------------------- \\

const (
	fileScript string = "component-versions.sh"
	fileJson   string = "component-versions.json"

	versionsScriptTemplate string = `#!/usr/bin/env bash

# AUTOGENERATED FILE - edit versions in ./component-versions.json.
# If you are adding components, modify this scripts' template in
# ./cmd/update-knative-components/main.go.
# You can regenerate locally with "make generate-kn-components-local".

set_versions() {
	# Note: Kubernetes Version node image per Kind releases (full hash is suggested):
	# https://github.com/kubernetes-sigs/kind/releases
	kind_node_version={{.KindNode}}

	# gets updated programatically via workflow -> PR creation
	knative_serving_version="{{.Serving}}"
	knative_eventing_version="{{.Eventing}}"
	contour_version="{{.Contour}}"
}
`
)

// all the components that are kept up to date
type Versions struct {
	KindNode string
	Serving  string
	Eventing string
	Contour  string
}

func main() {
	// there is an optional "local" argument
	if len(os.Args) == 2 && os.Args[1] == "local" { //leveraging lazy evals
		fmt.Println("Generate argument received! Regenerating files locally...")
		err := generateComponentVersions()
		if err != nil {
			fmt.Fprintf(os.Stderr, "error: %v", err)
			os.Exit(1)
		}
		fmt.Println("done")
		os.Exit(0)
	}

	// Set up context for possible signal inputs to not disrupt cleanup process.
	// This is not gonna do much for workflows since they finish and shutdown
	// but in case of local testing - dont leave left over resources on disk/RAM.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-sigs
		cancel()
		<-sigs
		os.Exit(130)
	}()

	const prTitle = "chore: Update components' versions to latest"
	client := github.NewClient(nil).WithAuthToken(os.Getenv("GITHUB_TOKEN"))

	e, err := prExists(ctx, client, prTitle)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	if e {
		fmt.Printf("PR already exists, nothing to do, exiting")
		os.Exit(0)
	}

	projects := []struct {
		owner, repo string
	}{
		{
			owner: "knative",
			repo:  "serving",
		},
		{
			owner: "knative",
			repo:  "eventing",
		},
		{
			owner: "knative-extensions",
			repo:  "net-contour",
		},
	}

	// Get current versions used.
	v, err := readVersions(fileJson)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	updated := false
	// cycle through all versions of components listed above, fetch their
	// latest from github releases - cmp them - create PR for update if necessary
	for _, p := range projects {
		newV, err := getLatestVersion(ctx, client, p.owner, p.repo)
		if err != nil {
			err = fmt.Errorf("error while getting latest v of %s/%s: %v", p.owner, p.repo, err)
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}

		// try to overwrite with possibly new versions
		if updateVersion(&v, p.repo, newV) {
			// if any of the files are updated, set true
			updated = true
		}
	}

	if !updated {
		// nothing was updated, nothing to do
		fmt.Printf("all good, no newer component releases, exiting\n")
		os.Exit(0)
	}

	if err := writeFiles(v, fileScript, fileJson); err != nil {
		err = fmt.Errorf("failed to write files: %v", err)
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	fmt.Println("files updated!")

	branchName := "update-components" + time.Now().Format(time.DateOnly)
	err = prepareBranch(branchName)
	if err != nil {
		err = fmt.Errorf("failed to prep the branch: %v", err)
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	err = createPR(ctx, client, prTitle, branchName)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

// read (unmarshal) component versions from .json
func readVersions(file string) (v Versions, err error) {
	fmt.Print("> Reading versions from file...")
	data, err := os.ReadFile(file)
	if err != nil {
		return
	}
	err = json.Unmarshal(data, &v)
	if err != nil {
		return v, err
	}
	fmt.Println("done")
	return v, nil
}

// attempt to update 'v' Versions to new 'val' for specific 'repo'
// 'v' - structure that holds versions of components
// 'repo' - points to a component repo that is being updated (example: serving)
// 'val' - value of the latest release of that repo (pulled via GH API)
func updateVersion(v *Versions, repo, val string) (updated bool) {
	fmt.Printf("check for %s update...", repo)
	if repo == "serving" && v.Serving != val {
		v.Serving = val
		updated = true
	} else if repo == "eventing" && v.Eventing != val {
		v.Eventing = val
		updated = true
	} else if repo == "net-contour" && v.Contour != val {
		v.Contour = val
		updated = true
	}
	if updated {
		fmt.Printf("found! new:'%s'\n", val)
	} else {
		fmt.Println("nothing to do")
	}
	return
}

// Overwrite the 'source of truth' file - .json and regenerate new script
// with new versions from 'v'.
// Arguments 'script' & 'json' are paths to files for autogenerated script and
// source (json) file respectively.
func writeFiles(v Versions, script, json string) error {
	fmt.Print("> Writing into files...")
	// write to json
	err := writeVersionsSource(v, json)
	if err != nil {
		return fmt.Errorf("failed to write to json: %v", err)
	}
	// write to script file
	err = writeVersionsScript(v, script)
	if err != nil {
		return fmt.Errorf("failed to generate script: %v", err)
	}
	fmt.Println("done")
	return nil
}

// write to 'source of truth' file (json) with updated versions
func writeVersionsSource(v Versions, file string) error {
	vB, err := json.MarshalIndent(v, "", "	")
	if err != nil {
		return fmt.Errorf("cant Marshal versions: %v", err)
	}
	f, err := os.Create(file)
	if err != nil {
		return err
	}

	defer f.Close()

	_, err = f.Write(append(vB, '\n')) // append newline for reviewdog
	return err
}

// write to autogenerated script file with newest Versions via templates pkg
func writeVersionsScript(v Versions, file string) error {
	tmpl, err := template.New("versions").Parse(versionsScriptTemplate)
	if err != nil {
		return err
	}
	f, err := os.Create(file)
	if err != nil {
		return err
	}
	defer f.Close()

	if err := tmpl.Execute(f, v); err != nil {
		return err
	}
	return nil
}

// get latest version of owner/repo via GH API
func getLatestVersion(ctx context.Context, client *github.Client, owner string, repo string) (v string, err error) {
	fmt.Printf("> get latest '%s/%s'...", owner, repo)
	rr, res, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
	if err != nil {
		err = fmt.Errorf("error: request for latest %s release: %v", owner+"/"+repo, err)
		return
	}
	if res.StatusCode < 200 && res.StatusCode > 299 {
		err = fmt.Errorf("error: Return status code of request for latest %s release is %d", owner+"/"+repo, res.StatusCode)
		return
	}
	v = *rr.Name
	if v == "" {
		return "", fmt.Errorf("internal error: returned latest release name is empty for '%s'", repo)
	}
	fmt.Println("done")
	return v, nil
}

// prepare branch for PR via git commands
// config user as knative bot -> create branch -> add changes -> commit -> push
func prepareBranch(branchName string) error {
	fmt.Println("> preparing branch")
	cmd := exec.Command("bash", "-c", fmt.Sprintf(`
		git config --local user.email "automation@knative.team" &&
		git config --local user.name "Knative Automation" &&
		git switch -c %s &&
		git add %s %s &&
		git commit -m "update components" &&
		git push origin %s
	`, branchName, fileScript, fileJson, branchName))

	cmd.Stderr = os.Stderr
	cmd.Stdout = os.Stdout
	return cmd.Run()
}

// create a PR via GH API on the func repo with those new versions
func createPR(ctx context.Context, client *github.Client, title string, branchName string) error {
	fmt.Print("> creating PR...")
	bodyText := "You might need to close & open this PR so all tests can run"
	body := fmt.Sprintf("%s\n%s\n/assign @gauron99", title, bodyText)

	newPR := github.NewPullRequest{
		Title:               github.Ptr(title),
		Base:                github.Ptr("main"),
		Head:                github.Ptr(branchName),
		Body:                github.Ptr(body),
		MaintainerCanModify: github.Ptr(true),
	}
	pr, _, err := client.PullRequests.Create(ctx, "knative", "func", &newPR)
	if err != nil {
		fmt.Printf("PR looks like this:\n%#v\n", pr)
		fmt.Printf("err: %s\n", err)
		return err
	}
	fmt.Println("ready")
	return nil
}

// Returns true when PR with given title already exists in knative/func repo
// otherwise false.
// Returns an error if occurred, otherwise nil.
func prExists(ctx context.Context, c *github.Client, title string) (bool, error) {
	perPage := 10
	opt := &github.PullRequestListOptions{State: "open", ListOptions: github.ListOptions{PerPage: perPage}}
	for {
		list, resp, err := c.PullRequests.List(ctx, "knative", "func", opt)
		if err != nil {
			return false, fmt.Errorf("errror pulling PRs in knative/func: %s", err)
		}
		for _, pr := range list {
			if pr.GetTitle() == title {
				// gauron99 - currently cannot update already existing PR
				return true, nil
			}
		}
		if resp.NextPage == 0 {
			// hit end of list
			return false, nil
		}
		// otherwise, continue to the next page
		opt.Page = resp.NextPage
	}
}

// -------------------------------------------------------------------------- \\
// -------------------------------------------------------------------------- \\
// -------------------------------------------------------------------------- \\
// -------------------------------------------------------------------------- \\

// This is used when running this file with 1st argument "generate".
// Regenerate written files (source (.json) & autogenerated .sh file)
// Generally you wont use this, but in case you make local changes to the
// files, you can simply regenerate them with this
func generateComponentVersions() error {
	v, err := readVersions(fileJson)
	if err != nil {
		return fmt.Errorf("failed to read Versions from json: %v", err)
	}
	// generate
	err = writeFiles(v, fileScript, fileJson)
	if err != nil {
		return fmt.Errorf("failed to write Versions: %v", err)
	}
	return nil
}
