package api

import (
	"fmt"
	"net/http"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
	"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/gin-gonic/gin"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// checkoutOptions serves everything the browser configurator needs to price a
// plan: the active plans (base allowances), the full catalogue (component prices
// in the running environment), and the environment name so the client can refuse
// a mismatch. The client token itself is baked into the adminsite build, never
// served from here.
func checkoutOptions(c *gin.Context) {
	ctx := c.Request.Context()
	plans := []models.Plan{}
	if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil {
		_ = cur.All(ctx, &plans)
	}
	rows, err := models.AllCatalogue(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{
		"plans":     plans,
		"catalogue": rows,
		"env":       paddle.Get().Env(),
	})
}

// createSelfHostedPlaceholder makes an instance row that exists only so a
// checkout has something to put in custom_data. It carries no licence and is
// flagged Placeholder until the customer pastes their install's real UUID. The
// generated id is temporary; linking replaces the identity.
func createSelfHostedPlaceholder(c *gin.Context) {
	s := auth.Current(c)
	var body struct {
		Name string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
		return
	}
	ctx := c.Request.Context()
	inst := models.Instance{
		InstanceID:  uuid.NewString(),
		AccountID:   s.AccountID,
		Name:        body.Name,
		Deployment:  license.DeploymentSelfHosted,
		Status:      models.StatusAwaitingLink,
		Placeholder: true,
		CreatedAt:   time.Now().UTC(),
	}
	if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID,
		Target: inst.InstanceID, IP: c.ClientIP()})
	c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}

// createCloudCheckout creates a PAID cloud placeholder and hands back its id so
// the browser can open a Paddle checkout keyed to it. Nothing is provisioned yet:
// a cloud instance costs real infrastructure, so it is created only once payment
// is confirmed, by the subscription webhook (billing.handleSubscription).
//
// This mirrors the self-hosted placeholder, with one difference that matters:
// admin owns the cloud UUID, so the id generated here is the id the instance
// will keep. Provisioning on the webhook reuses it (provision.CreateInstanceWithID),
// which is why there is no claim-and-rewrite step and the subscription's
// custom_data never goes stale. PendingOwnerUserID remembers who bought it so the
// webhook can make them the instance owner.
//
// An abandoned checkout therefore leaves only this row — no infrastructure — the
// same cheap, reap-safe state a self-hosted placeholder leaves.
func createCloudCheckout(c *gin.Context) {
	s := auth.Current(c)
	var body struct {
		Name string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
		return
	}
	ctx := c.Request.Context()
	inst := models.Instance{
		InstanceID:         uuid.NewString(),
		AccountID:          s.AccountID,
		Name:               strings.TrimSpace(body.Name),
		Deployment:         license.DeploymentCloud,
		Status:             models.StatusAwaitingLink,
		Placeholder:        true,
		PendingOwnerUserID: s.UserID,
		CreatedAt:          time.Now().UTC(),
	}
	if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.cloud_placeholder_created", AccountID: s.AccountID,
		Target: inst.InstanceID, IP: c.ClientIP()})
	c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}

// updateEntitlement sets an instance's DESIRED configuration and pushes the
// resulting line items to Paddle. It does NOT issue — the resulting
// subscription.updated webhook does, from granted. An increase is prorated
// immediately by Paddle; a reduction is recorded as desired and takes effect at
// renewal, so this never shrinks a live licence.
func updateEntitlement(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	ctx := c.Request.Context()
	var body struct {
		Tier     string   `json:"tier"`
		Term     string   `json:"term"`
		Servers  int      `json:"servers"`
		Features []string `json:"features"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"})
		return
	}

	plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"})
		return
	}
	if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)})
		return
	}

	desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()}
	items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// A live subscription is required to update. None means this instance has
	// never been paid for — that is a checkout, not an update.
	var sub models.Subscription
	if err := db.Admin("subscriptions").FindOne(ctx,
		bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil {
		c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"})
		return
	}

	pItems := make([]paddle.LineItem, 0, len(items))
	for _, it := range items {
		pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity})
	}
	if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"})
		return
	}

	// Record desired now; the webhook Paddle sends back promotes to granted and
	// reissues. Recording here makes the portal reflect the intent instantly
	// rather than waiting on the round-trip.
	limits, _, _ := catalogue.Resolve(ctx, plan, desired)
	next := models.Entitlement{
		InstanceID: inst.InstanceID, AccountID: inst.AccountID,
		Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term,
		Desired: desired, ResolvedLimits: limits,
	}
	ent, _ := models.GetEntitlement(ctx, inst.InstanceID)
	if ent != nil {
		next.Granted = ent.Granted
		next.GrantedAt = ent.GrantedAt
		if desired.Servers < ent.Granted.Servers {
			now := time.Now().UTC()
			next.ScheduledChangeAt = &now
		}
	} else {
		next.Granted = desired
	}
	if err := models.UpsertEntitlement(ctx, next); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: auth.Current(c).Email, Action: "entitlement.requested",
		AccountID: inst.AccountID, Target: inst.InstanceID})
	c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
}

// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
// real install UUID, then issues.
//
// :id is the placeholder (generated at checkout, carried in the subscription's
// custom_data); the body carries the UUID the install actually reports. The
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
// placeholder row's identity is rewritten to it and the subscription re-pointed,
// then billing issues from the recorded subscription. Linking and claiming stay
// one call here because, unlike Free, the payment already happened.
func claimPlaceholderLink(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	if !inst.Placeholder {
		c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
		return
	}
	var body struct {
		InstanceID string `json:"instance_id"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
		return
	}
	ctx := c.Request.Context()

	// The real UUID must be free across every account — the unique index on
	// instance_id is the tenant-isolation property, so refuse rather than collide.
	if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
		bson.M{"instance_id": body.InstanceID}); n > 0 {
		c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
		return
	}

	placeholderID := inst.InstanceID
	if _, err := db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": placeholderID},
		bson.M{
			"$set": bson.M{
				"instance_id": body.InstanceID,
				"status":      models.StatusActive,
				"placeholder": false,
			},
			"$addToSet": bson.M{"previous_instance_ids": placeholderID},
		}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	// Re-point the subscription rows from the placeholder id to the real UUID so
	// billing.IssueForInstance finds it, and rewrite Paddle's own copy of
	// custom_data — written at checkout, it still names the placeholder, and every
	// later event on this subscription is decoded from it.
	if err := licensing.RepointSubscriptions(ctx, placeholderID, body.InstanceID, inst.AccountID); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
		// The link stuck; issuance did not. The reconciler and a retry recover it,
		// and the customer is not blocked from linking. Surface it, do not roll back.
		c.JSON(http.StatusAccepted, gin.H{
			"instance_id": body.InstanceID,
			"warning":     "linked, but licence issuance is pending: " + err.Error()})
		return
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
		AccountID: inst.AccountID, Target: body.InstanceID,
		Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
}

// billingPortal mints a Paddle customer-portal URL. The account must already
// have a paddle_customer_id, which it learns from its first subscription webhook.
func billingPortal(c *gin.Context) {
	s := auth.Current(c)
	ctx := c.Request.Context()
	var acc models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no account"})
		return
	}
	if acc.PaddleCustomerID == "" {
		c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"})
		return
	}
	url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID)
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"url": url})
}
