// Copyright (c) 2021 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.
//
// Based on https://github.com/leodido/rn2md with kind permission from the author
package main

import (
	"bufio"
	"context"
	"fmt"
	"os"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/google/go-github/v38/github"
	logger "github.com/sirupsen/logrus"
	"github.com/spf13/cobra"
)

type UpdateOptions struct {
	Token         string
	ChangelogFile string
	Org           string
	Repo          string
	Branch        string
}

var opts = &UpdateOptions{}

var updateCommand = &cobra.Command{
	Use:   "update",
	Long:  "parses the latest entry from the existing changelog file, gets more recent PRs from GitHub and prepends their release note entries.",
	Short: "Generate markdown for your changelogs from release-note blocks.",
	Run: func(c *cobra.Command, args []string) {
		existingNotes, lastPrNumber, lastPrMonth := ParseFile(opts.ChangelogFile)
		client := NewClient(opts.Token)
		notes, err := GetReleaseNotes(client, opts, lastPrNumber, lastPrMonth)
		if err != nil {
			logger.WithError(err).Fatal("error retrieving PRs")
		}
		if len(notes) == 0 {
			logger.Infof("No new PRs, changelog is up-to-date")
			return
		}
		logger.Infof("Adding %d release note entries", len(notes))
		WriteFile(opts.ChangelogFile, notes, existingNotes, lastPrMonth)
	},
}

func init() {
	// Setup updateFlags before the command is initialized
	updateFlags := updateCommand.PersistentFlags()
	updateFlags.StringVarP(&opts.Org, "org", "o", opts.Org, "the github organization")
	updateFlags.StringVarP(&opts.Repo, "repo", "r", opts.Repo, "the github repository name")
	updateFlags.StringVarP(&opts.Branch, "branch", "b", "main", "the target branch you want to filter by the pull requests")
	updateFlags.StringVarP(&opts.ChangelogFile, "file", "f", "CHANGELOG.md", "the changelog file")
	updateFlags.StringVarP(&opts.Token, "token", "t", opts.Token, "a GitHub personal API token to perform authenticated requests")
	rootCommand.AddCommand(updateCommand)
}

var (
	noteLineRegexp = regexp.MustCompile(`[-*]\s.*\s\(\[#(\d*)\]\(`)
	dateLineRegexp = regexp.MustCompile(`## (\w* \d*)`)
)

func ParseFile(path string) (existingNotes []string, lastPrNumber int, lastPrMonth time.Time) {
	lastPrNumber = 0
	lastPrMonth = time.Unix(0, 0)
	if _, err := os.Stat(path); os.IsNotExist(err) {
		return
	}

	file, err := os.Open(path)
	if err != nil {
		logger.Fatal(err)
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		dateMatch := dateLineRegexp.FindStringSubmatch(scanner.Text())
		if len(dateMatch) > 1 && lastPrMonth == time.Unix(0, 0) {
			lastPrMonth, err = time.Parse("January 2006", dateMatch[1])
			if err != nil {
				logger.Warnf("Ignoring invalid date line %s", dateMatch[1])
			} else {
				logger.Infof("Last PR month %s", lastPrMonth.Format("January 2006"))
			}
		}
		prMatch := noteLineRegexp.FindStringSubmatch(scanner.Text())
		if len(prMatch) > 1 && lastPrNumber == 0 {
			lastPrNumber, err = strconv.Atoi(prMatch[1])
			if err != nil {
				logger.Warnf("Ignoring invalid PR number %s", prMatch[1])
			} else {
				logger.Infof("Last PR number #%d", lastPrNumber)
			}
		}
		if lastPrNumber != 0 {
			existingNotes = append(existingNotes, scanner.Text())
		}
	}
	if err := scanner.Err(); err != nil {
		logger.Fatal(err)
	}
	return
}

// ReleaseNote ...

type ReleaseNote struct {
	Breaking    bool
	Description string
	URI         string
	Num         int
	Authors     map[string]Author
	MergedAt    time.Time
}

type Author struct {
	Login string
	URL   string
}

type void struct{}

var member void
var releaseNoteRegexp = regexp.MustCompile("(?s)```release-note\\b(.+?)```")

const defaultGitHubBaseURI = "https://github.com"

