package api

import (
	"errors"
	"net/http"
	"sync"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"github.com/gin-gonic/gin"
)

// licenceExemptPaths are routes that must work while a licence is expired or
// missing, because they are how a customer recovers or stays safe.
//
//	/api/license          pasting a valid licence is the way out of degraded mode
//	apply-updates         security patching is never paywalled
//
// All DELETE requests are exempt separately (see RequireActiveLicense): a
// customer downgraded below their current usage must be able to delete their
// way back under the cap.
var licenceExemptPaths = map[string]bool{
	"/api/license": true,
}

// mutatingGETs are routes that change state despite their method. GET is
// otherwise always allowed through, so these have to be named explicitly:
// GET /api/servers/new mints a pre-registration token, which is a creation.
var mutatingGETs = map[string]bool{
	"/api/servers/new": true,
}

func licenceExempt(c *gin.Context) bool {
	if c.Request.Method == http.MethodDelete {
		return true
	}
	if licenceExemptPaths[c.FullPath()] {
		return true
	}
	if c.FullPath() == "/api/servers/:id/apply-updates" {
		return true
	}
	return false
}

// RequireActiveLicense blocks mutating requests when the licence is not valid.
//
// Mounted on the /api group, so a route added tomorrow is gated because of where
// it lives rather than because someone remembered. GET and HEAD always pass —
// reading is never blocked.
func RequireActiveLicense() gin.HandlerFunc {
	return func(c *gin.Context) {
		if (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) &&
			!mutatingGETs[c.FullPath()] {
			c.Next()
			return
		}
		if licenceExempt(c) {
			c.Next()
			return
		}

		st := services.GetLicenseState(auth.InstanceID(c))
		if st.Active() {
			c.Next()
			return
		}

		c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
			"error":  "license_required",
			"state":  st.Status,
			"reason": st.Reason,
		})
	}
}

// RequireFeature blocks a route when the licence does not grant a feature.
func RequireFeature(name string) gin.HandlerFunc {
	return func(c *gin.Context) {
		st := services.GetLicenseState(auth.InstanceID(c))
		if st.Feature(name) {
			c.Next()
			return
		}
		c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
			"error":   "feature_unavailable",
			"feature": name,
		})
	}
}

type licenceResponse struct {
	InstanceID    string               `json:"instance_id"`
	State         license.State        `json:"state"`
	Reason        string               `json:"reason,omitempty"`
	Tier          string               `json:"tier,omitempty"`
	SupportLevel  string               `json:"support_level,omitempty"`
	ExpiresAt     *time.Time           `json:"expires_at,omitempty"`
	DaysRemaining *int                 `json:"days_remaining,omitempty"`
	Limits        license.Limits       `json:"limits"`
	Features      map[string]bool      `json:"features"`
	Usage         licenceUsageResponse `json:"usage"`
	Source        string               `json:"source"`
	// Deployment is what tells the UI whether this instance owns its licence.
	// On cloud the licence is injected by admin and there is nothing for a
	// customer to paste, so the UI sends them to the portal instead.
	Deployment string `json:"deployment"`
}

type licenceUsageResponse struct {
	Servers      int `json:"servers"`
	Monitors     int `json:"monitors"`
	SecretGroups int `json:"secret_groups"`
	Channels     int `json:"channels"`
}

func getLicence(c *gin.Context) {
	instanceID := auth.InstanceID(c)
	st := services.GetLicenseState(instanceID)
	servers, monitors, groups, channels := services.LicenseUsage(instanceID)

	resp := licenceResponse{
		InstanceID:   instanceID,
		State:        st.Status,
		Reason:       st.Reason,
		Tier:         st.Tier,
		SupportLevel: st.SupportLevel,
		ExpiresAt:    st.ExpiresAt,
		Limits:       st.Limits,
		Features:     st.Features,
		Usage:        licenceUsageResponse{Servers: servers, Monitors: monitors, SecretGroups: groups, Channels: channels},
		Source:       st.Source,
		Deployment:   services.DeploymentMode(),
	}
	if st.ExpiresAt != nil {
		d := int(time.Until(*st.ExpiresAt).Hours() / 24)
		resp.DaysRemaining = &d
	}
	c.JSON(http.StatusOK, resp)
}

var (
	licencePostMu     sync.Mutex
	licencePostCounts = map[string][]time.Time{}
)

const licencePostLimit = 10

// licencePostAllowed permits 10 attempts per instance per hour.
func licencePostAllowed(instanceID string) bool {
	cutoff := time.Now().Add(-time.Hour)

	licencePostMu.Lock()
	defer licencePostMu.Unlock()

	kept := licencePostCounts[instanceID][:0]
	for _, t := range licencePostCounts[instanceID] {
		if t.After(cutoff) {
			kept = append(kept, t)
		}
	}
	if len(kept) >= licencePostLimit {
		licencePostCounts[instanceID] = kept
		return false
	}
	licencePostCounts[instanceID] = append(kept, time.Now())
	return true
}

func postLicence(c *gin.Context) {
	instanceID := auth.InstanceID(c)

	// A cloud instance's licence is written by admin straight into the database
	// (admin/internal/inject), never through this endpoint, so refusing here
	// cannot break injection. Hiding the form in web/ is a courtesy; this is the
	// boundary, the same split as an hq-managed user's role.
	if services.DeploymentMode() == license.DeploymentCloud {
		c.JSON(http.StatusConflict, gin.H{
			"error":  "This instance's licence is managed in Vantage HQ and cannot be set here.",
			"reason": "cloud_managed",
		})
		return
	}

	if !licencePostAllowed(instanceID) {
		c.JSON(http.StatusTooManyRequests, gin.H{
			"error": "Too many licence attempts. Try again later.",
		})
		return
	}

	var body struct {
		Blob string `json:"blob"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"})
		return
	}

	st, err := services.StoreLicense(instanceID, body.Blob)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error":  licenceRejectionMessage(err.Error(), instanceID),
			"reason": err.Error(),
		})
		return
	}

	services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
		"licence accepted (tier "+st.Tier+")")
	c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
}

// licenceRejectionMessage turns a machine reason into something a person can act
// on. The instance ID is included in the mismatch case because that is the one
// piece of information the customer needs and cannot guess.
func licenceRejectionMessage(reason, instanceID string) string {
	switch reason {
	case license.ReasonBadSignature:
		return "This licence key is not valid. Check it was copied in full."
	case license.ReasonDeploymentMismatch:
		return "This licence was issued for a different instance type."
	case license.ReasonInstanceMismatch:
		return "This licence was issued for a different instance. Your instance ID is " + instanceID + "."
	case license.ReasonNoLicense:
		return "No licence key was provided."
	default:
		return "This licence could not be accepted."
	}
}

// limitStatus maps a LimitError to a 403 body. Handlers that create countable
// resources call this so the UI gets a machine-readable limit name.
func limitStatus(c *gin.Context, err error) bool {
	var le *services.LimitError
	if !errors.As(err, &le) {
		return false
	}
	c.JSON(http.StatusForbidden, gin.H{
		"error":   "limit_exceeded",
		"limit":   le.Limit,
		"current": le.Current,
		"max":     le.Max,
	})
	return true
}
