package api

import (
	"errors"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"

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

// vulnGroup is one CVE across every server it affects.
//
// The board groups by CVE rather than listing findings flat: the same CVE on
// forty servers is one decision, and a flat list makes it look like forty.
type vulnGroup struct {
	CVEID       string               `json:"cve_id"`
	Severity    string               `json:"severity"`
	Title       string               `json:"title,omitempty"`
	ServerCount int                  `json:"server_count"`
	Findings    []models.VulnFinding `json:"findings"`
}

func listVulnerabilities(c *gin.Context) {
	findings, err := services.ListInstanceFindings(auth.InstanceID(c), services.FindingFilter{
		Severity: c.Query("severity"),
		State:    c.DefaultQuery("state", models.FindingOpen),
		ServerID: c.Query("server"),
		Tags:     tagsFromQuery(c),
		HasFix:   hasFixFromQuery(c),
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, groupByCVE(findings))
}

// groupByCVE collapses findings into one row per CVE, most severe first.
func groupByCVE(findings []models.VulnFinding) []vulnGroup {
	index := map[string]*vulnGroup{}
	order := []string{}

	for _, f := range findings {
		g, ok := index[f.CVEID]
		if !ok {
			g = &vulnGroup{CVEID: f.CVEID, Severity: f.Severity, Title: f.Title}
			index[f.CVEID] = g
			order = append(order, f.CVEID)
		}
		// Several servers can disagree on severity when their distributions
		// rate the same CVE differently. The highest is shown, because that is
		// the one deciding whether anyone acts.
		if models.SeverityRank(f.Severity) > models.SeverityRank(g.Severity) {
			g.Severity = f.Severity
		}
		g.Findings = append(g.Findings, f)
	}

	out := make([]vulnGroup, 0, len(order))
	for _, id := range order {
		g := index[id]
		servers := map[string]bool{}
		for _, f := range g.Findings {
			servers[f.ServerID] = true
		}
		g.ServerCount = len(servers)
		out = append(out, *g)
	}

	sort.SliceStable(out, func(i, j int) bool {
		ri, rj := models.SeverityRank(out[i].Severity), models.SeverityRank(out[j].Severity)
		if ri != rj {
			return ri > rj
		}
		return out[i].ServerCount > out[j].ServerCount
	})
	return out
}

// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
// or malformed value, is no filter — a filter nobody asked for must never hide
// findings, and the wrong direction here hides the unfixable ones.
func hasFixFromQuery(c *gin.Context) *bool {
	switch c.Query("has_fix") {
	case "true":
		v := true
		return &v
	case "false":
		v := false
		return &v
	default:
		return nil
	}
}

// tagsFromQuery reads repeated tag=key:value parameters.
func tagsFromQuery(c *gin.Context) map[string]string {
	out := map[string]string{}
	for _, raw := range c.QueryArray("tag") {
		k, v, ok := strings.Cut(raw, ":")
		if !ok || k == "" {
			continue
		}
		out[k] = v
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func vulnerabilitySummary(c *gin.Context) {
	counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	resp := gin.H{"counts": counts}

	// Database freshness travels with the counts rather than living in
	// settings: a fleet scanned against a three-week-old database must say so
	// wherever its findings are read, not somewhere the reader has to go and
	// look for it.
	if meta, err := services.GetVulnDBMeta(); err == nil && meta != nil {
		resp["db_version"] = meta.DBVersion
		resp["pulled_at"] = meta.PulledAt
		resp["last_full_scan_at"] = meta.LastFullScanAt
		if meta.LastError != "" {
			resp["last_error"] = meta.LastError
		}
	}

	c.JSON(http.StatusOK, resp)
}

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

	n, err := services.MarkInstanceForRescan(instanceID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	services.LogEvent(instanceID, "vuln.rescan", actorFromCtx(c), "", "",
		"queued "+strconv.FormatInt(n, 10)+" server(s) for rescan")
	c.JSON(http.StatusOK, gin.H{"queued": n})
}

type acceptFindingRequest struct {
	Reason string    `json:"reason"`
	Until  time.Time `json:"until"`
}

func acceptFinding(c *gin.Context) {
	var req acceptFindingRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
		return
	}

	// Both rejected deliberately. An acceptance with no reason is a dismissal
	// nobody can audit, and one already expired is a permanent dismissal
	// wearing an expiry — the graveyard the expiry exists to prevent.
	if strings.TrimSpace(req.Reason) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "a reason is required"})
		return
	}
	if req.Until.IsZero() || !req.Until.After(time.Now()) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "until must be a future date"})
		return
	}

	instanceID := auth.InstanceID(c)
	actor := actorFromCtx(c)

	f, err := services.AcceptFinding(instanceID, c.Param("id"), actor, req.Reason, req.Until)
	if err != nil {
		writeFindingError(c, err)
		return
	}

	services.LogEvent(instanceID, "vuln.accepted", actor, f.ServerID, "",
		f.CVEID+" on "+f.PackageName+" accepted until "+req.Until.Format(time.RFC3339)+": "+req.Reason)
	c.JSON(http.StatusOK, f)
}

