// Package lifecycle marks lapsed Free instances and sends the renewal notices.
//
// It sends; it never deletes. Deletion belongs to the control plane, which is
// the only service that knows what an instance is made of. The two are kept
// apart on purpose: a bug here sends a wrong email, a bug there loses data.
package lifecycle

import (
	"context"
	"log"
	"slices"
	"time"

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

// Interval is how often the sweep runs. Hourly is far finer than the daily
// granularity of the notices, which means a notice goes out within an hour of
// becoming due rather than up to a day late.
const Interval = time.Hour

// Notice keys, recorded on the instance so a restart cannot re-send one.
const (
	noticeExpiring = "expiring"
	noticeExpired  = "expired"
	noticeDelete7  = "delete_7"
	noticeDelete1  = "delete_1"
)

// portalURL is the customer portal address used in notice emails.
var portalURL string

// SetPortalURL is called once at boot.
func SetPortalURL(v string) { portalURL = v }

// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails
// can name the real deletion date. Zero means the reaper is off, and the
// deletion notices are then suppressed — promising a deletion that will never
// happen would be a lie, and a scarier one than saying nothing.
var reapAfter time.Duration

// Run performs one sweep: mark lapsed instances, then send whatever notices are
// due. Errors on one instance never stop the others.
func Run(ctx context.Context) error {
	now := time.Now().UTC()

	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
		"deployment": license.DeploymentCloud,
		"tier":       license.TierFree,
		"status":     bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}},
	})
	if err != nil {
		return err
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return err
	}

	for _, inst := range instances {
		var lic models.License
		if err := db.Admin("licenses").FindOne(ctx,
			bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
			continue // no licence yet; nothing to expire
		}

		// Flip active -> lapsed once the licence is past its expiry.
		if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive {
			if _, err := db.Admin("admin_instances").UpdateOne(ctx,
				bson.M{"instance_id": inst.InstanceID},
				bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil {
				log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err)
			}
		}

		if lic.ExpiresAt.Add(reapAfter).Before(now) {
			if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
				log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
			}

			if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
				log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
			}
		}

		due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
		if due == "" {
			continue
		}
		if err := sendNotice(ctx, inst, lic, due); err != nil {
			log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err)
			continue
		}
		if _, err := db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"instance_id": inst.InstanceID},
			bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
			log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err)
		}
	}
	return nil
}

// dueNotice returns the most urgent unsent notice, or "".
//
// Most urgent first, so an instance that was missed for a week — because admin
// was down — sends the one that matters now rather than working through a
// backlog of stale warnings.
func dueNotice(now, expires time.Time, sent []string) string {
	deleteOn := expires.Add(reapAfter)

	if reapAfter > 0 {
		if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) {
			return noticeDelete1
		}
		if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) {
			return noticeDelete7
		}
	}
	if now.After(expires) && !slices.Contains(sent, noticeExpired) {
		return noticeExpired
	}
	if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) {
		return noticeExpiring
	}
	return ""
}

func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error {
	if !mail.Enabled() {
		return nil
	}
	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil {
		return err
	}
	to := acct.BillingEmail
	deleteOn := lic.ExpiresAt.Add(reapAfter)

	switch key {
	case noticeExpiring:
		return mail.Default.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
	case noticeExpired:
		return mail.Default.SendExpired(to, inst.Name, portalURL, deleteOn)
	case noticeDelete7:
		return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
	case noticeDelete1:
		return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
	}
	return nil
}

// Start runs the sweep on a ticker until ctx is cancelled.
//
// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they
// disagree, the emails name a date the reaper does not honour — so they are
// documented as a pair in CLAUDE.md and set together in the compose file.
func Start(ctx context.Context, reapAfterDur time.Duration) {
	reapAfter = reapAfterDur
	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()
	if err := Run(runCtx); err != nil {
		log.Printf("lifecycle: %v", err)
	}
	sweepAwaitingLink(runCtx)
}

// Awaiting-link reminder keys.
const (
	noticeLink24 = "link_24"
	noticeLink72 = "link_72"
)

// sweepAwaitingLink chases self-hosted instances that were paid for but never
// linked: the subscription exists, the instance is still a placeholder. It
// emails a reminder at 24h and again at 72h. The staff dashboard already flags
// 48h; this is the active chasing on top of that. It never issues or deletes.
func sweepAwaitingLink(ctx context.Context) {
	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
		"deployment":  license.DeploymentSelfHosted,
		"placeholder": true,
		"status":      models.StatusAwaitingLink,
	})
	if err != nil {
		return
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return
	}
	now := time.Now().UTC()
	for _, inst := range instances {
		// Only chase placeholders a customer has actually paid for.
		n, err := db.Admin("subscriptions").CountDocuments(ctx,
			bson.M{"instance_id": inst.InstanceID, "status": models.SubActive})
		if err != nil || n == 0 {
			continue
		}
		if !mail.Enabled() {
			continue
		}
		to := accountEmail(ctx, inst.AccountID)
		if to == "" {
			continue
		}
		age := now.Sub(inst.CreatedAt)
		var due string
		if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72) {
			due = noticeLink72
		} else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24) {
			due = noticeLink24
		}
		if due == "" {
			continue
		}
		if err := mail.Default.SendLinkReminder(to, inst.Name); err != nil {
			log.Printf("lifecycle: link reminder %s for %s: %v", due, inst.InstanceID, err)
			continue
		}
		if _, err := db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"instance_id": inst.InstanceID},
			bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
			log.Printf("lifecycle: record link notice %s for %s: %v", due, inst.InstanceID, err)
		}
	}
}

func accountEmail(ctx context.Context, accountID string) string {
	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": accountID}).Decode(&acct); err != nil {
		return ""
	}
	return acct.BillingEmail
}
