package api

import (
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"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/cloudprov"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
	"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"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// ownedInstance resolves an instance and confirms the session's account owns it.
//
// EVERY customer handler that names an instance must go through this. It returns
// 404 for another account's instance rather than 403: a 403 confirms the
// instance exists, which is an existence oracle over customer data.
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
	s := auth.Current(c)
	if s == nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
		return nil, false
	}
	var inst models.Instance
	err := db.Admin("admin_instances").FindOne(c.Request.Context(),
		bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return nil, false
	}
	return &inst, true
}

// getMe reports who the caller is, for route guards in the UI.
//
// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a
// truthful 401 to redirect on, not an error page. It reveals nothing a caller
// does not already possess, because it only ever describes their own cookie.
func getMe(c *gin.Context) {
	s := auth.Load(c)
	if s == nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
		return
	}
	out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
	if s.Kind == auth.KindCustomer {
		var u models.CustomerUser
		if err := db.Admin("customer_users").FindOne(c.Request.Context(),
			bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
			out["account_role"] = u.AccountRole
		}
	}
	c.JSON(http.StatusOK, out)
}

func getAccount(c *gin.Context) {
	s := auth.Current(c)
	ctx := c.Request.Context()

	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return
	}

	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	instances := []models.Instance{}
	if err := cur.All(ctx, &instances); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"account":   acct,
		"instances": instances,
		// Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a
		// second source of truth for a rule the backend enforces.
		"max_relinks": models.MaxRelinksPerTerm,
	})
}

func linkInstance(c *gin.Context) {
	var body struct {
		InstanceID string `json:"instance_id"`
		Name       string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
		return
	}

	s := auth.Current(c)
	inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name)
	if err != nil {
		status := http.StatusBadRequest
		if errors.Is(err, licensing.ErrAlreadyLinked) {
			status = http.StatusConflict
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, inst)
}

