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/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/shared/license"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

func staffListAccounts(c *gin.Context) {
	filter := bson.M{}
	if q := c.Query("q"); q != "" {
		or := []bson.M{
			{"name": bson.M{"$regex": q, "$options": "i"}},
			{"billing_email": bson.M{"$regex": q, "$options": "i"}},
			{"paddle_customer_id": q},
		}
		// A support email often contains an instance UUID and nothing else, so
		// resolve that to its owning account rather than returning nothing.
		var inst models.Instance
		if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
			bson.M{"instance_id": q}).Decode(&inst); err == nil {
			or = append(or, bson.M{"account_id": inst.AccountID})
		}
		filter["$or"] = or
	}
	cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
		options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	accounts := []models.Account{}
	if err := cur.All(c.Request.Context(), &accounts); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, accounts)
}

func staffCreateAccount(c *gin.Context) {
	var body struct {
		Name         string `json:"name"`
		BillingEmail string `json:"billing_email"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
		return
	}
	acct := models.Account{
		AccountID:    uuid.NewString(),
		Name:         body.Name,
		BillingEmail: body.BillingEmail,
		Status:       models.AccountActive,
		CreatedAt:    time.Now().UTC(),
	}
	if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, acct)
}

func staffGetAccount(c *gin.Context) {
	ctx := c.Request.Context()
	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return
	}
	instances := []models.Instance{}
	if cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
		_ = cur.All(ctx, &instances)
	}
	subs := []models.Subscription{}
	if cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
		_ = cur.All(ctx, &subs)
	}
	users := []models.CustomerUser{}
	if cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
		_ = cur.All(ctx, &users)
	}
	entries := []models.AuditEntry{}
	if cur, err := db.Admin("admin_audit").Find(ctx, bson.M{"account_id": acct.AccountID},
		options.Find().SetLimit(100).SetSort(bson.D{{Key: "created_at", Value: -1}})); err == nil {
		_ = cur.All(ctx, &entries)
	}

	// CustomerUser's password hash and both verify-token fields are json:"-",
	// so no secret leaves here.
	c.JSON(http.StatusOK, gin.H{
		"account":       acct,
		"instances":     instances,
		"subscriptions": subs,
		"users":         users,
		"audit":         entries,
	})
}

func staffListInstances(c *gin.Context) {
	filter := bson.M{}
	for param, field := range map[string]string{
		"account_id": "account_id",
		"deployment": "deployment",
		"status":     "status",
	} {
		if v := c.Query(param); v != "" {
			filter[field] = v
		}
	}
	if c.Query("expiring") == "true" {
		// Instances whose licence expires within 14 days, for renewal chasing.
		//
		// Empty rather than nil: a nil slice marshals to `$in: null`, which
		// Mongo rejects outright, so the quiet week when nothing is expiring is
		// exactly when this query would have failed.
		ids := []string{}
		cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
			"superseded_by": bson.M{"$exists": false},
			"expires_at":    bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
		})
		if err == nil {
			var lics []models.License
			if cur.All(c.Request.Context(), &lics) == nil {
				for _, l := range lics {
					ids = append(ids, l.InstanceID)
				}
			}
		}
		filter["instance_id"] = bson.M{"$in": ids}
	}

	cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
		options.Find().SetLimit(500))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	instances := []models.Instance{}
	if err := cur.All(c.Request.Context(), &instances); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, instances)
}

// staffCreateInstance attaches an instance to an account.
//
// For cloud, this ADOPTS an instance that already exists in the control plane —
// the control-plane row is the source of truth for its name and slug, and this
// refuses if no such instance exists, because an admin row pointing at nothing
// would issue licences nobody can use.
//
// For self-hosted it does the same job as the customer-facing link endpoint, so
// staff can link on a customer's behalf during support.
//
// This is how existing cloud instances get licensed: adopt, then issue.
func staffCreateInstance(c *gin.Context) {
	var body struct {
		InstanceID string `json:"instance_id"`
		AccountID  string `json:"account_id"`
		Deployment string `json:"deployment"`
		Name       string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
		return
	}
	ctx := c.Request.Context()

	if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
		return
	}

	inst := models.Instance{
		InstanceID: body.InstanceID,
		AccountID:  body.AccountID,
		Name:       body.Name,
		Deployment: body.Deployment,
		Status:     models.StatusActive,
		CreatedAt:  time.Now().UTC(),
	}

	if body.Deployment == license.DeploymentCloud {
		var remote sharedmodels.Instance
		if err := db.Control("instances").FindOne(ctx,
			bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
			return
		}
		inst.Name = remote.Name
		inst.Slug = remote.Slug
	}

	if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
		if mongo.IsDuplicateKeyError(err) {
			c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
	c.JSON(http.StatusCreated, inst)
}

// staffGetInstance is the "why did this stop working" screen's data: one
// instance, its account, its whole licence history newest first, and whether
// the control plane currently holds what we think it holds.
func staffGetInstance(c *gin.Context) {
	ctx := c.Request.Context()

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return
	}

	var acct models.Account
	_ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct)

	lics := []models.License{}
	if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID},
		options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil {
		_ = cur.All(ctx, &lics)
	}

	// Injection state is only meaningful for cloud. For self-hosted the
	// customer holds the blob and there is nothing for us to have written.
	injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud}
	if inst.Deployment == license.DeploymentCloud {
		var remote sharedmodels.Instance
		err := db.Control("instances").FindOne(ctx,
			bson.M{"instance_id": inst.InstanceID}).Decode(&remote)
		switch {
		case err != nil:
			injection["state"] = "missing"
		case inst.CurrentLicense == "":
			injection["state"] = "none_issued"
		default:
			var current models.License
			if db.Admin("licenses").FindOne(ctx,
				bson.M{"license_id": inst.CurrentLicense}).Decode(&current) == nil &&
				remote.LicenseBlob == current.Blob {
				injection["state"] = "current"
			} else {
				injection["state"] = "stale"
			}
		}
		injection["failed_at"] = inst.InjectFailedAt
	}

	c.JSON(http.StatusOK, gin.H{
		"instance": inst, "account": acct, "licenses": lics, "injection": injection,
	})
}

// staffListSubscriptions backs the past-due queue on the dashboard.
func staffListSubscriptions(c *gin.Context) {
	filter := bson.M{}
	if v := c.Query("status"); v != "" {
		filter["status"] = v
	}
	if v := c.Query("account_id"); v != "" {
		filter["account_id"] = v
	}
	cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter,
		options.Find().SetLimit(500))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	subs := []models.Subscription{}
	if err := cur.All(c.Request.Context(), &subs); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, subs)
}

func staffIssue(c *gin.Context) {
	var body struct {
		Tier   string `json:"tier"`
		Term   string `json:"term"`
		Reason string `json:"reason"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
		return
	}
	if body.Reason == "" {
		body.Reason = models.ReasonManual
	}

	s := auth.Current(c)
	lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
		InstanceID: c.Param("id"),
		Tier:       body.Tier,
		Term:       body.Term,
		Reason:     body.Reason,
		IssuedBy:   s.Email,
	})
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var inst models.Instance
	if db.Admin("admin_instances").FindOne(c.Request.Context(),
		bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
		deliver(c, &inst, lic)
	}
	c.JSON(http.StatusCreated, lic)
}

