// Copyright 2023 syzkaller project authors. All rights reserved.
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"sort"
	"strings"
	"time"

	"github.com/google/syzkaller/dashboard/dashapi"
	"github.com/google/syzkaller/pkg/hash"
	"golang.org/x/net/context"
	"google.golang.org/appengine/v2"
	db "google.golang.org/appengine/v2/datastore"
	"google.golang.org/appengine/v2/log"
)

// reportingPollBugLists is called by backends to get bug lists that need to be reported.
func reportingPollBugLists(c context.Context, typ string) []*dashapi.BugListReport {
	state, err := loadReportingState(c)
	if err != nil {
		log.Errorf(c, "%v", err)
		return nil
	}
	registry, err := makeSubsystemReportRegistry(c)
	if err != nil {
		log.Errorf(c, "%v", err)
		return nil
	}
	ret := []*dashapi.BugListReport{}
	for ns, nsConfig := range config.Namespaces {
		rConfig := nsConfig.Subsystems.Reminder
		if rConfig == nil {
			continue
		}
		reporting := nsConfig.ReportingByName(rConfig.SourceReporting)
		stateEntry := state.getEntry(timeNow(c), ns, reporting.Name)
		// The DB might well contain info about stale entities, but by querying the latest
		// list of subsystems from the configuration, we make sure we only consider what's
		// currently relevant.
		rawSubsystems := nsConfig.Subsystems.Service.List()
		// Sort to keep output stable.
		sort.Slice(rawSubsystems, func(i, j int) bool {
			return rawSubsystems[i].Name < rawSubsystems[j].Name
		})
		for _, entry := range rawSubsystems {
			if entry.NoReminders {
				continue
			}
			for _, dbReport := range registry.get(ns, entry.Name) {
				if stateEntry.Sent >= reporting.DailyLimit {
					break
				}
				report, err := reportingBugListReport(c, dbReport, ns, entry.Name, typ)
				if err != nil {
					log.Errorf(c, "%v", err)
					return nil
				}
				if report != nil {
					ret = append(ret, report)
					stateEntry.Sent++
				}
			}
		}
	}
	return ret
}

const maxNewListsPerNs = 5

// handleSubsystemReports is periodically invoked to construct fresh SubsystemReport objects.
func handleSubsystemReports(w http.ResponseWriter, r *http.Request) {
	c := appengine.NewContext(r)
	registry, err := makeSubsystemRegistry(c)
	if err != nil {
		log.Errorf(c, "failed to load subsystems: %v", err)
		return
	}
	for ns, nsConfig := range config.Namespaces {
		rConfig := nsConfig.Subsystems.Reminder
		if rConfig == nil {
			continue
		}
		minPeriod := 24 * time.Hour * time.Duration(rConfig.PeriodDays)
		reporting := nsConfig.ReportingByName(rConfig.SourceReporting)
		var subsystems []*Subsystem
		for _, entry := range nsConfig.Subsystems.Service.List() {
			if entry.NoReminders {
				continue
			}
			subsystems = append(subsystems, registry.get(ns, entry.Name))
		}
		// Poll subsystems in a round-robin manner.
		sort.Slice(subsystems, func(i, j int) bool {
			return subsystems[i].ListsQueried.Before(subsystems[j].ListsQueried)
		})
		updateLimit := maxNewListsPerNs
		for _, subsystem := range subsystems {
			if updateLimit == 0 {
				break
			}
			if timeNow(c).Before(subsystem.LastBugList.Add(minPeriod)) {
				continue
			}
			report, err := querySubsystemReport(c, subsystem, reporting, rConfig)
			if err != nil {
				log.Errorf(c, "failed to query bug lists: %v", err)
				return
			}
			if err := registry.updatePoll(c, subsystem, report != nil); err != nil {
				log.Errorf(c, "failed to update subsystem: %v", err)
				return
			}
			if report == nil {
				continue
			}
			updateLimit--
			if err := storeSubsystemReport(c, subsystem, report); err != nil {
				log.Errorf(c, "failed to save subsystem: %v", err)
				return
			}
		}
	}
}