func relinkInstance(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	var body struct {
		InstanceID string `json:"instance_id"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
		return
	}

	s := auth.Current(c)
	lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
	if err != nil {
		status := http.StatusBadRequest
		if errors.Is(err, licensing.ErrRelinkLimit) {
			status = http.StatusForbidden
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}
	deliver(c, inst, lic)
	c.JSON(http.StatusOK, lic)
}

func getInstanceLicense(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	var lic models.License
	if err := db.Admin("licenses").FindOne(c.Request.Context(),
		bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
		return
	}
	// The owner gets the blob itself: it is signed public data bound to their
	// own instance, and the download endpoint hands over the same bytes. The
	// struct tag hides it, so the fields are listed explicitly.
	c.JSON(http.StatusOK, gin.H{
		"license_id": lic.LicenseID, "instance_id": lic.InstanceID, "tier": lic.Tier,
		"deployment": lic.Deployment, "limits": lic.Limits, "features": lic.Features,
		"issued_at": lic.IssuedAt, "expires_at": lic.ExpiresAt, "reason": lic.Reason,
		"issued_by": lic.IssuedBy, "blob": lic.Blob,
	})
}

func downloadInstanceLicense(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	var lic models.License
	if err := db.Admin("licenses").FindOne(c.Request.Context(),
		bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
		return
	}
	c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
	c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
}

func listSubscriptions(c *gin.Context) {
	s := auth.Current(c)
	cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
	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)
}

// provisionCloudInstance provisions a real cloud instance in the control plane
// and records admin's row for it, WITHOUT issuing a licence. Both the Free
// create path and the paid-checkout path share it, so the provisioning — and its
// unwind-in-reverse rollback — has one definition rather than two that drift.
//
// It leaves the instance unlicensed on purpose: createInstance then issues Free,
// and createCloudCheckout leaves it for the paid subscription webhook to license.
// The returned rec carries no Tier or CurrentLicense; the caller sets those once
// it has issued.
//
// Errors are returned unwrapped for the provisioning step so the caller can still
// match provision.ErrEmailTaken / ErrNameRejected; later steps are wrapped.
func provisionCloudInstance(c *gin.Context, name string) (*models.Instance, error) {
	ctx := c.Request.Context()
	s := auth.Current(c)

	var cu models.CustomerUser
	if err := db.Admin("customer_users").FindOne(ctx,
		bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
		return nil, fmt.Errorf("read account: %w", err)
	}

	inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
	if err != nil {
		return nil, err
	}

	rec := models.Instance{
		InstanceID: inst.InstanceID,
		AccountID:  s.AccountID,
		Name:       inst.Name,
		Slug:       inst.Slug,
		Deployment: license.DeploymentCloud,
		Status:     models.StatusActive,
		CreatedAt:  time.Now().UTC(),
	}
	if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
		// Unwind in reverse: the owner first, because RollbackInstance refuses
		// an instance that still has users.
		if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
			_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
		}
		if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
			log.Printf("provisionCloudInstance: rollback of %s failed: %v", inst.InstanceID, e)
		}
		return nil, fmt.Errorf("record instance: %w", err)
	}

	// Record the owner's membership. Best-effort: the projected user already
	// exists and is what actually grants access, so a missing row here costs a
	// line in the members panel, not access — and the boot backfill rebuilds it.
	ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
	if err != nil {
		log.Printf("provisionCloudInstance: owner lookup for %s: %v", inst.InstanceID, err)
	} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
		MemberID:       uuid.NewString(),
		AccountID:      s.AccountID,
		InstanceID:     inst.InstanceID,
		CustomerUserID: cu.UserID,
		ControlUserID:  ownerID,
		Role:           sharedmodels.RoleOwner,
		Email:          cu.Email,
		CreatedAt:      time.Now().UTC(),
	}); err != nil {
		log.Printf("provisionCloudInstance: record owner membership for %s: %v", inst.InstanceID, err)
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
		Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
	return &rec, nil
}

// cloudProvisionError maps the errors provisionCloudInstance can surface onto the
// customer-facing responses shared by the Free and paid-checkout paths.
func cloudProvisionError(c *gin.Context, err error) {
	switch {
	case errors.Is(err, provision.ErrEmailTaken):
		// users.email is unique per instance, so this means the address already
		// owns a user in an instance we are not creating — a legacy cloud tenant.
		// Staff have to attach that one by hand.
		c.JSON(http.StatusConflict, gin.H{
			"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
	case errors.Is(err, provision.ErrNameRejected):
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
	default:
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
	}
}

// createInstance provisions a Free cloud instance for the calling account.
//
// The ordering matters and each step unwinds the previous one. Licence issuance
// and email are deliberately NOT allowed to fail the request: the instance
// exists and the customer can sign in, they see the licence banner, and staff
// can issue by hand. Rolling back an instance the customer can already see would
// be worse than shipping it unlicensed.
func createInstance(c *gin.Context) {
	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": "name is required"})
		return
	}
	name := strings.TrimSpace(body.Name)
	ctx := c.Request.Context()
	s := auth.Current(c)

	// Pre-check the Free rule so we never create an instance we then cannot
	// licence. licensing.Issue enforces it too; this is the friendly refusal.
	//
	// Scoped to cloud because that is what this endpoint creates. It MUST match
	// checkFreeLimit's scoping — a pre-check stricter than the issuer refuses
	// something that would have worked.
	n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
		"account_id": s.AccountID,
		"deployment": license.DeploymentCloud,
		"tier":       license.TierFree,
		"status":     bson.M{"$ne": models.StatusCancelled},
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"})
		return
	}
	if n > 0 {
		c.JSON(http.StatusConflict, gin.H{
			"error": "this account already has a Free cloud instance"})
		return
	}

	rec, err := provisionCloudInstance(c, name)
	if err != nil {
		cloudProvisionError(c, err)
		return
	}
	inst := rec

	// Past this point nothing fails the request.
	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       license.TierFree,
		Term:       "monthly",
		Reason:     models.ReasonNew,
		IssuedBy:   "self-serve",
	})
	if err != nil {
		log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err)
		c.JSON(http.StatusCreated, rec)
		return
	}
	inject.Deliver(ctx, lic)

	if mail.Enabled() {
		if err := mail.Default.SendInstanceReady(s.Email, inst.Name,
			loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
			log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
		}
	}

	rec.Tier = lic.Tier
	rec.CurrentLicense = lic.LicenseID
	c.JSON(http.StatusCreated, rec)
}

// renewInstance extends a Free licence by another term.
//
// Renewal is manual on purpose: it is the entire reclaim signal. An instance
// nobody renews is an instance nobody is using, and that is what makes the
// reaper safe to run at all.
func renewInstance(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	if inst.Tier != license.TierFree {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "only Free instances renew here; paid plans renew through billing"})
		return
	}

	ctx := c.Request.Context()

	var current models.License
	if err := db.Admin("licenses").FindOne(ctx,
		bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
		return
	}
	if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("this licence is not due yet; you can renew from %s",
				current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))})
		return
	}

	// Free renews on its deployment's only term: monthly for cloud, annual for
	// self-hosted. Reading it from TermsFor rather than hardcoding is what stops
	// a self-hosted instance being handed a one-month licence.
	terms := license.TermsFor(inst.Deployment)
	term := terms[len(terms)-1]

	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       license.TierFree,
		Term:       term,
		Reason:     models.ReasonRenewal,
		IssuedBy:   "self-serve",
	})
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// Cloud is injected; self-hosted is delivered to the customer, because their
	// database is theirs and we cannot write to it.
	deliver(c, inst, lic)

	// Clear the notice log so the next term starts the sequence again. Issue has
	// already set status back to active.
	if _, err := db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": inst.InstanceID},
		bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil {
		log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err)
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID,
		Target: inst.InstanceID, IP: c.ClientIP()})

	if mail.Enabled() {
		if err := mail.Default.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
			log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
		}
	}

	c.JSON(http.StatusOK, lic)
}

// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
// yields an empty string, and the email simply omits the link.
func loginURLFor(slug string) string {
	if appLoginURL == "" {
		return ""
	}
	return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug))
}

// appLoginURL is set once at boot from config.
var appLoginURL string

// SetAppLoginURL is called from main.
func SetAppLoginURL(v string) { appLoginURL = v }

// claimFree issues a Free licence on a linked self-hosted instance.
//
// The link step creates the row; this gives it a licence. They are separate
// because linking is about identity — proving which install is yours — and
// claiming is about entitlement, and a customer who links an install and then
// changes their mind should not have consumed their one Free allowance.
//
// Free is outside Paddle entirely, so there is no checkout, no subscription and
// nothing to reconcile. licensing.Issue's own checkFreeLimit is the real guard;
// the count here exists to refuse politely before anything is written.
func claimFree(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	ctx := c.Request.Context()

	// Cloud Free is claimed at creation by POST /api/instances. Allowing it here
	// too would be a second way to reach the same state, with its own bugs.
	if inst.Deployment != license.DeploymentSelfHosted {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "cloud instances get their Free licence when they are created"})
		return
	}
	if inst.CurrentLicense != "" {
		c.JSON(http.StatusConflict, gin.H{
			"error": "this instance already has a licence"})
		return
	}

	plan, err := models.GetPlan(ctx, license.DeploymentSelfHosted, license.TierFree)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "no Free plan configured"})
		return
	}
	if !plan.Active {
		c.JSON(http.StatusForbidden, gin.H{
			"error": "Free self-hosted is not currently offered"})
		return
	}

	// The entitlement is written BEFORE the licence, so Issue snapshots it rather
	// than falling back to the plan base. They are the same numbers today, but
	// the ordering is what makes that a coincidence rather than a dependency.
	if err := models.UpsertEntitlement(ctx, models.Entitlement{
		InstanceID:     inst.InstanceID,
		AccountID:      inst.AccountID,
		Deployment:     license.DeploymentSelfHosted,
		Tier:           license.TierFree,
		Term:           "annual",
		Desired:        models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
		Granted:        models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
		ResolvedLimits: plan.BaseLimits,
	}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	s := auth.Current(c)
	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       license.TierFree,
		// Annual, and not a choice. Self-hosted sells annual only because the
		// term length is the revocation window for an offline licence.
		Term:     "annual",
		Reason:   models.ReasonNew,
		IssuedBy: s.Email,
	})
	if err != nil {
		status := http.StatusBadRequest
		if errors.Is(err, licensing.ErrFreeLimit) {
			status = http.StatusConflict
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}

	deliver(c, inst, lic)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.claimed_free", AccountID: s.AccountID,
		Target: inst.InstanceID, Detail: "self-hosted Free, annual", IP: c.ClientIP()})
	c.JSON(http.StatusCreated, lic)
}

// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
// Delivery failures are logged, never returned: the licence is already recorded,
// which is the part that must not be lost.
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
	if inst.Deployment == license.DeploymentCloud {
		inject.Deliver(c.Request.Context(), lic)
		return
	}
	s := auth.Current(c)
	if s != nil && mail.Enabled() {
		_ = mail.Default.SendLicense(s.Email, inst.Name, lic.Blob)
	}
}
