package models

import (
	"context"
	"errors"
	"time"

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

// ErrNoEntitlement means the instance has no configuration row.
//
// Callers fall back to the plan's base rather than failing: staff manual
// issuance and any instance predating the backfill legitimately have none.
var ErrNoEntitlement = errors.New("instance has no entitlement")

// Config is one side of an entitlement — a complete statement of what an
// instance is allowed.
//
// Servers is the TOTAL the customer sees, not the number of units billed. The
// billed quantity is Servers minus the plan's base allowance, and it is computed
// where the line items are built rather than stored, so the two can never
// disagree about which of them included the base.
type Config struct {
	Servers  int      `bson:"servers" json:"servers"`
	Features Features `bson:"features" json:"features"`
}

// Entitlement is what one instance's customer configured.
//
// Both the subscription and the licence are derived from it; it is derived from
// nothing. Desired is what they last asked for; Granted is what a payment
// confirmed. A licence is only ever signed from Granted, so an abandoned
// checkout leaves a Desired that reached nothing.
type Entitlement struct {
	ID         bson.ObjectID `bson:"_id,omitempty" json:"-"`
	InstanceID string        `bson:"instance_id" json:"instance_id"`
	AccountID  string        `bson:"account_id" json:"account_id"`
	Deployment string        `bson:"deployment" json:"deployment"`
	Tier       string        `bson:"tier" json:"tier"`
	Term       string        `bson:"term" json:"term"`

	Desired Config `bson:"desired" json:"desired"`
	Granted Config `bson:"granted" json:"granted"`

	// ResolvedLimits is BaseLimits with Granted folded in. It is stored rather
	// than derived on read so the fold lives in exactly one place — deriving it
	// at every read would put the arithmetic in the issuer, the portal and the
	// staff console.
	ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"`

	// ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only
	// when Desired grants less than Granted, and it is what lets the portal say
	// "drops to 5 on 12 August" instead of guessing.
	ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"`

	GrantedAt time.Time `bson:"granted_at" json:"granted_at"`
	UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}

// Pending reports whether Desired and Granted disagree.
func (e Entitlement) Pending() bool {
	if e.Desired.Servers != e.Granted.Servers {
		return true
	}
	if len(e.Desired.Features) != len(e.Granted.Features) {
		return true
	}
	have := map[string]bool{}
	for _, f := range e.Granted.Features {
		have[f] = true
	}
	for _, f := range e.Desired.Features {
		if !have[f] {
			return true
		}
	}
	return false
}

// GetEntitlement reads one instance's configuration.
func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) {
	var e Entitlement
	err := db.Admin("entitlements").FindOne(ctx,
		bson.M{"instance_id": instanceID}).Decode(&e)
	if errors.Is(err, mongo.ErrNoDocuments) {
		return nil, ErrNoEntitlement
	}
	if err != nil {
		return nil, err
	}
	return &e, nil
}

// UpsertEntitlement writes an entitlement, creating it if absent.
//
// GrantedAt is only touched when Granted actually changes, which is what makes
// it answer "since when has this instance been allowed this" rather than "when
// was this row last written".
func UpsertEntitlement(ctx context.Context, e Entitlement) error {
	now := time.Now().UTC()
	set := bson.M{
		"account_id":      e.AccountID,
		"deployment":      e.Deployment,
		"tier":            e.Tier,
		"term":            e.Term,
		"desired":         e.Desired,
		"granted":         e.Granted,
		"resolved_limits": e.ResolvedLimits,
		"updated_at":      now,
	}
	if e.ScheduledChangeAt != nil {
		set["scheduled_change_at"] = *e.ScheduledChangeAt
	}
	if !e.GrantedAt.IsZero() {
		set["granted_at"] = e.GrantedAt
	} else {
		set["granted_at"] = now
	}
	update := bson.M{"$set": set}
	if e.ScheduledChangeAt == nil {
		update["$unset"] = bson.M{"scheduled_change_at": ""}
	}
	_, err := db.Admin("entitlements").UpdateOne(ctx,
		bson.M{"instance_id": e.InstanceID},
		mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}),
		options.UpdateOne().SetUpsert(true))
	return err
}

// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one.
func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M {
	update["$setOnInsert"] = onInsert
	return update
}