func unacceptFinding(c *gin.Context) {
	instanceID := auth.InstanceID(c)
	actor := actorFromCtx(c)

	f, err := services.UnacceptFinding(instanceID, c.Param("id"))
	if err != nil {
		writeFindingError(c, err)
		return
	}

	services.LogEvent(instanceID, "vuln.unaccepted", actor, f.ServerID, "",
		f.CVEID+" on "+f.PackageName+" returned to open")
	c.JSON(http.StatusOK, f)
}

func writeFindingError(c *gin.Context, err error) {
	if errors.Is(err, services.ErrFindingNotFound) {
		c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
		return
	}
	c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}

func listServerVulnerabilities(c *gin.Context) {
	findings, err := services.ListFindings(c.Request.Context(), auth.InstanceID(c), c.Param("id"))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if findings == nil {
		findings = []models.VulnFinding{}
	}
	c.JSON(http.StatusOK, findings)
}

func getServerPackages(c *gin.Context) {
	sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if sp == nil {
		// Not a 404: an agent that has not reported yet is the normal state for
		// the first hour after install, and is a different thing from a bad
		// server id.
		c.JSON(http.StatusOK, gin.H{"reported": false})
		return
	}
	c.JSON(http.StatusOK, sp)
}

func searchPackages(c *gin.Context) {
	name := c.Query("name")
	if name == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
		return
	}
	hits, err := services.SearchPackages(auth.InstanceID(c), name)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, hits)
}

func listVulnRules(c *gin.Context) {
	rules, err := services.ListVulnRules(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, rules)
}

func createVulnRule(c *gin.Context) {
	var r models.VulnAlertRule
	if err := c.ShouldBindJSON(&r); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
		return
	}

	instanceID := auth.InstanceID(c)
	created, err := services.CreateVulnRule(instanceID, &r)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	services.LogEvent(instanceID, "vuln.rule_created", actorFromCtx(c), "", "", "rule "+created.Name)
	c.JSON(http.StatusCreated, created)
}

func updateVulnRule(c *gin.Context) {
	var r models.VulnAlertRule
	if err := c.ShouldBindJSON(&r); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
		return
	}

	instanceID := auth.InstanceID(c)
	if err := services.UpdateVulnRule(instanceID, c.Param("id"), &r); err != nil {
		if errors.Is(err, services.ErrVulnRuleNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"})
			return
		}
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	services.LogEvent(instanceID, "vuln.rule_updated", actorFromCtx(c), "", "", "rule "+r.Name)
	c.JSON(http.StatusOK, gin.H{"status": "updated"})
}

func deleteVulnRule(c *gin.Context) {
	instanceID := auth.InstanceID(c)
	if err := services.DeleteVulnRule(instanceID, c.Param("id")); err != nil {
		if errors.Is(err, services.ErrVulnRuleNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	services.LogEvent(instanceID, "vuln.rule_deleted", actorFromCtx(c), "", "", "rule "+c.Param("id"))
	c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