// staffRelink has no attempt cap. The customer-facing limit exists to put a
// human in front of the fourth attempt; this is that human.
func staffRelink(c *gin.Context) {
	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()

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return
	}

	lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, lic)
}

func staffListLicenses(c *gin.Context) {
	filter := bson.M{}
	if v := c.Query("instance_id"); v != "" {
		filter["instance_id"] = v
	}
	if v := c.Query("account_id"); v != "" {
		filter["account_id"] = v
	}
	cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
		options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	lics := []models.License{}
	if err := cur.All(c.Request.Context(), &lics); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, lics)
}

// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
// placeholders, so a customer who paid and got nothing is visible rather than
// stuck in a support queue.
func staffBillingHealth(c *gin.Context) {
	ctx := c.Request.Context()
	failed := []models.PaddleEvent{}
	if cur, err := db.Admin("paddle_events").Find(ctx,
		bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil {
		_ = cur.All(ctx, &failed)
	}
	unlinked := []models.Instance{}
	if cur, err := db.Admin("admin_instances").Find(ctx,
		bson.M{"placeholder": true, "status": models.StatusAwaitingLink}); err == nil {
		_ = cur.All(ctx, &unlinked)
	}
	c.JSON(http.StatusOK, gin.H{
		"failed_events":  failed,
		"failed_count":   len(failed),
		"unlinked_paid":  unlinked,
		"unlinked_count": len(unlinked),
	})
}

func staffListPlans(c *gin.Context) {
	cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	plans := []models.Plan{}
	if err := cur.All(c.Request.Context(), &plans); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, plans)
}