func reportingBugListCommand(c context.Context, cmd *dashapi.BugListUpdate) (string, error) {
	// We have to execute it outside of the transacation, otherwise we get the
	// "Only ancestor queries are allowed inside transactions." error.
	subsystem, rawReport, _, err := findSubsystemReportByID(c, cmd.ID)
	if err != nil {
		return "", err
	}
	if subsystem == nil {
		return "", fmt.Errorf("the bug list was not found")
	}
	reply := ""
	tx := func(c context.Context) error {
		subsystemKey := subsystemKey(c, subsystem)
		reportKey := subsystemReportKey(c, subsystemKey, rawReport)
		report := new(SubsystemReport)
		if err := db.Get(c, reportKey, report); err != nil {
			return fmt.Errorf("failed to query SubsystemReport (%v): %w", reportKey, err)
		}
		stage := report.findStage(cmd.ID)
		if stage.ExtID == "" {
			stage.ExtID = cmd.ExtID
		}
		if stage.Link == "" {
			stage.Link = cmd.Link
		}
		// It might e.g. happen that we skipped a stage in reportingBugListReport.
		// Make sure all skipped stages have non-nil Closed.
		for i := range report.Stages {
			item := &report.Stages[i]
			if cmd.Command != dashapi.BugListRegenerateCmd && item == stage {
				break
			}
			item.Closed = timeNow(c)
		}
		switch cmd.Command {
		case dashapi.BugListSentCmd:
			if !stage.Reported.IsZero() {
				return fmt.Errorf("the reporting stage was already reported")
			}
			stage.Reported = timeNow(c)

			state, err := loadReportingState(c)
			if err != nil {
				return fmt.Errorf("failed to query state: %w", err)
			}
			stateEnt := state.getEntry(timeNow(c), subsystem.Namespace,
				config.Namespaces[subsystem.Namespace].Subsystems.Reminder.SourceReporting)
			stateEnt.Sent++
			if err := saveReportingState(c, state); err != nil {
				return fmt.Errorf("failed to save state: %w", err)
			}
		case dashapi.BugListUpstreamCmd:
			if !stage.Moderation {
				reply = `The report cannot be sent further upstream.
It's already at the last reporting stage.`
				return nil
			}
			if !stage.Closed.IsZero() {
				reply = `The bug list was already upstreamed.
Please visit the new discussion thread.`
				return nil
			}
			stage.Closed = timeNow(c)
		case dashapi.BugListRegenerateCmd:
			dbSubsystem := new(Subsystem)
			err := db.Get(c, subsystemKey, dbSubsystem)
			if err != nil {
				return fmt.Errorf("failed to get subsystem: %s", err)
			}
			dbSubsystem.LastBugList = time.Time{}
			_, err = db.Put(c, subsystemKey, dbSubsystem)
			if err != nil {
				return fmt.Errorf("failed to save subsystem: %s", err)
			}
		}
		_, err = db.Put(c, reportKey, report)
		if err != nil {
			return fmt.Errorf("failed to save the object: %w", err)
		}
		return nil
	}
	return reply, db.RunInTransaction(c, tx, &db.TransactionOptions{
		XG:       true,
		Attempts: 10,
	})
}

func findSubsystemReportByID(c context.Context, ID string) (*Subsystem,
	*SubsystemReport, *SubsystemReportStage, error) {
	var subsystemReports []*SubsystemReport
	reportKeys, err := db.NewQuery("SubsystemReport").
		Filter("Stages.ID=", ID).
		Limit(1).
		GetAll(c, &subsystemReports)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("failed to query subsystem reports: %w", err)
	}
	if len(subsystemReports) == 0 {
		return nil, nil, nil, nil
	}
	stage := subsystemReports[0].findStage(ID)
	if stage == nil {
		// This should never happen (provided that all the code is correct).
		return nil, nil, nil, fmt.Errorf("bug list is found, but the stage is missing")
	}
	subsystem := new(Subsystem)
	if err := db.Get(c, reportKeys[0].Parent(), subsystem); err != nil {
		return nil, nil, nil, fmt.Errorf("failed to query subsystem: %w", err)
	}
	return subsystem, subsystemReports[0], stage, nil
}

