// Package hqsync keeps projected control-plane users consistent with the HQ
// people they were projected from.
//
// It is separate from inject on purpose. inject writes exactly three licence
// fields on `instances` and that narrowness is the reason admin's reach into
// the control plane is reviewable at all; a password repair pass bolted onto it
// would quietly turn it into "the package that writes whatever admin wants".
// This one goes through cloudprov, which is the sanctioned user write path.
package hqsync

import (
	"context"
	"log"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// Interval matches inject's reconciler. Fifteen minutes is the worst-case
// staleness a password change can suffer, which the spec accepts as
// recoverable.
const Interval = 15 * time.Minute

// Reconcile compares every projected user's stored hash against the HQ hash it
// came from, and repairs mismatches.
//
// The comparison is on the hash string, not the password: two bcrypt hashes of
// one password differ by salt, so this repairs by COPYING HQ's hash rather than
// re-hashing. That is also why propagation copies rather than re-derives.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
	cur, err := db.Admin("customer_users").Find(ctx,
		bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
	if err != nil {
		return 0, 0, err
	}
	var people []models.CustomerUser
	if err := cur.All(ctx, &people); err != nil {
		return 0, 0, err
	}

	for _, p := range people {
		projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
		if err != nil {
			log.Printf("hqsync: read projections of %s: %v", p.Email, err)
			continue
		}
		stale := false
		for _, u := range projected {
			checked++
			if u.PasswordHash != p.PasswordHash {
				stale = true
			}
		}
		if !stale {
			// Clear a stale failure flag: the instances agree, whatever the
			// flag says. Nothing reads the flag to decide what to repair.
			if p.HQSyncFailedAt != nil {
				_, _ = db.Admin("customer_users").UpdateOne(ctx,
					bson.M{"user_id": p.UserID},
					bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
			}
			continue
		}

		n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
		if err != nil {
			log.Printf("hqsync: repair %s: %v", p.Email, err)
			continue
		}
		repaired += int(n)
		log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
		_, _ = db.Admin("customer_users").UpdateOne(ctx,
			bson.M{"user_id": p.UserID},
			bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
	}
	return checked, repaired, nil
}

// Start runs once at boot, then on a ticker until ctx is cancelled.
//
// The boot pass is for the same reason inject's is: the likeliest moment for a
// half-applied write is a deploy or a crash, and waiting a full interval to
// notice means a customer's new password does not work somewhere for fifteen
// minutes after we already know how to fix it.
func Start(ctx context.Context) {
	go func() {
		runOnce(ctx)
		t := time.NewTicker(Interval)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				runOnce(ctx)
			}
		}
	}()
}

func runOnce(ctx context.Context) {
	runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
	defer cancel()

	checked, repaired, err := Reconcile(runCtx)
	if err != nil {
		log.Printf("hqsync: %v", err)
		return
	}
	if repaired > 0 {
		log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
	}
}
