// Copyright (c) 2024 Tigera, Inc. 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 outputs

import (
	"context"
	_ "embed"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/google/go-github/v53/github"
	"github.com/sirupsen/logrus"

	"github.com/projectcalico/calico/release/internal/utils"
	"github.com/projectcalico/calico/release/internal/version"
)

const ReleaseNotesDir = "release-notes"

const (
	releaseNoteRequiredLabel = "release-note-required"
	bugFixLabel              = "kind/bug"
	closedState              = issueState("closed")
	openState                = issueState("open")
	allState                 = issueState("all")
)

var (
	//go:embed templates/release-note.md.gotmpl
	releaseNoteTemplate string
	repos               = []string{utils.CalicoRepoName, utils.BirdRepoName}
	newlinePattern      = regexp.MustCompile(`\r\n|[\r\n\v\f\x{0085}\x{2028}\x{2029}]`)
	releaseNotePattern  = regexp.MustCompile("(?s)```release-note\\s(.*?)\\s```")
)

type issueState string

// ReleaseNoteIssueData represents the data for an release note issue
type ReleaseNoteIssueData struct {
	ID     int
	Note   string
	Repo   string
	URL    string
	Author string
}

// ReleaseNoteData represents the data for release notes
type ReleaseNoteData struct {
	Date         string
	BugFixes     []*ReleaseNoteIssueData
	OtherChanges []*ReleaseNoteIssueData
}

// milestoneNumber returns the milestone number for a given milestone
func milestoneNumber(client *github.Client, owner, repo, milestone string, opts *github.MilestoneListOptions) (int, error) {
	for {
		milestones, resp, err := client.Issues.ListMilestones(context.Background(), owner, repo, opts)
		if err != nil {
			return -1, err
		}
		for _, m := range milestones {
			if m.GetTitle() == milestone {
				return m.GetNumber(), nil
			}
		}
		if resp.NextPage == 0 {
			break
		}
		opts.Page = resp.NextPage
	}
	return -1, fmt.Errorf("milestone not found")
}

// prIssuesByRepo returns all the PR issues for a given repo
func prIssuesByRepo(client *github.Client, owner, repo string, opts *github.IssueListByRepoOptions) ([]*github.Issue, error) {
	prIssues := []*github.Issue{}
	for {
		issues, resp, err := client.Issues.ListByRepo(context.Background(), owner, repo, opts)
		if err != nil {
			return nil, err
		}
		for _, issue := range issues {
			if issue.IsPullRequest() {
				prIssues = append(prIssues, issue)
			}
		}
		if resp.NextPage == 0 {
			break
		}
		opts.Page = resp.NextPage
	}
	return prIssues, nil
}

// extractReleaseNoteFromIssue extracts release notes from an issue.
// It looks for the release note block in the issue body and returns the content
// between the start and end markers. To make RNs more readable, replace newlines
// with spaces rather than breaking it into multiple RNs (in case someone word-wraps
// their RNs).
func extractReleaseNoteFromIssue(issue *github.Issue) ([]string, error) {
	body := issue.GetBody()
	matches := releaseNotePattern.FindAllStringSubmatch(body, -1)
	if len(matches) == 0 {
		return []string{issue.GetTitle()}, fmt.Errorf("no release notes found")
	}
	var notes []string
	for _, match := range matches {
		if len(match) > 1 {
			releaseNoteText := strings.TrimSpace(match[1])
			releaseNoteText = newlinePattern.ReplaceAllString(releaseNoteText, " ")
			notes = append(notes, releaseNoteText)
		}
	}
	return notes, nil
}

// extractReleaseNote extracts release notes from a list of issues
func extractReleaseNote(repo string, issues []*github.Issue) ([]*ReleaseNoteIssueData, error) {
	issueDataList := []*ReleaseNoteIssueData{}
	for _, issue := range issues {
		logrus.WithField("issue", issue.GetNumber()).Debug("Extracting release notes")
		notes, err := extractReleaseNoteFromIssue(issue)
		if err != nil && len(notes) == 0 {
			logrus.WithError(err).Errorf("Failed to extract release notes for issue %d", issue.GetNumber())
			return nil, err
		}
		for _, note := range notes {
			note = strings.TrimSpace(note)
			if note == "TBD" {
				logrus.WithFields(logrus.Fields{
					"url":    issue.GetHTMLURL(),
					"author": issue.GetUser().GetLogin(),
				}).Warnf("Release note is TBD, please update the issue")
			}
			issueData := &ReleaseNoteIssueData{
				ID:     issue.GetNumber(),
				Note:   note,
				Repo:   repo,
				URL:    issue.GetHTMLURL(),
				Author: issue.GetUser().GetLogin(),
			}
			issueDataList = append(issueDataList, issueData)
		}
	}
	return issueDataList, nil
}

// appendReleaseNotes extracts release notes from the given issues and appends
// them to the provided list.
func appendReleaseNotes(dataList []*ReleaseNoteIssueData, repo string, issues []*github.Issue) ([]*ReleaseNoteIssueData, error) {
	relNoteDataList, err := extractReleaseNote(repo, issues)
	if err != nil {
		return dataList, err
	}
	return append(dataList, relNoteDataList...), nil
}

// issueHasLabel reports whether the issue carries the given label.
func issueHasLabel(issue *github.Issue, name string) bool {
	for _, label := range issue.Labels {
		if label.GetName() == name {
			return true
		}
	}
	return false
}