// Get returns the list of release notes found for the given parameters.
func GetReleaseNotes(c *github.Client, opts *UpdateOptions, lastPrNr int, lastPrMonth time.Time) ([]ReleaseNote, error) {
	var (
		ctx          = context.Background()
		releaseNotes = []ReleaseNote{}
		processed    = make(map[int]void)
	)
	lastPrDate := lastPrMonth
	if lastPrNr != 0 {
		lastPr, _, err := c.PullRequests.Get(ctx, opts.Org, opts.Repo, lastPrNr)
		if err != nil {
			return nil, err
		}
		lastPrDate = lastPr.GetMergedAt()
	}
	logger.Infof("Will stop processing for PRs updated before %s", lastPrDate.Format("2006-01-02 15:04:05"))

	listingOpts := &github.PullRequestListOptions{
		State:     "closed",
		Base:      opts.Branch,
		Sort:      "updated",
		Direction: "desc",
		ListOptions: github.ListOptions{
			// All GitHub paginated queries start at page 1 !?
			// https://docs.github.com/en/rest/guides/traversing-with-pagination
			Page: 1,
		},
	}

	for {
		logger.Infof("Querying PRs from GitHub, page %d", listingOpts.ListOptions.Page)
		prs, response, err := c.PullRequests.List(ctx, opts.Org, opts.Repo, listingOpts)
		if _, ok := err.(*github.RateLimitError); ok {
			return nil, fmt.Errorf("hit rate limiting")
		}
		if err != nil {
			return nil, err
		}
		logger.Infof("Received %d PRs", len(prs))
		for _, p := range prs {
			num := p.GetNumber()
			if _, exists := processed[num]; exists {
				continue
			}
			// the changelog is sorted by merge date, but the GitHub results can only be
			// sorted by last update. So it's a bit tricky to find out when to stop, as we
			// sometimes comment on already merged PRs.
			if p.GetUpdatedAt().Before(lastPrDate) {
				// PRs are sorted by last update, so from here all PRs have already been
				// processed in prior runs
				return releaseNotes, nil
			}
			if p.GetMergedAt().Before(lastPrDate) || p.GetMergedAt().Equal(lastPrDate) {
				// PR has already been processed, but it could have been updated after merge
				continue
			}

			processed[num] = member

			isMerged, _, err := c.PullRequests.IsMerged(ctx, opts.Org, opts.Repo, num)
			if _, ok := err.(*github.RateLimitError); ok {
				return nil, fmt.Errorf("hit rate limiting")
			}
			if err != nil {
				return nil, fmt.Errorf("error detecting if PR %d is merged or not", num)
			}
			if !isMerged {
				// It means PR has been closed but not merged in
				continue
			}

			authors, err := GetAuthors(c, ctx, opts, num)
			if err != nil {
				return nil, fmt.Errorf("error getting authors of #%d", num)
			}
			res := releaseNoteRegexp.FindStringSubmatch(p.GetBody())
			if len(res) == 0 {
				// legacy mode for pre-changelog automation PRs
				rn := ReleaseNote{
					Breaking:    false,
					Description: p.GetTitle(),
					URI:         fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, opts.Org, opts.Repo, num),
					Num:         num,
					Authors:     authors,
					MergedAt:    p.GetMergedAt(),
				}
				releaseNotes = append(releaseNotes, rn)
				continue
			}
			note := strings.TrimSpace(res[1])
			if strings.ToUpper(note) == "NONE" {
				continue
			}
			notes := strings.Split(note, "\n")
			for _, n := range notes {
				n = strings.Trim(n, "\r")
				breaking := false
				if strings.HasPrefix(n, "!") {
					breaking = true
					n = strings.TrimSpace(n[1:])
				}
				rn := ReleaseNote{
					Breaking:    breaking,
					Description: n,
					URI:         fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, opts.Org, opts.Repo, num),
					Num:         num,
					Authors:     authors,
					MergedAt:    p.GetMergedAt(),
				}
				releaseNotes = append(releaseNotes, rn)
			}
		}
		if response.NextPage == 0 {
			break
		}
		listingOpts.ListOptions.Page = response.NextPage
	}
	return releaseNotes, nil
}

func GetAuthors(c *github.Client, ctx context.Context, opts *UpdateOptions, prNum int) (map[string]Author, error) {
	authors := make(map[string]Author)
	listOpts := &github.ListOptions{
		Page: 1,
	}
	for {
		commits, response, err := c.PullRequests.ListCommits(ctx, opts.Org, opts.Repo, prNum, listOpts)
		if _, ok := err.(*github.RateLimitError); ok {
			return nil, fmt.Errorf("hit rate limiting")
		}
		if err != nil {
			return nil, err
		}
		for _, commit := range commits {
			authors[commit.GetAuthor().GetLogin()] = Author{
				Login: commit.GetAuthor().GetLogin(),
				URL:   commit.GetAuthor().GetHTMLURL(),
			}
		}
		if response.NextPage == 0 {
			return authors, nil
		}
		listOpts.Page = response.NextPage
	}
}

func WriteFile(path string, notes []ReleaseNote, existingNotes []string, lastPrDate time.Time) {
	sort.SliceStable(notes, func(i, j int) bool {
		return notes[i].MergedAt.After(notes[j].MergedAt)
	})
	file, err := os.Create(path)
	if err != nil {
		logger.Fatalf("Cannot write file %s", path)
	}
	defer file.Close()
	fmt.Fprintln(file, "# Change Log")
	lastMonth := ""
	currentMonth := ""
	for _, note := range notes {
		currentMonth = note.MergedAt.Format("January 2006")
		if currentMonth != lastMonth {
			fmt.Fprintln(file, "\n##", currentMonth)
			lastMonth = currentMonth
		}
		breaking := ""
		if note.Breaking {
			breaking = " *BREAKING*"
		}
		authors := make([]string, len(note.Authors))
		i := 0
		for _, author := range note.Authors {
			authors[i] = fmt.Sprintf("[@%s](%s)", author.Login, author.URL)
			i++
		}
		sort.Strings(authors)
		line := fmt.Sprintf("-%s %s ([#%d](%s)) - %s", breaking, note.Description, note.Num, note.URI, strings.Join(authors, ", "))
		fmt.Fprintln(file, line)
	}

	if len(existingNotes) > 0 {
		currentMonth = lastPrDate.Format("January 2006")
		if currentMonth != lastMonth {
			fmt.Fprintln(file, "\n## ", currentMonth)
		}
		for _, note := range existingNotes {
			fmt.Fprintln(file, note)
		}
	}
}
