// Package inject writes licences onto control-plane instance documents.
//
// This is admin's ONLY write path into the control plane, and it touches exactly
// three fields on one collection. If this package ever grows a second write
// target, that is a design change and not a refactor.
package inject

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

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

// ReconcileInterval is how often every cloud instance is compared against what
// admin believes it should hold.
//
// This job, not the issuance path, is what guarantees eventual consistency.
// Injection at issue time is best-effort; this is the backstop.
const ReconcileInterval = 15 * time.Minute

// Cloud writes the licence onto the control-plane instance document.
//
// Idempotent and safe to re-run: it is a single UpdateOne of three fields with
// no read-modify-write. Retries three times with backoff.
//
// The control plane caches licence state for 60 seconds, so this takes effect
// within a minute with no restart.
func Cloud(ctx context.Context, lic *models.License) error {
	set := bson.M{"$set": bson.M{
		"license_blob":   lic.Blob,
		"license_tier":   lic.Tier,
		"license_expiry": lic.ExpiresAt,
	}}

	var lastErr error
	for attempt := 1; attempt <= 3; attempt++ {
		res, err := db.Control("instances").UpdateOne(ctx,
			bson.M{"instance_id": lic.InstanceID}, set)
		if err == nil {
			if res.MatchedCount == 0 {
				return fmt.Errorf("no control-plane instance %s", lic.InstanceID)
			}
			return nil
		}
		lastErr = err
		time.Sleep(time.Duration(attempt) * 2 * time.Second)
	}
	return fmt.Errorf("inject after 3 attempts: %w", lastErr)
}

// Deliver injects and records the outcome without ever failing the caller.
//
// A licence that is recorded but not injected is recoverable — the reconciler
// will fix it within 15 minutes, and staff can see it on the health endpoint.
// Failing the purchase because one write failed would be worse.
func Deliver(ctx context.Context, lic *models.License) {
	if err := Cloud(ctx, lic); err != nil {
		log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err)
		now := time.Now().UTC()
		_, _ = db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"instance_id": lic.InstanceID},
			bson.M{"$set": bson.M{"inject_failed_at": now}})
		return
	}
	_, _ = db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": lic.InstanceID},
		bson.M{"$unset": bson.M{"inject_failed_at": ""}})
}

// Reconcile compares every active cloud instance's current licence against the
// blob actually stored in the control plane, and re-injects on mismatch.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
		"deployment":      license.DeploymentCloud,
		"status":          models.StatusActive,
		"current_license": bson.M{"$ne": ""},
	})
	if err != nil {
		return 0, 0, err
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return 0, 0, err
	}

	for _, inst := range instances {
		checked++

		var lic models.License
		if err := db.Admin("licenses").FindOne(ctx,
			bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
			log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense)
			continue
		}

		var remote sharedmodels.Instance
		if err := db.Control("instances").FindOne(ctx,
			bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
			// The control plane's reaper deletes lapsed Free instances. Record
			// that here rather than re-logging it every fifteen minutes forever,
			// and so the lifecycle sweep stops emailing about it.
			if errors.Is(err, mongo.ErrNoDocuments) {
				if _, uErr := db.Admin("admin_instances").UpdateOne(ctx,
					bson.M{"instance_id": inst.InstanceID},
					bson.M{"$set": bson.M{"status": models.StatusDeleted}}); uErr != nil {
					log.Printf("reconcile: mark %s deleted: %v", inst.InstanceID, uErr)
				} else {
					log.Printf("reconcile: instance %s is gone from the control plane; marked deleted", inst.InstanceID)
				}
				continue
			}
			log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
			continue
		}

		if remote.LicenseBlob == lic.Blob {
			continue
		}

		log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID)
		if err := Cloud(ctx, &lic); err != nil {
			log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err)
			continue
		}
		repaired++
		_, _ = db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"instance_id": inst.InstanceID},
			bson.M{"$unset": bson.M{"inject_failed_at": ""}})
	}
	return checked, repaired, nil
}

// StartReconciler reconciles once at boot, then on a ticker until ctx is
// cancelled.
//
// The pass at boot matters: injection failures are most likely around a deploy
// or a crash, and waiting a full interval to notice would leave a paying
// customer read-only for that long. It also means a restart is a supported way
// to force reconciliation.
func StartReconciler(ctx context.Context) {
	go func() {
		runOnce(ctx)

		t := time.NewTicker(ReconcileInterval)
		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("reconcile: %v", err)
		return
	}
	if repaired > 0 {
		log.Printf("reconcile: checked %d, repaired %d", checked, repaired)
	}
}
