// Package vulnsched owns the vulnerability scan loop.
//
// It runs inside bus.RunAsLeader("housekeeping", …) alongside monitorsched,
// workflowsched and the sweepers: one role, one lock. N replicas each running
// this loop would mean N copies of the ~50MB database resident, N rescans of
// the same fleet on every database refresh, and N digests reaching the
// customer for one set of findings.
package vulnsched

import (
	"context"
	"errors"
	"log"
	"os"
	"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/services"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulndb"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

const (
	tickInterval = 60 * time.Second
	// trivy-db is rebuilt every six hours; pulling more often buys nothing.
	dbMaxAge = 6 * time.Hour
)

// Deps are injected from main.go rather than imported, following
// workflowsched. It keeps this package's reach explicit and reviewable.
type Deps struct {
	LogEvent   func(instanceID, eventType, actor, serverID, keyID, details string)
	SendDigest func(instanceID string, newly []models.VulnFinding)
}

type scheduler struct {
	deps    Deps
	dir     string
	store   *vulndb.Store
	version int
	pulled  time.Time
}

func Start(ctx context.Context, deps Deps) {
	if vulndb.Disabled() {
		log.Println("vulnsched: disabled by VANTAGE_VULNDB_DISABLED")
		return
	}

	dir, err := os.MkdirTemp("", "vantage-vulndb-")
	if err != nil {
		// The classic form of this is "stat /tmp: no such file or directory" on
		// the scratch runtime image. It is logged once at boot while everything
		// else runs normally, so the only other symptom is a fleet that never
		// reports a finding.
		log.Printf("vulnsched: temp dir: %v (scan loop NOT started)", err)
		return
	}

	s := &scheduler{deps: deps, dir: dir}
	log.Printf("vulnsched: started, ref=%s tick=%s dir=%s debug=%t",
		vulndb.Ref(), tickInterval, dir, vulndb.DebugEnabled())

	go func() {
		defer os.RemoveAll(dir)
		defer s.closeStore()

		ticker := time.NewTicker(tickInterval)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				log.Println("vulnsched: leadership lost or shutting down, scan loop stopping")
				return
			case <-ticker.C:
				s.tick(ctx)
			}
		}
	}()
}

func (s *scheduler) tick(ctx context.Context) {
	started := time.Now()
	vulndb.Debugf("vulnsched tick starting (store loaded=%t, db version=%d, pulled %s ago)",
		s.store != nil, s.version, time.Since(s.pulled).Round(time.Second))

	if err := s.ensureDB(ctx); err != nil {
		// Keep the last good database and carry on scanning against it. A
		// network blip must never clear findings or read as "all fixed".
		log.Printf("vulnsched: database unavailable: %v", err)
		s.recordDBError(ctx, err)
		if s.store == nil {
			log.Println("vulnsched: no database loaded at all, nothing can be scanned this tick")
			return
		}
	}
	s.scanPending(ctx)
	vulndb.Debugf("vulnsched tick finished in %s", time.Since(started).Round(time.Millisecond))
}

// ensureDB pulls a fresh database when the local copy is stale, and marks the
// whole fleet for rescanning when the version changes — which is what makes a
// newly published CVE flag existing servers within a minute rather than at the
// next agent report.
func (s *scheduler) ensureDB(ctx context.Context) error {
	if s.store != nil && time.Since(s.pulled) < dbMaxAge {
		vulndb.Debugf("database is %s old, under the %s limit; not pulling",
			time.Since(s.pulled).Round(time.Second), dbMaxAge)
		return nil
	}
	log.Printf("vulnsched: pulling database (age %s, max %s)", time.Since(s.pulled).Round(time.Second), dbMaxAge)

	version, err := vulndb.Pull(ctx, s.dir)
	if err != nil {
		return err
	}

	s.closeStore()
	store, err := vulndb.Open(s.dir)
	if err != nil {
		return err
	}
	s.store = store
	s.pulled = time.Now()

	changed := version != s.version
	log.Printf("vulnsched: database ready, schema %d (previous %d, changed=%t)", version, s.version, changed)
	s.version = version

	_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
		bson.M{"$set": bson.M{"db_version": version, "pulled_at": s.pulled}, "$unset": bson.M{"last_error": ""}},
		options.UpdateOne().SetUpsert(true),
	)

	if changed {
		res, err := db.Col("server_packages").UpdateMany(ctx,
			bson.M{"status": bson.M{"$ne": models.ScanStatusUnsupported}},
			bson.M{"$set": bson.M{"scan_pending": true}},
		)
		if err != nil {
			log.Printf("vulnsched: mark fleet pending: %v", err)
		} else {
			log.Printf("vulnsched: database version %d, %d servers marked for rescan", version, res.ModifiedCount)
		}
	}
	return nil
}