// querySubsystemReport queries the open bugs and constructs a new SubsystemReport object.
func querySubsystemReport(c context.Context, subsystem *Subsystem, reporting *Reporting,
	config *BugListReportingConfig) (*SubsystemReport, error) {
	rawOpenBugs, fixedBugs, err := queryMatchingBugs(c, subsystem.Namespace,
		subsystem.Name, reporting.AccessLevel)
	if err != nil {
		return nil, err
	}
	withRepro, noRepro := []*Bug{}, []*Bug{}
	for _, bug := range rawOpenBugs {
		currReporting, _, _, _, _ := currentReporting(bug)
		if reporting.Name != currReporting.Name {
			// The big is not at the expected reporting stage.
			continue
		}
		const possiblyFixedTimespan = 24 * time.Hour * 14
		if bug.LastTime.Before(timeNow(c).Add(-possiblyFixedTimespan)) {
			// The bug didn't happen recently, possibly it was already fixed.
			// Let's not display such bugs in reminders.
			continue
		}
		if bug.FirstTime.After(timeNow(c).Add(-config.MinBugAge)) {
			// Don't take bugs which are too new -- they're still fresh in memory.
			continue
		}
		discussions := bug.discussionSummary()
		if discussions.ExternalMessages > 0 &&
			discussions.LastMessage.After(timeNow(c).Add(-config.UserReplyFrist)) {
			// Don't take bugs with recent user replies.
			// As we don't keep exactly the date of the last user message, approximate it.
			continue
		}
		if bug.HasLabel(NoRemindersLabel, "") {
			// The bug was intentionally excluded from monthly reminders.
			continue
		}
		if bug.ReproLevel == dashapi.ReproLevelNone {
			noRepro = append(noRepro, bug)
		} else {
			withRepro = append(withRepro, bug)
		}
	}
	// Let's reduce noise and don't remind about just one bug.
	if len(noRepro)+len(withRepro) < config.MinBugsCount {
		return nil, nil
	}
	// Even if we have enough bugs with a reproducer, there might still be bugs
	// without a reproducer that have a lot of crashes. So let's take a small number
	// of such bugs and give them a chance to be present in the final list.
	takeNoRepro := 2
	if takeNoRepro+len(withRepro) < config.BugsInReport {
		takeNoRepro = config.BugsInReport - len(withRepro)
	}
	if takeNoRepro > len(noRepro) {
		takeNoRepro = len(noRepro)
	}
	sort.Slice(noRepro, func(i, j int) bool {
		return noRepro[i].NumCrashes > noRepro[j].NumCrashes
	})
	takeBugs := append(withRepro, noRepro[:takeNoRepro]...)
	sort.Slice(takeBugs, func(i, j int) bool {
		firstPrio, secondPrio := takeBugs[i].prio(), takeBugs[j].prio()
		if firstPrio != secondPrio {
			return !firstPrio.LessThan(secondPrio)
		}
		if takeBugs[i].NumCrashes != takeBugs[j].NumCrashes {
			return takeBugs[i].NumCrashes > takeBugs[j].NumCrashes
		}
		return takeBugs[i].Title < takeBugs[j].Title
	})
	keys := []*db.Key{}
	for _, bug := range takeBugs {
		keys = append(keys, bug.key(c))
	}
	if len(keys) > config.BugsInReport {
		keys = keys[:config.BugsInReport]
	}
	report := makeSubsystemReport(c, config, keys)
	report.TotalStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, 0)
	report.PeriodStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, config.PeriodDays)
	return report, nil
}

func makeSubsystemReportStats(c context.Context, open, fixed []*Bug, days int) SubsystemReportStats {
	if days > 0 {
		after := timeNow(c).Add(-time.Hour * 24 * time.Duration(days))
		ret := SubsystemReportStats{}
		for _, bug := range open {
			if bug.FirstTime.After(after) {
				ret.Reported++
			}
		}
		for _, bug := range fixed {
			if len(bug.CommitInfo) > 0 && bug.CommitInfo[0].Date.After(after) {
				ret.Fixed++
			}
		}
		return ret
	}
	return SubsystemReportStats{
		Reported: len(open),
		Fixed:    len(fixed),
	}
}

func queryMatchingBugs(c context.Context, ns, name string, accessLevel AccessLevel) ([]*Bug, []*Bug, error) {
	allOpenBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query {
		return query.Filter("Namespace=", ns).
			Filter("Status=", BugStatusOpen).
			Filter("Labels.Label=", SubsystemLabel).
			Filter("Labels.Value=", name)
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to query open bugs for subsystem: %w", err)
	}
	allFixedBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query {
		return query.Filter("Namespace=", ns).
			Filter("Status=", BugStatusFixed).
			Filter("Labels.Label=", SubsystemLabel).
			Filter("Labels.Value=", name)
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to query fixed bugs for subsystem: %w", err)
	}
	open, fixed := []*Bug{}, []*Bug{}
	for _, bug := range append(allOpenBugs, allFixedBugs...) {
		if len(bug.Commits) != 0 || bug.Status == BugStatusFixed {
			// This bug is no longer really open.
			fixed = append(fixed, bug)
			continue
		}
		currReporting, _, _, _, err := currentReporting(bug)
		if err != nil {
			continue
		}
		if currReporting.AccessLevel > accessLevel {
			continue
		}
		open = append(open, bug)
	}
	return open, fixed, nil
}

