package licensing

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

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

var (
	ErrBadUUID       = errors.New("that does not look like an instance ID")
	ErrAlreadyLinked = errors.New("that instance ID is already linked to an account")
	ErrRelinkLimit   = errors.New("relink limit reached for this term; contact support")
)

// LinkInstance attaches a self-hosted instance UUID to an account.
//
// The duplicate error deliberately does not say WHICH account holds it. It is a
// small enumeration surface, but there is no reason to leave it open.
func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) {
	if _, err := uuid.Parse(instanceID); err != nil {
		return nil, ErrBadUUID
	}

	// A self-hosted UUID must not collide with a cloud instance either.
	if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 {
		return nil, ErrAlreadyLinked
	}

	inst := models.Instance{
		InstanceID: instanceID,
		AccountID:  accountID,
		Name:       name,
		Deployment: license.DeploymentSelfHosted,
		Status:     models.StatusActive,
		CreatedAt:  time.Now().UTC(),
	}
	if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
		if mongo.IsDuplicateKeyError(err) {
			// The unique index is what actually prevents two accounts owning
			// one instance. The check above is a nicety; this is the guarantee.
			return nil, ErrAlreadyLinked
		}
		return nil, err
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID})
	return &inst, nil
}

// RepointSubscriptions follows an instance identity rewrite: it moves every
// subscription row from the old id to the new one, then rewrites Paddle's copy
// of custom_data so future webhooks decode to the new id.
//
// The local rewrite is returned as an error — issuance reads the subscription
// back, so a half-moved row is worth failing on. The Paddle patch only logs: the
// customer must not be blocked from linking or relinking by an outbound API
// failure, and the caller has already recorded the old id in
// previous_instance_ids, which is what makes the webhook path correct whether or
// not the patch lands.
func RepointSubscriptions(ctx context.Context, oldID, newID, accountID string) error {
	if _, err := db.Admin("subscriptions").UpdateMany(ctx,
		bson.M{"instance_id": oldID},
		bson.M{"$set": bson.M{"instance_id": newID}}); err != nil {
		return fmt.Errorf("repoint %s -> %s: %w", oldID, newID, err)
	}

	cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": newID})
	if err != nil {
		log.Printf("repoint %s -> %s: read subscriptions: %v", oldID, newID, err)
		return nil
	}
	var subs []models.Subscription
	if err := cur.All(ctx, &subs); err != nil {
		log.Printf("repoint %s -> %s: decode subscriptions: %v", oldID, newID, err)
		return nil
	}
	for _, s := range subs {
		if s.PaddleSubscriptionID == "" {
			continue
		}
		// Paddle replaces the whole custom_data object on a PATCH, so account_id
		// is sent alongside rather than dropped.
		if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID,
			map[string]string{"account_id": accountID, "instance_id": newID}); err != nil {
			log.Printf("repoint %s -> %s: patch custom_data on %s: %v",
				oldID, newID, s.PaddleSubscriptionID, err)
		}
	}
	return nil
}

// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
// a way to extend a subscription.
//
// The old licence is not revoked, because offline verification has no
// revocation. It simply no longer matches any UUID the customer controls, and
// its binding stops it working on another machine anyway.
func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) {
	if _, err := uuid.Parse(newID); err != nil {
		return nil, ErrBadUUID
	}

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil {
		return nil, ErrUnknownInstance
	}

	// The cap is a signal, not a defence. Its job is to put a human in front of
	// the fourth attempt, so staff bypass it.
	if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm {
		return nil, ErrRelinkLimit
	}

	if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 {
		return nil, ErrAlreadyLinked
	}

	// Preserve the remaining term from the current licence.
	remaining := time.Now().UTC().Add(models.GracePeriod)
	var current models.License
	if err := db.Admin("licenses").FindOne(ctx,
		bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err == nil {
		remaining = current.ExpiresAt
	}

	if _, err := db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": oldID},
		bson.M{
			"$set":      bson.M{"instance_id": newID},
			"$inc":      bson.M{"relink_count": 1},
			"$addToSet": bson.M{"previous_instance_ids": oldID},
		}); err != nil {
		if mongo.IsDuplicateKeyError(err) {
			return nil, ErrAlreadyLinked
		}
		return nil, fmt.Errorf("relink: %w", err)
	}

	// A relink rewrites the instance's identity exactly as a placeholder claim
	// does, so the same two things have to follow it: the subscription rows that
	// named the old id, and Paddle's own copy of custom_data. Without this a
	// renewal after a relink cannot find its instance and the term never extends.
	if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil {
		return nil, err
	}

	actor := accountID
	if staff {
		actor = "staff"
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: actor, Action: "instance.relinked", AccountID: accountID,
		Target: newID, Detail: "was " + oldID})

	return Issue(ctx, IssueInput{
		InstanceID: newID,
		Tier:       inst.Tier,
		ExpiresAt:  remaining,
		Reason:     models.ReasonRelink,
		IssuedBy:   actor,
	})
}
