package services

import (
	"context"
	"fmt"
	"log"
	"sort"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

// digestRowLimit caps how many findings a single digest lists by name. The
// remainder is summarised as a count: a webhook payload holding six hundred
// rows is not a notification, it is a report nobody reads in a chat client.
const digestRowLimit = 20

// ErrVulnRuleNotFound is returned for a rule that does not exist in this
// instance. Callers turn it into a 404.
var ErrVulnRuleNotFound = fmt.Errorf("vulnerability alert rule not found")

func ListVulnRules(instanceID string) ([]models.VulnAlertRule, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	cur, err := db.Col("vuln_alert_rules").Find(ctx, bson.M{"instance_id": instanceID})
	if err != nil {
		return nil, err
	}
	defer cur.Close(ctx)

	rules := []models.VulnAlertRule{}
	if err := cur.All(ctx, &rules); err != nil {
		return nil, err
	}
	return rules, nil
}

func CreateVulnRule(instanceID string, r *models.VulnAlertRule) (*models.VulnAlertRule, error) {
	if err := validateVulnRule(instanceID, r); err != nil {
		return nil, err
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	r.ID = bson.NewObjectID()
	r.InstanceID = instanceID
	r.CreatedAt = time.Now()
	r.UpdatedAt = r.CreatedAt

	if _, err := db.Col("vuln_alert_rules").InsertOne(ctx, r); err != nil {
		return nil, err
	}
	return r, nil
}

func UpdateVulnRule(instanceID, ruleID string, r *models.VulnAlertRule) error {
	if err := validateVulnRule(instanceID, r); err != nil {
		return err
	}

	id, err := bson.ObjectIDFromHex(ruleID)
	if err != nil {
		return ErrVulnRuleNotFound
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	res, err := db.Col("vuln_alert_rules").UpdateOne(ctx,
		bson.M{"_id": id, "instance_id": instanceID},
		bson.M{"$set": bson.M{
			"name":         r.Name,
			"enabled":      r.Enabled,
			"min_severity": r.MinSeverity,
			"tags":         r.Tags,
			"channel_ids":  r.ChannelIDs,
			"updated_at":   time.Now(),
		}},
	)
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return ErrVulnRuleNotFound
	}
	return nil
}

func DeleteVulnRule(instanceID, ruleID string) error {
	id, err := bson.ObjectIDFromHex(ruleID)
	if err != nil {
		return ErrVulnRuleNotFound
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	res, err := db.Col("vuln_alert_rules").DeleteOne(ctx, bson.M{"_id": id, "instance_id": instanceID})
	if err != nil {
		return err
	}
	if res.DeletedCount == 0 {
		return ErrVulnRuleNotFound
	}
	return nil
}

// validateVulnRule rejects a rule that could never fire, and one naming a
// channel from another instance. The channel check reuses validateChannelIDs so
// there is one answer to "is this channel mine".
func validateVulnRule(instanceID string, r *models.VulnAlertRule) error {
	if r.Name == "" {
		return fmt.Errorf("name is required")
	}
	switch r.MinSeverity {
	case models.SeverityUnknown, models.SeverityLow, models.SeverityMedium,
		models.SeverityHigh, models.SeverityCritical:
	default:
		return fmt.Errorf("min_severity %q is not a severity", r.MinSeverity)
	}
	if len(r.ChannelIDs) == 0 {
		return fmt.Errorf("at least one channel is required")
	}
	return validateChannelIDs(instanceID, r.ChannelIDs)
}

// SendVulnDigest delivers one message per rule per tick — never one per
// finding. See vulnsched for why the tick is the batch boundary.
func SendVulnDigest(instanceID string, newly []models.VulnFinding) {
	rules, err := ListVulnRules(instanceID)
	if err != nil {
		log.Printf("vuln digest: list rules: %v", err)
		return
	}

	for _, rule := range rules {
		if !rule.Enabled {
			continue
		}

		matched := filterBySeverity(newly, rule.MinSeverity)
		if len(matched) == 0 {
			continue
		}

		if len(rule.Tags) > 0 {
			// ResolveTargets is already the single answer to which servers a
			// selector touches. A rule that disagreed with a workflow about
			// what env:prod means would be worse than no filter at all.
			allowed, err := ResolveTargets(instanceID, nil, rule.Tags)
			if err != nil {
				log.Printf("vuln digest: resolve targets: %v", err)
				continue
			}
			matched = filterByServers(matched, allowed)
			if len(matched) == 0 {
				continue
			}
		}

		dispatchVulnDigest(instanceID, rule, matched)
	}
}

func filterBySeverity(findings []models.VulnFinding, min string) []models.VulnFinding {
	floor := models.SeverityRank(min)
	out := make([]models.VulnFinding, 0, len(findings))
	for _, f := range findings {
		if models.SeverityRank(f.Severity) >= floor {
			out = append(out, f)
		}
	}
	return out
}

// filterByServers keeps findings on servers the rule's tag selector matched.
// ResolveTargets answers in whole server documents, so the IDs are lifted here.
func filterByServers(findings []models.VulnFinding, allowed []models.Server) []models.VulnFinding {
	set := make(map[string]bool, len(allowed))
	for _, s := range allowed {
		set[s.ServerID] = true
	}
	out := make([]models.VulnFinding, 0, len(findings))
	for _, f := range findings {
		if set[f.ServerID] {
			out = append(out, f)
		}
	}
	return out
}

// dispatchVulnDigest builds one digest and sends it over each of the rule's
// channels.
func dispatchVulnDigest(instanceID string, rule models.VulnAlertRule, findings []models.VulnFinding) {
	channels, err := GetChannels(instanceID, rule.ChannelIDs)
	if err != nil {
		log.Printf("vuln digest: load channels for rule %s: %v", rule.Name, err)
		return
	}

	digest := buildVulnDigest(instanceID, rule, findings)

	for _, ch := range channels {
		if !ch.Enabled {
			continue
		}
		go func(c models.NotificationChannel) {
			if err := notify.DispatchVulnDigest(c, digest); err != nil {
				log.Printf("vuln digest: dispatch to %s (%s): %v", c.Name, c.Type, err)
			}
		}(ch)
	}
}

// buildVulnDigest turns a batch of findings into one message.
//
// Findings are ordered most severe first so the capped list shows the ones that
// matter rather than whichever the scan happened to produce first.
func buildVulnDigest(instanceID string, rule models.VulnAlertRule, findings []models.VulnFinding) notify.VulnDigest {
	sorted := make([]models.VulnFinding, len(findings))
	copy(sorted, findings)
	sort.SliceStable(sorted, func(i, j int) bool {
		return models.SeverityRank(sorted[i].Severity) > models.SeverityRank(sorted[j].Severity)
	})

	counts := map[string]int{}
	servers := map[string]bool{}
	for _, f := range sorted {
		counts[f.Severity]++
		servers[f.ServerID] = true
	}

	names := serverNames(instanceID)

	shown := sorted
	more := 0
	if len(shown) > digestRowLimit {
		more = len(shown) - digestRowLimit
		shown = shown[:digestRowLimit]
	}

	rows := make([]mail.VulnDigestRow, 0, len(shown))
	for _, f := range shown {
		name := names[f.ServerID]
		if name == "" {
			name = f.ServerID
		}
		rows = append(rows, mail.VulnDigestRow{
			CVEID:       f.CVEID,
			Severity:    f.Severity,
			PackageName: f.PackageName,
			ServerName:  name,
			FixedIn:     f.FixedIn,
		})
	}

	top := models.SeverityUnknown
	if len(sorted) > 0 {
		top = sorted[0].Severity
	}

	instanceName := instanceID
	if inst, err := GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
		instanceName = inst.Name
	}

	return notify.VulnDigest{
		InstanceName: instanceName,
		RuleName:     rule.Name,
		Summary:      summariseCounts(counts, len(servers)),
		TopSeverity:  top,
		Count:        len(sorted),
		Rows:         rows,
		More:         more,
		DBAge:        vulnDBAge(),
	}
}

// summariseCounts renders "12 new critical, 4 new high across 6 servers".
func summariseCounts(counts map[string]int, serverCount int) string {
	order := []string{
		models.SeverityCritical, models.SeverityHigh,
		models.SeverityMedium, models.SeverityLow, models.SeverityUnknown,
	}
	parts := ""
	for _, sev := range order {
		if counts[sev] == 0 {
			continue
		}
		if parts != "" {
			parts += ", "
		}
		parts += fmt.Sprintf("%d new %s", counts[sev], sev)
	}
	if parts == "" {
		parts = "new findings"
	}
	plural := "servers"
	if serverCount == 1 {
		plural = "server"
	}
	return fmt.Sprintf("%s across %d %s", parts, serverCount, plural)
}

// serverNames maps server IDs to display names for one instance. A digest that
// named raw UUIDs would be unreadable in a chat client.
func serverNames(instanceID string) map[string]string {
	out := map[string]string{}
	servers, err := ListServers(instanceID)
	if err != nil {
		log.Printf("vuln digest: list servers: %v", err)
		return out
	}
	for _, s := range servers {
		out[s.ServerID] = s.Hostname
	}
	return out
}

// vulnDBAge renders how long ago the vulnerability database was pulled.
//
// It is on every digest deliberately: a fleet scanned against a three-week-old
// database must say so rather than let the reader assume freshness.
func vulnDBAge() string {
	meta, err := GetVulnDBMeta()
	if err != nil || meta == nil || meta.PulledAt.IsZero() {
		return "an unknown time"
	}
	d := time.Since(meta.PulledAt)
	switch {
	case d < time.Hour:
		return fmt.Sprintf("%d minutes", int(d.Minutes()))
	case d < 48*time.Hour:
		return fmt.Sprintf("%d hours", int(d.Hours()))
	default:
		return fmt.Sprintf("%d days", int(d.Hours()/24))
	}
}

// GetVulnDBMeta reads the deployment-wide vulnerability database metadata.
// It carries no instance_id: the database is a property of the deployment, not
// of a tenant.
func GetVulnDBMeta() (*models.VulnDBMeta, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	var meta models.VulnDBMeta
	err := db.Col("vulndb_meta").FindOne(ctx, bson.M{}).Decode(&meta)
	if err == mongo.ErrNoDocuments {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &meta, nil
}