// makeSubsystemReport creates a new SubsystemReminder object.
func makeSubsystemReport(c context.Context, config *BugListReportingConfig,
	keys []*db.Key) *SubsystemReport {
	ret := &SubsystemReport{
		Created: timeNow(c),
	}
	for _, key := range keys {
		ret.BugKeys = append(ret.BugKeys, key.Encode())
	}
	baseID := hash.String([]byte(fmt.Sprintf("%v-%v", timeNow(c), ret.BugKeys)))
	if config.ModerationConfig != nil {
		ret.Stages = append(ret.Stages, SubsystemReportStage{
			ID:         bugListReportingHash(baseID, "moderation"),
			Moderation: true,
		})
	}
	ret.Stages = append(ret.Stages, SubsystemReportStage{
		ID: bugListReportingHash(baseID, "public"),
	})
	return ret
}

const bugListHashPrefix = "list"

func bugListReportingHash(base, name string) string {
	return bugListHashPrefix + bugReportingHash(base, name)
}

func isBugListHash(hash string) bool {
	return strings.HasPrefix(hash, bugListHashPrefix)
}

func reportingBugListReport(c context.Context, subsystemReport *SubsystemReport,
	ns, name, targetReportingType string) (*dashapi.BugListReport, error) {
	for _, stage := range subsystemReport.Stages {
		if !stage.Closed.IsZero() {
			continue
		}
		repConfig := bugListReportingConfig(ns, &stage)
		if repConfig == nil {
			// It might happen if e.g. Moderation was set to nil.
			// Just skip the stage then.
			continue
		}
		if !stage.Reported.IsZero() || repConfig.Type() != targetReportingType {
			break
		}
		configJSON, err := json.Marshal(repConfig)
		if err != nil {
			return nil, err
		}
		ret := &dashapi.BugListReport{
			ID:          stage.ID,
			Created:     subsystemReport.Created,
			Config:      configJSON,
			Link:        fmt.Sprintf("%v/%s/s/%s", appURL(c), ns, name),
			Subsystem:   name,
			Maintainers: subsystemMaintainers(c, ns, name),
			Moderation:  stage.Moderation,
			TotalStats:  subsystemReport.TotalStats.toDashapi(),
			PeriodStats: subsystemReport.PeriodStats.toDashapi(),
			PeriodDays:  config.Namespaces[ns].Subsystems.Reminder.PeriodDays,
		}
		bugKeys, err := subsystemReport.getBugKeys()
		if err != nil {
			return nil, fmt.Errorf("failed to get bug keys: %w", err)
		}
		bugs := make([]*Bug, len(bugKeys))
		err = db.GetMulti(c, bugKeys, bugs)
		if err != nil {
			return nil, fmt.Errorf("failed to get bugs: %w", err)
		}
		for _, bug := range bugs {
			bugReporting := bugReportingByName(bug,
				config.Namespaces[ns].Subsystems.Reminder.SourceReporting)
			ret.Bugs = append(ret.Bugs, dashapi.BugListItem{
				Title:      bug.displayTitle(),
				Link:       fmt.Sprintf("%v/bug?extid=%v", appURL(c), bugReporting.ID),
				ReproLevel: bug.ReproLevel,
				Hits:       bug.NumCrashes,
			})
		}
		return ret, nil
	}
	return nil, nil
}

func bugListReportingConfig(ns string, stage *SubsystemReportStage) ReportingType {
	cfg := config.Namespaces[ns].Subsystems.Reminder
	if stage.Moderation {
		return cfg.ModerationConfig
	}
	return cfg.Config
}

func makeSubsystem(ns, name string) *Subsystem {
	return &Subsystem{
		Namespace: ns,
		Name:      name,
	}
}

func subsystemKey(c context.Context, s *Subsystem) *db.Key {
	return db.NewKey(c, "Subsystem", fmt.Sprintf("%v-%v", s.Namespace, s.Name), 0, nil)
}

func subsystemReportKey(c context.Context, subsystemKey *db.Key, r *SubsystemReport) *db.Key {
	return db.NewKey(c, "SubsystemReport", r.Created.UTC().Format(time.RFC822), 0, subsystemKey)
}