// outputReleaseNotes outputs the release notes to a file
func outputReleaseNotes(bugFixes, otherChanges []*ReleaseNoteIssueData, outputFilePath string) error {
	dir := filepath.Dir(outputFilePath)
	if err := os.MkdirAll(dir, utils.DirPerms); err != nil {
		logrus.WithError(err).Errorf("Failed to create release notes folder %s", dir)
		return err
	}
	logrus.WithField("template", releaseNoteTemplate).Debug("Parsing release note template")
	tmpl, err := template.New("release-note").Parse(releaseNoteTemplate)
	if err != nil {
		logrus.WithError(err).Error("Failed to parse release note template")
		return err
	}
	logrus.Debug("Generating release notes from template")
	date := time.Now().Format("02 Jan 2006")
	data := &ReleaseNoteData{
		Date:         date,
		BugFixes:     bugFixes,
		OtherChanges: otherChanges,
	}
	releaseNotedFile, err := os.Create(outputFilePath)
	if err != nil {
		logrus.WithError(err).Error("Failed to create release notes file")
		return err
	}
	defer func() { _ = releaseNotedFile.Close() }()
	if err := tmpl.Execute(releaseNotedFile, data); err != nil {
		logrus.WithError(err).Error("Failed to execute release note template")
		return err
	}
	return nil
}

// ReleaseNotes generates release notes for a milestone
// and outputs it to a file in <outputDir>/release-notes/<milestone>-release-notes.md
func ReleaseNotes(owner, githubToken, repoRootDir, outputDir string, ver version.Version) (string, error) {
	if githubToken == "" {
		return "", fmt.Errorf("github token not set, set GITHUB_TOKEN environment variable")
	}
	if outputDir == "" && repoRootDir == "" {
		return "", fmt.Errorf("either outputDir or repoRootDir must be set")
	}
	if outputDir == "" {
		outputDir = releaseNoteDirPath(repoRootDir)
	}
	if owner != utils.ProjectCalicoOrg {
		logrus.WithField("org", owner).Warnf("generating release notes outside of %s GitHub organization is not supported, switching back to %s", utils.ProjectCalicoOrg, utils.ProjectCalicoOrg)
		owner = utils.ProjectCalicoOrg
	}

	logrus.Infof("Generating release notes for %s", ver.FormattedString())
	milestone := ver.Milestone(utils.ProductName)
	githubClient := github.NewTokenClient(context.Background(), githubToken)
	bugFixDataList := []*ReleaseNoteIssueData{}
	otherChangesDataList := []*ReleaseNoteIssueData{}
	opts := &github.MilestoneListOptions{
		State: string(openState),
	}
	prIssues := []*github.Issue{}
	for _, repo := range repos {
		milestoneNumber, err := milestoneNumber(githubClient, owner, repo, milestone, opts)
		if err != nil {
			logrus.WithError(err).Warnf("Failed to retrieve milestone for %s", repo)
			continue
		}
		logrus.WithField("repo", repo).Debugf("Found milestone %s: %d", milestone, milestoneNumber)
		logrus.WithField("repo", repo).Debug("Getting issues")
		prIssuesByRepo, err := prIssuesByRepo(githubClient, owner, repo, &github.IssueListByRepoOptions{
			Milestone: strconv.Itoa(milestoneNumber),
			State:     string(allState),
		})
		if err != nil {
			logrus.WithError(err).Errorf("Failed to get issues for %s", repo)
			return "", err
		}
		logrus.WithField("repo", repo).Debugf("Found %d PRs", len(prIssuesByRepo))
		prIssues = append(prIssues, prIssuesByRepo...)
		closedBugFixIssues := []*github.Issue{}
		closedOtherIssues := []*github.Issue{}
		for _, issue := range prIssuesByRepo {
			if issue.GetState() != string(closedState) || !issueHasLabel(issue, releaseNoteRequiredLabel) {
				continue
			}
			if issueHasLabel(issue, bugFixLabel) {
				closedBugFixIssues = append(closedBugFixIssues, issue)
			} else {
				closedOtherIssues = append(closedOtherIssues, issue)
			}
		}
		bugFixDataList, err = appendReleaseNotes(bugFixDataList, repo, closedBugFixIssues)
		if err != nil {
			logrus.WithError(err).Error("Failed to extract bug fix release notes")
			return "", err
		}
		otherChangesDataList, err = appendReleaseNotes(otherChangesDataList, repo, closedOtherIssues)
		if err != nil {
			logrus.WithError(err).Error("Failed to extract release notes")
			return "", err
		}
	}
	if len(prIssues) == 0 {
		logrus.WithField("milestone", milestone).Error("No PRs found for milestone")
		return "", fmt.Errorf("no PRs found for milestone %s", milestone)
	}

	if len(bugFixDataList) == 0 && len(otherChangesDataList) == 0 {
		logrus.WithField("milestone", milestone).Warn("No closed issues requiring release notes found in milestone")
	}
	releaseNoteFilePath := releaseNoteFilePathFromDir(outputDir, ver.FormattedString())
	if err := outputReleaseNotes(bugFixDataList, otherChangesDataList, releaseNoteFilePath); err != nil {
		logrus.WithError(err).Error("Failed to output release notes")
		return "", err
	}
	return releaseNoteFilePath, nil
}

func releaseNoteDirPath(rootDir string) string {
	return filepath.Join(rootDir, ReleaseNotesDir)
}

func releaseNoteFilePathFromDir(dir, version string) string {
	return filepath.Join(dir, fmt.Sprintf("%s-release-notes.md", version))
}

func ReleaseNoteFilePath(repoRootDir, version string) string {
	return releaseNoteFilePathFromDir(releaseNoteDirPath(repoRootDir), version)
}
