// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// The stacks command finds all gopls stack traces reported by
// telemetry in the past 7 days, and reports their associated GitHub
// issue, creating new issues as needed.
package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"flag"
	"fmt"
	"hash/fnv"
	"log"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"io"

	"golang.org/x/telemetry"
	"golang.org/x/tools/gopls/internal/util/browser"
	"golang.org/x/tools/gopls/internal/util/maps"
)

// flags
var (
	daysFlag = flag.Int("days", 7, "number of previous days of telemetry data to read")

	token string // optional GitHub authentication token, to relax the rate limit
)

func main() {
	log.SetFlags(0)
	log.SetPrefix("stacks: ")
	flag.Parse()

	// Read GitHub authentication token from $HOME/.stacks.token.
	//
	// You can create one using the flow at: GitHub > You > Settings >
	// Developer Settings > Personal Access Tokens > Fine-grained tokens >
	// Generate New Token.  Generate the token on behalf of yourself
	// (not "golang" or "google"), with no special permissions.
	// The token is typically of the form "github_pat_XXX", with 82 hex digits.
	// Save it in the file, with mode 0400.
	//
	// For security, secret tokens should be read from files, not
	// command-line flags or environment variables.
	{
		home, err := os.UserHomeDir()
		if err != nil {
			log.Fatal(err)
		}
		tokenFile := filepath.Join(home, ".stacks.token")
		content, err := os.ReadFile(tokenFile)
		if err != nil {
			if !os.IsNotExist(err) {
				log.Fatalf("cannot read GitHub authentication token: %v", err)
			}
			log.Printf("no file %s containing GitHub authentication token; continuing without authentication, which is subject to stricter rate limits (https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).", tokenFile)
		}
		token = string(bytes.TrimSpace(content))
	}

	// Maps stack text to Version/GoVersion/GOOS/GOARCH string to counter.
	stacks := make(map[string]map[string]int64)
	var distinctStacks int

	// Maps stack to a telemetry URL.
	stackToURL := make(map[string]string)

	// Read all recent telemetry reports.
	t := time.Now()
	for i := 0; i < *daysFlag; i++ {
		const DateOnly = "2006-01-02" // TODO(adonovan): use time.DateOnly in go1.20.
		date := t.Add(-time.Duration(i+1) * 24 * time.Hour).Format(DateOnly)

		url := fmt.Sprintf("https://storage.googleapis.com/prod-telemetry-merged/%s.json", date)
		resp, err := http.Get(url)
		if err != nil {
			log.Fatalf("can't GET %s: %v", url, err)
		}
		defer resp.Body.Close()
		if resp.StatusCode != 200 {
			log.Fatalf("GET %s returned %d %s", url, resp.StatusCode, resp.Status)
		}

		dec := json.NewDecoder(resp.Body)
		for {
			var report telemetry.Report
			if err := dec.Decode(&report); err != nil {
				if err == io.EOF {
					break
				}
				log.Fatal(err)
			}
			for _, prog := range report.Programs {
				if prog.Program == "golang.org/x/tools/gopls" && len(prog.Stacks) > 0 {
					// Include applicable client names (e.g. vscode, eglot).
					var clients []string
					var clientSuffix string
					for key := range prog.Counters {
						client := strings.TrimPrefix(key, "gopls/client:")
						if client != key {
							clients = append(clients, client)
						}
					}
					sort.Strings(clients)
					if len(clients) > 0 {
						clientSuffix = " " + strings.Join(clients, ",")
					}

					// Ignore @devel versions as they correspond to
					// ephemeral (and often numerous) variations of
					// the program as we work on a fix to a bug.
					if prog.Version == "devel" {
						continue
					}

					distinctStacks++

					info := fmt.Sprintf("%s@%s %s %s/%s%s",
						prog.Program, prog.Version,
						prog.GoVersion, prog.GOOS, prog.GOARCH,
						clientSuffix)
					for stack, count := range prog.Stacks {
						counts := stacks[stack]
						if counts == nil {
							counts = make(map[string]int64)
							stacks[stack] = counts
						}
						counts[info] += count
						stackToURL[stack] = url
					}
				}
			}
		}
	}

	// Compute IDs of all stacks.
	var stackIDs []string
	for stack := range stacks {
		stackIDs = append(stackIDs, stackID(stack))
	}

	// Query GitHub for existing GitHub issues.
	// (Note: there may be multiple Issue records
	// for the same logical issue, i.e. Issue.Number.)
	issuesByStackID := make(map[string]*Issue)
	for len(stackIDs) > 0 {
		// For some reason GitHub returns 422 UnprocessableEntity
		// if we attempt to read more than 6 at once.
		batch := stackIDs[:min(6, len(stackIDs))]
		stackIDs = stackIDs[len(batch):]

		query := "is:issue label:gopls/telemetry-wins in:body " + strings.Join(batch, " OR ")
		res, err := searchIssues(query)
		if err != nil {
			log.Fatalf("GitHub issues query %q failed: %v", query, err)
		}
		for _, issue := range res.Items {
			for _, id := range batch {
				// Matching is a little fuzzy here
				// but base64 will rarely produce
				// words that appear in the body
				// by chance.
				if strings.Contains(issue.Body, id) {
					issuesByStackID[id] = issue
				}
			}
		}
	}

	fmt.Printf("Found %d distinct stacks in last %v days:\n", distinctStacks, *daysFlag)

	// For each stack, show existing issue or create a new one.
	// Aggregate stack IDs by issue summary.
	var (
		// Both vars map the summary line to the stack count.
		existingIssues = make(map[string]int64)
		newIssues      = make(map[string]int64)
	)
	for stack, counts := range stacks {
		id := stackID(stack)

		var total int64
		for _, count := range counts {
			total += count
		}

		if issue, ok := issuesByStackID[id]; ok {
			// existing issue
			summary := fmt.Sprintf("#%d: %s [%s]",
				issue.Number, issue.Title, issue.State)
			existingIssues[summary] += total
		} else {
			// new issue
			title := newIssue(stack, id, stackToURL[stack], counts)
			summary := fmt.Sprintf("%s: %s [%s]", id, title, "new")
			newIssues[summary] += total
		}
	}
	print := func(caption string, issues map[string]int64) {
		// Print items in descending frequency.
		keys := maps.Keys(issues)
		sort.Slice(keys, func(i, j int) bool {
			return issues[keys[i]] > issues[keys[j]]
		})
		fmt.Printf("%s issues:\n", caption)
		for _, summary := range keys {
			count := issues[summary]
			fmt.Printf("%s (n=%d)\n", summary, count)
		}
	}
	print("Existing", existingIssues)
	print("New", newIssues)
}