type subsystemsRegistry struct {
	entities map[string]map[string]*Subsystem
}

func makeSubsystemRegistry(c context.Context) (*subsystemsRegistry, error) {
	var subsystems []*Subsystem
	if _, err := db.NewQuery("Subsystem").GetAll(c, &subsystems); err != nil {
		return nil, err
	}
	ret := &subsystemsRegistry{
		entities: map[string]map[string]*Subsystem{},
	}
	for _, item := range subsystems {
		ret.store(item)
	}
	return ret, nil
}

func (sr *subsystemsRegistry) get(ns, name string) *Subsystem {
	ret := sr.entities[ns][name]
	if ret == nil {
		ret = makeSubsystem(ns, name)
	}
	return ret
}

func (sr *subsystemsRegistry) store(item *Subsystem) {
	if sr.entities[item.Namespace] == nil {
		sr.entities[item.Namespace] = map[string]*Subsystem{}
	}
	sr.entities[item.Namespace][item.Name] = item
}

func (sr *subsystemsRegistry) updatePoll(c context.Context, s *Subsystem, success bool) error {
	key := subsystemKey(c, s)
	return db.RunInTransaction(c, func(c context.Context) error {
		dbSubsystem := new(Subsystem)
		err := db.Get(c, key, dbSubsystem)
		if err == db.ErrNoSuchEntity {
			dbSubsystem = s
		} else if err != nil {
			return fmt.Errorf("failed to get Subsystem '%v': %w", key, err)
		}
		dbSubsystem.ListsQueried = timeNow(c)
		if success {
			dbSubsystem.LastBugList = timeNow(c)
		}
		if _, err := db.Put(c, key, dbSubsystem); err != nil {
			return fmt.Errorf("failed to save Subsystem: %w", err)
		}
		sr.store(dbSubsystem)
		return nil
	}, nil)
}

type subsystemReportRegistry struct {
	entities map[string]map[string][]*SubsystemReport
}

func makeSubsystemReportRegistry(c context.Context) (*subsystemReportRegistry, error) {
	var reports []*SubsystemReport
	reportKeys, err := db.NewQuery("SubsystemReport").GetAll(c, &reports)
	if err != nil {
		return nil, err
	}
	var subsystemKeys []*db.Key
	for _, key := range reportKeys {
		subsystemKeys = append(subsystemKeys, key.Parent())
	}
	subsystems := make([]*Subsystem, len(subsystemKeys))
	if err := db.GetMulti(c, subsystemKeys, subsystems); err != nil {
		return nil, fmt.Errorf("failed to query subsystems: %w", err)
	}
	ret := &subsystemReportRegistry{
		entities: map[string]map[string][]*SubsystemReport{},
	}
	for i, item := range reports {
		ret.store(subsystems[i].Namespace, subsystems[i].Name, item)
	}
	return ret, nil
}

func (srr *subsystemReportRegistry) get(ns, name string) []*SubsystemReport {
	return srr.entities[ns][name]
}

func (srr *subsystemReportRegistry) store(ns, name string, item *SubsystemReport) {
	if srr.entities[ns] == nil {
		srr.entities[ns] = map[string][]*SubsystemReport{}
	}
	srr.entities[ns][name] = append(srr.entities[ns][name], item)
}

func storeSubsystemReport(c context.Context, s *Subsystem, report *SubsystemReport) error {
	key := subsystemKey(c, s)
	return db.RunInTransaction(c, func(c context.Context) error {
		// First close all previouly active per-subsystem reports.
		var previous []*SubsystemReport
		prevKeys, err := db.NewQuery("SubsystemReport").
			Ancestor(key).
			Filter("Stages.Closed=", time.Time{}).
			GetAll(c, &previous)
		if err != nil {
			return fmt.Errorf("failed to query old subsystem reports: %w", err)
		}
		for i, subsystem := range previous {
			for i := range subsystem.Stages {
				subsystem.Stages[i].Closed = timeNow(c)
			}
			if _, err := db.Put(c, prevKeys[i], subsystem); err != nil {
				return fmt.Errorf("failed to save SubsystemReport: %w", err)
			}
		}
		// Now save a new one.
		reportKey := subsystemReportKey(c, key, report)
		if _, err := db.Put(c, reportKey, report); err != nil {
			return fmt.Errorf("failed to store new SubsystemReport: %w", err)
		}
		return nil
	}, nil)
}