func (s *scheduler) recordDBError(ctx context.Context, err error) {
	_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
		bson.M{"$set": bson.M{"last_error": err.Error()}},
		options.UpdateOne().SetUpsert(true),
	)
}

func (s *scheduler) scanPending(ctx context.Context) {
	cur, err := db.Col("server_packages").Find(ctx, bson.M{"scan_pending": true})
	if err != nil {
		log.Printf("vulnsched: find pending: %v", err)
		return
	}
	defer cur.Close(ctx)

	var pending []models.ServerPackages
	if err := cur.All(ctx, &pending); err != nil {
		log.Printf("vulnsched: decode pending: %v", err)
		return
	}

	if len(pending) == 0 {
		vulndb.Debugf("no servers pending scan")
		return
	}
	log.Printf("vulnsched: %d server(s) pending scan", len(pending))

	// Newly opened findings are collected across the whole tick and sent as one
	// digest per instance. A database refresh can open several hundred findings
	// at once; one message per finding would rate-limit the webhook or get the
	// channel muted, and either way the alerts stop being read.
	newly := map[string][]models.VulnFinding{}

	// One metadata cache for the whole tick: a CVE affects every host running
	// the package, and the lookup is the same read each time.
	meta := vulndb.NewMetaCache(s.store)

	for _, sp := range pending {
		if ctx.Err() != nil {
			// Leadership lost. scan_pending is still set, so the next leader
			// picks these up — which is why it lives on the document.
			return
		}
		opened := s.scanOne(ctx, sp, meta)
		newly[sp.InstanceID] = append(newly[sp.InstanceID], opened...)
	}

	for instanceID, findings := range newly {
		if len(findings) > 0 && s.deps.SendDigest != nil {
			s.deps.SendDigest(instanceID, findings)
		}
	}

	_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
		bson.M{"$set": bson.M{"last_full_scan_at": time.Now()}},
		options.UpdateOne().SetUpsert(true),
	)
}

func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages, meta *vulndb.MetaCache) []models.VulnFinding {
	now := time.Now()
	log.Printf("vulnsched: scanning server %s (instance %s, os %s %s, %d packages)",
		sp.ServerID, sp.InstanceID, sp.OS.Family, sp.OS.VersionID, len(sp.Packages))

	results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
	if err != nil {
		// We hold no feed for this distribution, so we cannot answer whether it
		// is vulnerable. Say "unsupported" — reporting zero findings here would
		// be indistinguishable from reporting a clean host, and one of those is
		// a lie.
		status := models.ScanStatusUnsupported
		if !errors.Is(err, vulndb.ErrUnsupportedFamily) {
			log.Printf("vulnsched: scan %s: %v", sp.ServerID, err)
			status = sp.Status
		} else {
			log.Printf("vulnsched: server %s marked unsupported: no feed for %s %s",
				sp.ServerID, sp.OS.Family, sp.OS.VersionID)
		}
		s.clearPending(ctx, sp.ID, status, now)
		return nil
	}

	// Severity for the Debian-family buckets lives on the CVE, not the
	// advisory. Without this every finding is stored as "unknown", which also
	// silences every alert rule with a minimum severity.
	meta.Enrich(results)

	existing, err := services.ListFindings(ctx, sp.InstanceID, sp.ServerID)
	if err != nil {
		log.Printf("vulnsched: list findings %s: %v", sp.ServerID, err)
		return nil
	}

	diff := services.DiffFindings(existing, results, now)
	if err := services.ApplyFindingDiff(ctx, sp.InstanceID, sp.ServerID, diff, now); err != nil {
		log.Printf("vulnsched: apply diff %s: %v", sp.ServerID, err)
		return nil
	}

	log.Printf("vulnsched: server %s scanned: %d matches, %d existing, %d upserts, %d newly opened, %d reopened, %d fixed",
		sp.ServerID, len(results), len(existing), len(diff.Upserts),
		len(diff.NewlyOpened), len(diff.ReopenIDs), len(diff.FixedIDs))

	s.clearPending(ctx, sp.ID, models.ScanStatusOK, now)

	for i := range diff.NewlyOpened {
		diff.NewlyOpened[i].ServerID = sp.ServerID
	}
	return diff.NewlyOpened
}

func (s *scheduler) clearPending(ctx context.Context, id bson.ObjectID, status string, now time.Time) {
	_, _ = db.Col("server_packages").UpdateOne(ctx,
		bson.M{"_id": id},
		bson.M{"$set": bson.M{
			"scan_pending": false,
			"status":       status,
			"scanned_at":   now,
			"db_version":   s.version,
		}},
	)
}

func (s *scheduler) closeStore() {
	if s.store != nil {
		_ = s.store.Close()
		s.store = nil
	}
}