// stackID returns a 32-bit identifier for a stack
// suitable for use in GitHub issue titles.
func stackID(stack string) string {
	// Encode it using base64 (6 bytes) for brevity,
	// as a single issue's body might contain multiple IDs
	// if separate issues with same cause wre manually de-duped,
	// e.g. "AAAAAA, BBBBBB"
	//
	// https://hbfs.wordpress.com/2012/03/30/finding-collisions:
	// the chance of a collision is 1 - exp(-n(n-1)/2d) where n
	// is the number of items and d is the number of distinct values.
	// So, even with n=10^4 telemetry-reported stacks each identified
	// by a uint32 (d=2^32), we have a 1% chance of a collision,
	// which is plenty good enough.
	h := fnv.New32()
	io.WriteString(h, stack)
	return base64.URLEncoding.EncodeToString(h.Sum(nil))[:6]
}

// newIssue creates a browser tab with a populated GitHub "New issue"
// form for the specified stack. (The triage person is expected to
// manually de-dup the issue before deciding whether to submit the form.)
//
// It returns the title.
func newIssue(stack, id, jsonURL string, counts map[string]int64) string {
	// Use a heuristic to find a suitable symbol to blame
	// in the title: the first public function or method
	// of a public type, in gopls, to appear in the stack
	// trace. We can always refine it later.
	var symbol string
	for _, line := range strings.Split(stack, "\n") {
		// Look for:
		//   gopls/.../pkg.Func
		//   gopls/.../pkg.Type.method
		//   gopls/.../pkg.(*Type).method
		if strings.Contains(line, "internal/util/bug.") {
			continue // not interesting
		}
		if _, rest, ok := strings.Cut(line, "golang.org/x/tools/gopls/"); ok {
			if i := strings.IndexByte(rest, '.'); i >= 0 {
				rest = rest[i+1:]
				rest = strings.TrimPrefix(rest, "(*")
				if rest != "" && 'A' <= rest[0] && rest[0] <= 'Z' {
					rest, _, _ = strings.Cut(rest, ":")
					symbol = " " + rest
					break
				}
			}
		}
	}

	// Populate the form (title, body, label)
	title := fmt.Sprintf("x/tools/gopls:%s bug reported by telemetry", symbol)
	body := new(bytes.Buffer)
	fmt.Fprintf(body, "This stack `%s` was [reported by telemetry](%s):\n\n",
		id, jsonURL)
	fmt.Fprintf(body, "```\n%s\n```\n", stack)

	// Add counts, gopls version, and platform info.
	// This isn't very precise but should provide clues.
	//
	// TODO(adonovan): link each stack (ideally each frame) to source:
	// https://cs.opensource.google/go/x/tools/+/gopls/VERSION:gopls/FILE;l=LINE
	// (Requires parsing stack, shallow-cloning gopls module at that tag, and
	// computing correct line offsets. Would be labor-saving though.)
	fmt.Fprintf(body, "```\n")
	for info, count := range counts {
		fmt.Fprintf(body, "%s (%d)\n", info, count)
	}
	fmt.Fprintf(body, "```\n\n")

	fmt.Fprintf(body, "Issue created by golang.org/x/tools/gopls/internal/telemetry/cmd/stacks.\n")

	const labels = "gopls,Tools,gopls/telemetry-wins,NeedsInvestigation"

	// Report it.
	if !browser.Open("https://github.com/golang/go/issues/new?labels=" + labels + "&title=" + url.QueryEscape(title) + "&body=" + url.QueryEscape(body.String())) {
		log.Print("Please file a new issue at golang.org/issue/new using this template:\n\n")
		log.Printf("Title: %s\n", title)
		log.Printf("Labels: %s\n", labels)
		log.Printf("Body: %s\n", body)
	}

	return title
}

// -- GitHub search --

// searchIssues queries the GitHub issue tracker.
func searchIssues(query string) (*IssuesSearchResult, error) {
	q := url.QueryEscape(query)

	req, err := http.NewRequest("GET", IssuesURL+"?q="+q, nil)
	if err != nil {
		return nil, err
	}
	if token != "" {
		req.Header.Add("Authorization", "Bearer "+token)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		return nil, fmt.Errorf("search query failed: %s (body: %s)", resp.Status, body)
	}
	var result IssuesSearchResult
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		resp.Body.Close()
		return nil, err
	}
	resp.Body.Close()
	return &result, nil
}

// See https://developer.github.com/v3/search/#search-issues.

const IssuesURL = "https://api.github.com/search/issues"

type IssuesSearchResult struct {
	TotalCount int `json:"total_count"`
	Items      []*Issue
}

type Issue struct {
	Number    int
	HTMLURL   string `json:"html_url"`
	Title     string
	State     string
	User      *User
	CreatedAt time.Time `json:"created_at"`
	Body      string    // in Markdown format
}

type User struct {
	Login   string
	HTMLURL string `json:"html_url"`
}

// -- helpers --

func min(x, y int) int {
	if x < y {
		return x
	} else {
		return y
	}
}