// staffUpdatePlan changes what a (deployment, tier) pair grants FROM NOW ON.
// Existing licences snapshotted their plan at issue time and are unaffected —
// the same rule as workflow_runs.steps_snapshot.
//
// It writes no Paddle identifiers: those live in the catalogue, because a
// metered plan is priced by several components.
func staffUpdatePlan(c *gin.Context) {
	var body models.Plan
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
		return
	}
	deployment, tier := c.Param("deployment"), c.Param("tier")
	set := bson.M{
		"name":          body.Name,
		"base_limits":   body.BaseLimits,
		"base_features": body.BaseFeatures.OrEmpty(),
		"support_level": body.SupportLevel,
		"active":        body.Active,
	}
	res, err := db.Admin("plans").UpdateOne(c.Request.Context(),
		bson.M{"deployment": deployment, "tier": tier}, bson.M{"$set": set})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if res.MatchedCount == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such plan"})
		return
	}
	audit.Write(c.Request.Context(), models.AuditEntry{
		Actor:  auth.Current(c).Email,
		Action: "plan.updated",
		Target: deployment + "/" + tier,
		Detail: fmt.Sprintf("servers=%d monitors=%d support=%s active=%t",
			body.BaseLimits.MaxServers, body.BaseLimits.MaxMonitors,
			body.SupportLevel, body.Active),
	})
	c.JSON(http.StatusOK, gin.H{"updated": true})
}

func staffListCatalogue(c *gin.Context) {
	rows, err := models.AllCatalogue(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, rows)
}

// staffUpdateCatalogue sets the price IDs on one component.
//
// The component is addressed by its natural key rather than by an ObjectID, so
// the staff UI never has to hold a Mongo identifier and a seeded row can be
// updated the moment it exists. Only price IDs are writable: a row's kind, plan
// and key are seeded by SeedCatalogue, and letting a form invent a limit_key
// would let it invent a limit nothing enforces.
func staffUpdateCatalogue(c *gin.Context) {
	var body struct {
		Kind       string                       `json:"kind"`
		Deployment string                       `json:"deployment"`
		Tier       string                       `json:"tier"`
		LimitKey   string                       `json:"limit_key"`
		FeatureKey string                       `json:"feature_key"`
		PriceIDs   map[string]map[string]string `json:"price_ids"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component"})
		return
	}

	// Refuse a price on a term the deployment does not sell. Storing one would
	// mean a resolved self-hosted monthly price later, which the resolver treats
	// as a configuration error — better to refuse it at the point somebody
	// pastes it, while they are looking at the screen.
	for env, byTerm := range body.PriceIDs {
		for term, id := range byTerm {
			if id == "" {
				continue
			}
			if !termSold(body.Deployment, term) {
				c.JSON(http.StatusBadRequest, gin.H{
					"error": fmt.Sprintf("%s does not sell %s (environment %s)",
						body.Deployment, term, env)})
				return
			}
		}
	}

	filter := bson.M{
		"kind":        body.Kind,
		"deployment":  body.Deployment,
		"tier":        body.Tier,
		"limit_key":   body.LimitKey,
		"feature_key": body.FeatureKey,
	}
	res, err := db.Admin("catalogue").UpdateOne(c.Request.Context(), filter,
		bson.M{"$set": bson.M{"price_ids": body.PriceIDs}})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if res.MatchedCount == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such component"})
		return
	}
	audit.Write(c.Request.Context(), models.AuditEntry{
		Actor:  auth.Current(c).Email,
		Action: "catalogue.updated",
		Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
		Detail: body.LimitKey + body.FeatureKey,
	})
	c.JSON(http.StatusOK, gin.H{"updated": true})
}

func termSold(deployment, term string) bool {
	for _, t := range license.TermsFor(deployment) {
		if t == term {
			return true
		}
	}
	return false
}

func staffAudit(c *gin.Context) {
	filter := bson.M{}
	if v := c.Query("account_id"); v != "" {
		filter["account_id"] = v
	}
	cur, err := db.Admin("admin_audit").Find(c.Request.Context(), filter,
		options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	entries := []models.AuditEntry{}
	if err := cur.All(c.Request.Context(), &entries); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, entries)
}

// staffInjectionHealth lists instances whose last injection failed. This is the
// page to look at when a customer says their cloud instance is read-only.
func staffInjectionHealth(c *gin.Context) {
	cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
		bson.M{"inject_failed_at": bson.M{"$exists": true}})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	failed := []models.Instance{}
	if err := cur.All(c.Request.Context(), &failed); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
}

// staffCreateAccountUser gives an account an HQ login.
//
// This is how a legacy cloud customer — one whose instance predates HQ accounts
// — gets into the portal, alongside the manual instance attach the spec README
// describes. It reuses CreateCustomerUser, so the row is unverified until the
// emailed link is opened and is rolled back if that email cannot be sent.
func staffCreateAccountUser(c *gin.Context) {
	var body struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "email and a password of at least 12 characters are required"})
		return
	}
	ctx := c.Request.Context()
	accountID := c.Param("id")

	if n, err := db.Admin("accounts").CountDocuments(ctx,
		bson.M{"account_id": accountID}); err != nil || n == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
		return
	}

	email := strings.ToLower(strings.TrimSpace(body.Email))
	// Staff attaching a legacy customer are attaching the person who runs that
	// account, so they get owner.
	if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
	c.JSON(http.StatusCreated, gin.H{"pending": true})
}
