package agentsync

import (
	"context"
	"log"
	"runtime"
	"sync"
	"sync/atomic"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
	grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
	"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
	"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
)

// collectPackagesFlag is written by the 30s key poll and read by the hourly
// package loop — two different goroutines, hence the atomic.
//
// It defaults to false, so an agent that has not yet completed a poll, or is
// talking to a server too old to send the field, collects nothing. Off is the
// safe default: collecting without a licence costs the customer storage they
// are not paying for.
var collectPackagesFlag atomic.Bool

// firstPoll closes once a SyncKeys response has set the flag above.
//
// Without it the boot-time package report loses a race it can only lose: the
// hourly loop starts before the first poll, reads a flag that is still false by
// construction, and skips — so a freshly installed agent reports no packages for
// an hour and the server shows nothing to scan.
// How long the boot package report waits for that first poll. Two poll
// intervals plus slack: long enough to cover one failed attempt, short enough
// that a dead control plane does not hold the OS-update report hostage.
const firstPollWait = 90 * time.Second

var (
	firstPoll     = make(chan struct{})
	firstPollOnce sync.Once
)

func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }

// waitFirstPoll blocks until the flag is known, or gives up. The wait is
// bounded because this loop also reports OS updates, which do not depend on the
// flag at all — a control plane that cannot be polled must not silence those too.
func waitFirstPoll(ctx context.Context, limit time.Duration) {
	t := time.NewTimer(limit)
	defer t.Stop()
	select {
	case <-firstPoll:
	case <-t.C:
		log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
	case <-ctx.Done():
	}
}

func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }

// reportPackages offers a hash of the installed package set and sends the full
// list only if the server does not already hold it.
//
// It runs on the same hourly cadence as the update check because a package set
// changes on roughly the same schedule, and reusing that loop means one timer
// rather than two.
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
	if runtime.GOOS != "linux" {
		return
	}
	if !collectPackagesEnabled() {
		return
	}

	osrel, pkgs, err := packages.Collect()
	if err != nil {
		log.Printf("package collection error: %v", err)
		return
	}

	pbOS := pb.OSRelease{
		Family:    osrel.Family,
		VersionId: osrel.VersionID,
		Arch:      osrel.Arch,
	}
	hash := packages.Hash(pkgs)

	// The offer: hash only, no body. On an unchanged host this is the whole
	// exchange, which is the point of the handshake.
	needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
		ServerId:   cfg.ServerID,
		AgentToken: cfg.AgentToken,
		Hash:       hash,
		Os:         pbOS,
	})
	if err != nil {
		log.Printf("ReportPackages offer error: %v", err)
		return
	}
	if !needFull {
		return
	}

	pbPkgs := make([]pb.InstalledPackage, len(pkgs))
	for i, p := range pkgs {
		pbPkgs[i] = pb.InstalledPackage{
			Name:       p.Name,
			Version:    p.Version,
			Epoch:      int32(p.Epoch),
			Arch:       p.Arch,
			SourceName: p.SourceName,
		}
	}

	if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
		ServerId:   cfg.ServerID,
		AgentToken: cfg.AgentToken,
		Hash:       hash,
		Os:         pbOS,
		Packages:   pbPkgs,
	}); err != nil {
		log.Printf("ReportPackages full error: %v", err)
		return
	}
	log.Printf("reported %d installed packages", len(pkgs))
}
