package handler

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"security-center-backend/internal/middleware"
	vulnmodel "security-center-backend/internal/modules/vuln/model"
	vulnservice "security-center-backend/internal/modules/vuln/service"
	"security-center-backend/internal/pkg/cache"
	"security-center-backend/internal/pkg/httpcache"
	"security-center-backend/internal/pkg/response"
	"security-center-backend/internal/pkg/storage"
	"security-center-backend/internal/service/directory"
	"security-center-backend/internal/service/jwt"
)

type Handler struct {
	service  *vulnservice.Service
	uploader storage.FileUploader
	cache    *cache.Cache
}

type authorizedViewerCandidateDTO struct {
	ID             uint64 `json:"id"`
	Name           string `json:"name"`
	Email          string `json:"email"`
	AvatarURL      string `json:"avatar_url,omitempty"`
	DepartmentID   string `json:"department_id,omitempty"`
	DepartmentName string `json:"department_name,omitempty"`
	JobTitle       string `json:"job_title,omitempty"`
}

func New(service *vulnservice.Service, uploader storage.FileUploader, cacheLayer *cache.Cache) *Handler {
	return &Handler{service: service, uploader: uploader, cache: cacheLayer}
}

func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
	group := rg.Group("/vulnerabilities")
	group.GET("", h.List)
	group.POST("", h.Create)
	group.GET("/config", h.Config)
	group.POST("/config", h.UpdateConfig)
	group.POST("/uploads", h.UploadAttachment)
	group.POST("/uploads/confirm", h.ConfirmAttachmentUpload)
	group.GET("/uploads/:id/content", h.GetAttachmentContent)
	group.GET("/uploads/:id/url", h.GetAttachmentURL)
	group.GET("/:public_id", h.Get)
	group.PUT("/:public_id", h.Update)
	group.GET("/:public_id/events", h.ListEvents)
	group.POST("/:public_id/transition", h.TransitionStatus)
	group.POST("/:public_id/meta", h.UpdateMeta)
	group.POST("/:public_id/viewers", h.UpdateAuthorizedViewers)
	group.POST("/:public_id/visibility", h.UpdatePublicVisibility)
	group.GET("/:public_id/comments", h.ListComments)
	group.POST("/:public_id/comments", h.AddComment)
	group.POST("/:public_id/claim", h.Claim)
	group.POST("/:public_id/assign", h.Assign)
}

func (h *Handler) RegisterPublicVisibilityRoutes(rg *gin.RouterGroup) {
	group := rg.Group("/vulnerabilities")
	group.GET("", h.List)
	group.GET("/:public_id", h.Get)
	group.POST("/:public_id/visibility", h.UpdatePublicVisibility)
}

func (h *Handler) RegisterRestrictedRoutes(rg *gin.RouterGroup) {
	group := rg.Group("/vulnerabilities")
	group.GET("/config", h.Config)
	group.POST("/config", h.UpdateConfig)
	group.POST("/uploads", h.UploadAttachment)
	group.POST("/uploads/confirm", h.ConfirmAttachmentUpload)
	group.GET("/uploads/:id/content", h.GetAttachmentContent)
	group.GET("/uploads/:id/url", h.GetAttachmentURL)
	group.PUT("/:public_id", h.Update)
	group.GET("/:public_id/events", h.ListEvents)
	group.POST("/:public_id/transition", h.TransitionStatus)
	group.POST("/:public_id/meta", h.UpdateMeta)
	group.POST("/:public_id/viewers", h.UpdateAuthorizedViewers)
	group.GET("/:public_id/comments", h.ListComments)
	group.POST("/:public_id/comments", h.AddComment)
	group.POST("/:public_id/claim", h.Claim)
	group.POST("/:public_id/assign", h.Assign)
}

func (h *Handler) RegisterAuthorizedViewerCandidateRoutes(rg *gin.RouterGroup) {
	group := rg.Group("/vulnerabilities")
	group.GET("/:public_id/viewers/candidates", h.SearchAuthorizedViewerCandidates)
}

// RegisterSubmitRoutes registers vulnerability submission (Create) only; it is
// mounted on a dedicated subgroup gated by the vuln:submit_findings permission
// (see Module.RegisterRoutes).
func (h *Handler) RegisterSubmitRoutes(rg *gin.RouterGroup) {
	group := rg.Group("/vulnerabilities")
	group.POST("", h.Create)
}

// List lists vulnerabilities.
//
//	@Summary	List vulnerabilities
//	@Description	List vulnerabilities with filtering by multi-select status/severity, application, discovery phase, network environment, keyword, tags, etc.; paginated.
//	@Tags	vuln
//	@Produce	json
//	@Param	page	query	int	false	"Page number, default 1"
//	@Param	page_size	query	int	false	"Page size, default 20, max 100"
//	@Param	status	query	string	false	"Status multi-select (comma-separated)"
//	@Param	severity	query	string	false	"Severity multi-select (comma-separated)"
//	@Param	application	query	string	false	"Application"
//	@Param	discovery_phase	query	string	false	"Discovery phase"
//	@Param	discovery_channel	query	string	false	"Discovery channel"
//	@Param	network_env	query	string	false	"Network environment"
//	@Param	search	query	string	false	"Keyword"
//	@Param	tags	query	string	false	"Tag multi-select (comma-separated)"
//	@Param	dependency_name	query	string	false	"Dependency name"
//	@Param	dependency_version	query	string	false	"Dependency version"
//	@Param	owner_employee_id	query	int	false	"Owner employee ID"
//	@Param	submitter_user_id	query	int	false	"Submitter user ID"
//	@Param	sort_by	query	string	false	"Sort field"
//	@Param	sort_order	query	string	false	"Sort order (asc/desc)"
//	@Param	batch_group_id	query	int	false	"Batch group ID"
//	@Param	batch_scope	query	string	false	"Batch scope (batched/single)"
//	@Param	has_poc	query	bool	false	"Filter by PoC verification presence"
//	@Param	is_overdue	query	bool	false	"Filter by overdue flag"
//	@Param	is_third_party	query	bool	false	"Filter by third-party vulnerability flag"
//	@Param	submitted_from	query	string	false	"Submitted from (RFC3339)"
//	@Param	submitted_to	query	string	false	"Submitted to (RFC3339)"
//	@Success	200	{object}	response.APIResponse{data=response.PaginatedResponse}	"Success; data is a paginated structure whose items are vulnerability entries"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities [get]
func (h *Handler) List(c *gin.Context) {
	page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
	pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
	if pageSize > 100 {
		pageSize = 100
	}
	statuses := getMultiValueParam(c, "status")
	severities := getMultiValueParam(c, "severity")

	query := vulnservice.ListQuery{
		Page:              page,
		PageSize:          pageSize,
		Statuses:          toStatuses(statuses),
		Severities:        toSeverities(severities),
		Application:       c.Query("application"),
		DiscoveryPhase:    c.Query("discovery_phase"),
		DiscoveryChannel:  c.Query("discovery_channel"),
		NetworkEnv:        c.Query("network_env"),
		Keyword:           c.Query("search"),
		Tags:              getMultiValueParam(c, "tags"),
		DependencyName:    c.Query("dependency_name"),
		DependencyVersion: c.Query("dependency_version"),
		SortBy:            c.Query("sort_by"),
		SortOrder:         c.Query("sort_order"),
	}

	if ownerEmployeeID := parseUint64Pointer(c.Query("owner_employee_id")); ownerEmployeeID != nil {
		query.OwnerEmployeeID = ownerEmployeeID
	}
	if submitterUserID := parseUint64Pointer(c.Query("submitter_user_id")); submitterUserID != nil {
		query.SubmitterUserID = submitterUserID
	}
	if batchGroupID := parseUint64Pointer(c.Query("batch_group_id")); batchGroupID != nil {
		query.BatchGroupID = batchGroupID
	}
	switch strings.ToLower(c.Query("batch_scope")) {
	case "batched":
		value := true
		query.HasBatchGroup = &value
	case "single":
		value := false
		query.HasBatchGroup = &value
	}

	if hasPOC := parseBoolPointer(c.Query("has_poc")); hasPOC != nil {
		query.HasPOC = hasPOC
	}
	if isOverdue := parseBoolPointer(c.Query("is_overdue")); isOverdue != nil {
		query.IsOverdue = isOverdue
	}
	if isThirdParty := parseBoolPointer(c.Query("is_third_party")); isThirdParty != nil {
		query.IsThirdParty = isThirdParty
	}

	if from := parseTimePointer(c.Query("submitted_from")); from != nil {
		query.SubmittedFrom = from
	}
	if to := parseTimePointer(c.Query("submitted_to")); to != nil {
		query.SubmittedTo = to
	}

	_, actorID := currentActor(c)
	userID := uint64(0)
	if actorID != nil {
		userID = *actorID
		query.ActorUserID = actorID
	}
	cacheKey := ""
	if h.cache != nil {
		encodedQuery := c.Request.URL.Query().Encode()
		cacheKey = fmt.Sprintf("api:v1:vulnerabilities:list:u%d:%s", userID, encodedQuery)
		if cached, err := h.cache.Get(c.Request.Context(), cacheKey); err == nil && cached != "" {
			if splitAt := strings.IndexByte(cached, '\n'); splitAt > 0 {
				etag := cached[:splitAt]
				body := cached[splitAt+1:]
				c.Header("ETag", etag)
				c.Header("Cache-Control", "private, max-age=30")
				if httpcache.IfNoneMatchMatches(c.GetHeader("If-None-Match"), etag) {
					c.Status(http.StatusNotModified)
					return
				}
				c.Data(http.StatusOK, "application/json", []byte(body))
				return
			}
		}
	}

	result, err := h.service.List(c.Request.Context(), query)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}
	effectivePage := page
	if result.Page > 0 {
		effectivePage = result.Page
	}
	effectiveSize := pageSize
	if result.PageSize > 0 {
		effectiveSize = result.PageSize
	}
	items := make([]vulnerabilityListItemDTO, len(result.Items))
	itemFingerprints := make([]string, 0, len(result.Items))
	for i := range result.Items {
		actions := h.availableActions(c.Request.Context(), actorID, &result.Items[i])
		items[i] = toVulnListItemDTO(result.Items[i], actions)
		itemFingerprints = append(itemFingerprints, fmt.Sprintf("%d@%s", result.Items[i].ID, result.Items[i].UpdatedAt.UTC().Format(time.RFC3339Nano)))
	}

	data := response.PaginatedResponse{
		Items:      items,
		Total:      result.Total,
		Page:       effectivePage,
		PageSize:   effectiveSize,
		TotalPages: int((result.Total + int64(effectiveSize) - 1) / int64(effectiveSize)),
	}
	etag := httpcache.Checksum(
		"vulnerabilities:list",
		fmt.Sprintf("u=%d", userID),
		c.Request.URL.Query().Encode(),
		fmt.Sprintf("total=%d", result.Total),
		strings.Join(itemFingerprints, ","),
	)
	c.Header("ETag", etag)
	c.Header("Cache-Control", "private, max-age=30")
	if httpcache.IfNoneMatchMatches(c.GetHeader("If-None-Match"), etag) {
		c.Status(http.StatusNotModified)
		return
	}

	apiResp := response.APIResponse{
		Code:      0,
		Message:   "success",
		Data:      data,
		Timestamp: time.Now().Unix(),
	}
	encoded, err := json.Marshal(apiResp)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, "failed to encode response")
		return
	}
	if cacheKey != "" {
		_ = h.cache.Set(c.Request.Context(), cacheKey, etag+"\n"+string(encoded), 30*time.Second)
	}
	c.Data(http.StatusOK, "application/json", encoded)
}

func (h *Handler) Config(c *gin.Context) {
	result, err := h.service.GetConfiguration(c.Request.Context())
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}
	response.Success(c, result)
}

func (h *Handler) UpdateConfig(c *gin.Context) {
	if !currentUserHasPermission(c, "vuln:manage_findings") {
		response.Error(c, http.StatusForbidden, "insufficient permissions")
		return
	}
	var payload struct {
		SLARules                 map[string]int `json:"sla_rules"`
		SubmissionRequiredFields []string       `json:"submission_required_fields"`
	}
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}
	if payload.SubmissionRequiredFields != nil {
		if _, err := vulnservice.NormalizeSubmissionRequiredFields(payload.SubmissionRequiredFields); err != nil {
			response.Error(c, http.StatusBadRequest, err.Error())
			return
		}
	}
	if err := h.service.UpdateSLAConfig(c.Request.Context(), payload.SLARules); err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}
	if payload.SubmissionRequiredFields != nil {
		if err := h.service.UpdateSubmissionRequiredFields(c.Request.Context(), payload.SubmissionRequiredFields); err != nil {
			response.Error(c, http.StatusBadRequest, err.Error())
			return
		}
	}
	result, _ := h.service.GetConfiguration(c.Request.Context())
	response.Success(c, result)
}

// Create submits a new vulnerability.
//
//	@Summary	Submit a vulnerability
//	@Description	Submit a new vulnerability. title, severity_level, status and discoverer_employee_id are always required; the configurable submission_required_fields (see GET /api/v1/vulnerabilities/config) may mark more fields as required. Agent (API Key) constraints: submitter_user_id is forced to the Key owner's user ID (request value ignored), and submitted_at/sla_deadline are forced to server-side defaults (request values ignored). To embed images in description, upload them first via POST /api/v1/vulnerabilities/uploads + POST /api/v1/vulnerabilities/uploads/confirm, then reference each attachment in the HTML with data-sc-attachment-id="<attachment_id>". Requires the vuln:submit_findings permission (for agents: the Key scope must include it).
//	@Tags	vuln
//	@Accept	json
//	@Produce	json
//	@Param	body	body	createVulnerabilityRequest	true	"Vulnerability submission"
//	@Success	200	{object}	response.APIResponse	"Success; data is the created vulnerability (vulnmodel.Vulnerability)"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities [post]
func (h *Handler) Create(c *gin.Context) {
	var payload createVulnerabilityRequest
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	actorName, actorID := currentActor(c)
	if payload.SubmitterUserID == 0 && actorID != nil {
		payload.SubmitterUserID = *actorID
	}
	if payload.SubmitterUserID == 0 {
		response.Error(c, http.StatusBadRequest, "submitter_user_id is required")
		return
	}
	if payload.OwnerEmployeeID == nil && payload.FixerEmployeeID != nil {
		payload.OwnerEmployeeID = payload.FixerEmployeeID
	}
	now := time.Now().UTC()
	submittedAt := payload.SubmittedAt
	if submittedAt == nil {
		submittedAt = &now
	}

	slaDeadline := payload.SLADeadline
	if slaDeadline == nil {
		defaultDeadline := submittedAt.Add(defaultSLADuration(payload.SeverityLevel))
		slaDeadline = &defaultDeadline
	}

	if err := h.service.ValidateSubmissionRequiredFields(c.Request.Context(), vulnservice.SubmissionRequiredFieldValues{
		Title:                payload.Title,
		SeverityLevel:        payload.SeverityLevel,
		Application:          payload.Application,
		CodeRepository:       payload.CodeRepository,
		VulnerabilityType:    payload.VulnerabilityType,
		DiscoveryChannel:     payload.DiscoveryChannel,
		DiscoveryPhase:       payload.DiscoveryPhase,
		NetworkEnv:           payload.NetworkEnv,
		InterfacePath:        payload.InterfacePath,
		Source:               payload.Source,
		AffectedAssetsCount:  &payload.AffectedAssetsCount,
		Tags:                 payload.Tags,
		DependencyName:       payload.DependencyName,
		DependencyVersion:    payload.DependencyVersion,
		DiscovererEmployeeID: payload.DiscovererEmployeeID,
		FixerEmployeeID:      payload.FixerEmployeeID,
		OwnerEmployeeID:      payload.OwnerEmployeeID,
		DepartmentID:         payload.DepartmentID,
		Description:          payload.Description,
		VerificationSteps:    payload.VerificationSteps,
		POCText:              payload.POCText,
	}); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}
	if err := h.service.ValidateEmployeeIDs(c.Request.Context(), payload.DiscovererEmployeeID, payload.FixerEmployeeID, payload.OwnerEmployeeID); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}
	attachmentIDs := extractAttachmentIDsFromHTML(payload.Description)
	if len(attachmentIDs) > 0 {
		if actorID == nil || *actorID == 0 {
			response.Error(c, http.StatusUnauthorized, "invalid token")
			return
		}
		if err := h.service.ValidateDescriptionAttachments(c.Request.Context(), *actorID, 0, attachmentIDs); err != nil {
			h.respondAttachmentBindingError(c, err)
			return
		}
	}

	vuln := &vulnmodel.Vulnerability{
		Title:                payload.Title,
		SeverityLevel:        payload.SeverityLevel,
		Status:               payload.Status,
		Application:          payload.Application,
		CodeRepository:       payload.CodeRepository,
		VulnerabilityType:    payload.VulnerabilityType,
		DiscoveryChannel:     payload.DiscoveryChannel,
		DiscoveryPhase:       payload.DiscoveryPhase,
		NetworkEnv:           payload.NetworkEnv,
		IsThirdParty:         payload.IsThirdParty,
		HasPOCVerification:   payload.HasPOCVerification,
		AffectedAssetsCount:  payload.AffectedAssetsCount,
		Tags:                 vulnmodel.StringArray(payload.Tags),
		DependencyName:       payload.DependencyName,
		DependencyVersion:    payload.DependencyVersion,
		BatchGroupID:         payload.BatchGroupID,
		DiscovererEmployeeID: payload.DiscovererEmployeeID,
		FixerEmployeeID:      payload.FixerEmployeeID,
		OwnerEmployeeID:      payload.OwnerEmployeeID,
		DepartmentID:         payload.DepartmentID,
		SubmitterUserID:      payload.SubmitterUserID,
		InterfacePath:        payload.InterfacePath,
		Source:               payload.Source,
		Description:          payload.Description,
		VerificationSteps:    payload.VerificationSteps,
		POCText:              payload.POCText,
		SubmittedAt:          *submittedAt,
		SLADeadline:          *slaDeadline,
		IsOverdue:            false,
	}

	if err := h.service.Create(c.Request.Context(), vuln); err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}
	_ = h.service.AppendEvent(c.Request.Context(), vuln.ID, "submitted", "Vulnerability submitted", map[string]interface{}{
		"actor_name": actorName,
		"actor_id":   actorID,
	})
	if len(attachmentIDs) > 0 {
		if err := h.service.ValidateAndAttachDescriptionAttachments(c.Request.Context(), *actorID, vuln.ID, attachmentIDs); err != nil {
			h.respondAttachmentBindingError(c, err)
			return
		}
	}
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, vuln)
}

// UploadAttachment presigns a vulnerability image upload.
//
//	@Summary	Presign an image attachment upload
//	@Description	Step 1 of the image attachment flow: create a pending attachment and return its attachment_id plus a presigned upload_url. Step 2: PUT the file bytes directly to upload_url (no API Key needed; the presigned URL itself is the credential). Step 3: call POST /api/v1/vulnerabilities/uploads/confirm with the attachment_id. Images only (png/jpeg/webp/gif; SVG is not allowed), max 10MB — enforced at confirm time. Reference the confirmed attachment in vulnerability descriptions with data-sc-attachment-id.
//	@Tags	vuln
//	@Accept	json
//	@Produce	json
//	@Param	body	body	uploadAttachmentRequest	true	"Filename and content type"
//	@Success	200	{object}	response.APIResponse	"Success; data contains attachment_id, upload_url, key, expires_in and optional view_url"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/uploads [post]
func (h *Handler) UploadAttachment(c *gin.Context) {
	if h.uploader == nil {
		response.Error(c, http.StatusNotImplemented, "file uploads are not enabled")
		return
	}
	var payload uploadAttachmentRequest
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}
	_, actorID := currentActor(c)
	if actorID == nil || *actorID == 0 {
		response.Error(c, http.StatusUnauthorized, "invalid token")
		return
	}

	result, err := h.uploader.PresignPut(c.Request.Context(), payload.Filename, payload.ContentType)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}

	attachment, err := h.service.CreatePendingAttachment(c.Request.Context(), *actorID, h.uploader.Bucket(), result.Key, payload.ContentType)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}

	viewURL := h.uploader.ObjectURL(result.Key)
	viewExpiresIn := int64(0)
	if viewURL == "" {
		if signed, err := h.uploader.PresignGet(c.Request.Context(), result.Key); err == nil {
			viewURL = signed.URL
			viewExpiresIn = signed.ExpiresIn
		}
	}

	response.Success(c, struct {
		AttachmentID  uint64 `json:"attachment_id"`
		UploadURL     string `json:"upload_url"`
		Key           string `json:"key"`
		ExpiresIn     int64  `json:"expires_in"`
		ViewURL       string `json:"view_url,omitempty"`
		ViewExpiresIn int64  `json:"view_expires_in,omitempty"`
	}{
		AttachmentID:  attachment.ID,
		UploadURL:     result.UploadURL,
		Key:           result.Key,
		ExpiresIn:     result.ExpiresIn,
		ViewURL:       viewURL,
		ViewExpiresIn: viewExpiresIn,
	})
}

// ConfirmAttachmentUpload confirms a vulnerability image upload.
//
//	@Summary	Confirm an image attachment upload
//	@Description	Step 3 of the image attachment flow: validate the uploaded object and mark the attachment ready. Only the original uploader may confirm it. Images only (content type png/jpeg/webp/gif; SVG is not allowed) and size at most 10MB; a non-compliant object is deleted and the attachment is rejected.
//	@Tags	vuln
//	@Accept	json
//	@Produce	json
//	@Param	body	body	confirmAttachmentUploadRequest	true	"Attachment ID returned by the presign step"
//	@Success	200	{object}	response.APIResponse	"Success; data is the confirmed attachment (vulnmodel.VulnerabilityAttachment) plus optional view_url"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/uploads/confirm [post]
func (h *Handler) ConfirmAttachmentUpload(c *gin.Context) {
	if h.uploader == nil {
		response.Error(c, http.StatusNotImplemented, "file uploads are not enabled")
		return
	}
	_, actorID := currentActor(c)
	if actorID == nil || *actorID == 0 {
		response.Error(c, http.StatusUnauthorized, "invalid token")
		return
	}

	var payload confirmAttachmentUploadRequest
	if err := c.ShouldBindJSON(&payload); err != nil || payload.AttachmentID == 0 {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}

	item, err := h.service.GetAttachment(c.Request.Context(), payload.AttachmentID)
	if err != nil {
		response.Error(c, http.StatusNotFound, "not found")
		return
	}
	if item.UploaderID != *actorID {
		response.Error(c, http.StatusForbidden, "forbidden")
		return
	}

	info, err := h.uploader.Head(c.Request.Context(), item.S3Key)
	if err != nil {
		response.Error(c, http.StatusBadRequest, "upload not found")
		return
	}

	if !isAllowedImageContentType(info.ContentType) {
		_ = h.uploader.Delete(c.Request.Context(), item.S3Key)
		_ = h.service.RejectAttachment(c.Request.Context(), *actorID, payload.AttachmentID)
		response.Error(c, http.StatusBadRequest, "unsupported image type")
		return
	}

	const maxBytes = 10 * 1024 * 1024
	if info.SizeBytes <= 0 || info.SizeBytes > maxBytes {
		_ = h.uploader.Delete(c.Request.Context(), item.S3Key)
		_ = h.service.RejectAttachment(c.Request.Context(), *actorID, payload.AttachmentID)
		response.Error(c, http.StatusBadRequest, "image too large (max 10MB)")
		return
	}

	confirmed, err := h.service.ConfirmAttachment(c.Request.Context(), *actorID, payload.AttachmentID, info.ContentType, info.SizeBytes)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}

	viewURL := h.uploader.ObjectURL(item.S3Key)
	viewExpiresIn := int64(0)
	if viewURL == "" {
		if signed, err := h.uploader.PresignGet(c.Request.Context(), item.S3Key); err == nil {
			viewURL = signed.URL
			viewExpiresIn = signed.ExpiresIn
		}
	}

	response.Success(c, struct {
		*vulnmodel.VulnerabilityAttachment
		ViewURL       string `json:"view_url,omitempty"`
		ViewExpiresIn int64  `json:"view_expires_in,omitempty"`
	}{
		VulnerabilityAttachment: confirmed,
		ViewURL:                 viewURL,
		ViewExpiresIn:           viewExpiresIn,
	})
}

func (h *Handler) GetAttachmentContent(c *gin.Context) {
	if h.uploader == nil {
		response.Error(c, http.StatusNotImplemented, "file uploads are not enabled")
		return
	}
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil || id == 0 {
		response.Error(c, http.StatusBadRequest, "invalid id")
		return
	}

	item, err := h.service.GetAttachment(c.Request.Context(), id)
	if err != nil {
		response.Error(c, http.StatusNotFound, "not found")
		return
	}
	if item.Status != vulnmodel.AttachmentStatusReady || item.VulnerabilityID == nil || *item.VulnerabilityID == 0 {
		response.Error(c, http.StatusNotFound, "not found")
		return
	}
	if err := h.ensureCanView(c, *item.VulnerabilityID); err != nil {
		return
	}

	body, objInfo, err := h.uploader.Get(c.Request.Context(), item.S3Key)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}
	defer body.Close()

	if objInfo == nil || !isAllowedImageContentType(objInfo.ContentType) {
		response.Error(c, http.StatusBadRequest, "invalid image")
		return
	}
	c.Header("Cache-Control", "private, max-age=60")
	c.DataFromReader(http.StatusOK, objInfo.SizeBytes, objInfo.ContentType, body, nil)
}

func (h *Handler) GetAttachmentURL(c *gin.Context) {
	if h.uploader == nil {
		response.Error(c, http.StatusNotImplemented, "file uploads are not enabled")
		return
	}
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil || id == 0 {
		response.Error(c, http.StatusBadRequest, "invalid id")
		return
	}

	item, err := h.service.GetAttachment(c.Request.Context(), id)
	if err != nil {
		response.Error(c, http.StatusNotFound, "not found")
		return
	}
	if item.Status != vulnmodel.AttachmentStatusReady || item.VulnerabilityID == nil || *item.VulnerabilityID == 0 {
		response.Error(c, http.StatusNotFound, "not found")
		return
	}
	if err := h.ensureCanView(c, *item.VulnerabilityID); err != nil {
		return
	}
	if item.ContentType != "" && !isAllowedImageContentType(item.ContentType) {
		response.Error(c, http.StatusBadRequest, "invalid image")
		return
	}

	viewURL := h.uploader.ObjectURL(item.S3Key)
	expiresIn := int64(0)
	if viewURL == "" {
		signed, err := h.uploader.PresignGet(c.Request.Context(), item.S3Key)
		if err != nil {
			response.Error(c, http.StatusInternalServerError, err.Error())
			return
		}
		viewURL = signed.URL
		expiresIn = signed.ExpiresIn
	}

	response.Success(c, struct {
		URL       string `json:"url"`
		ExpiresIn int64  `json:"expires_in"`
	}{
		URL:       viewURL,
		ExpiresIn: expiresIn,
	})
}

// Get returns the vulnerability detail.
//
//	@Summary	Get vulnerability detail
//	@Description	Get a single vulnerability by public_id; sensitive fields are trimmed according to the actions available to the current user.
//	@Tags	vuln
//	@Produce	json
//	@Param	public_id	path	string	true	"Vulnerability public ID"
//	@Success	200	{object}	response.APIResponse	"Success; data contains vulnerability (vulnmodel.Vulnerability) and actions ([]string)"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/{public_id} [get]
func (h *Handler) Get(c *gin.Context) {
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	if err := h.ensureCanView(c, vuln.ID); err != nil {
		return
	}

	actions := []string{"view"}
	if actorID := currentUserIDFromVulnClaims(c); actorID != nil {
		if resolved, actionsErr := h.service.AvailableActions(c.Request.Context(), *actorID, vuln); actionsErr == nil {
			actions = resolved
		}
	}
	sanitized := *vuln
	actionSet := make(map[string]struct{}, len(actions))
	for _, action := range actions {
		actionSet[action] = struct{}{}
	}
	_, canViewSensitive := actionSet["view_sensitive_details"]
	_, canEditSensitive := actionSet["edit_sensitive_details"]
	_, canEditDetails := actionSet["edit_details"]
	if !canViewSensitive && !canEditSensitive && !canEditDetails {
		sanitized.VerificationSteps = ""
		sanitized.POCText = ""
	}
	if _, ok := actionSet["authorize_viewers"]; !ok {
		sanitized.AuthorizedViewerEmployeeIDs = vulnmodel.UInt64Array{}
	}

	response.Success(c, struct {
		Vulnerability vulnmodel.Vulnerability `json:"vulnerability"`
		Actions       []string                `json:"actions"`
	}{
		Vulnerability: sanitized,
		Actions:       actions,
	})
}

// ListEvents returns the vulnerability change event stream.
//
//	@Summary	Get vulnerability change events
//	@Description	Get lifecycle events (status transitions, field changes, etc.) of a vulnerability by public_id.
//	@Tags	vuln
//	@Produce	json
//	@Param	public_id	path	string	true	"Vulnerability public ID"
//	@Success	200	{object}	response.APIResponse	"Success; data is an array of vulnerability events (vulnmodel.VulnerabilityEvent)"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/{public_id}/events [get]
func (h *Handler) ListEvents(c *gin.Context) {
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	if err := h.ensureCanView(c, vuln.ID); err != nil {
		return
	}
	events, err := h.service.ListEvents(c.Request.Context(), vuln.ID)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}
	response.Success(c, events)
}

// TransitionStatus transitions the vulnerability status.
//
//	@Summary	Transition vulnerability status
//	@Description	Transition a vulnerability by public_id; the body field status is the target status and selects one of five actions: retesting (=request_retest, submit fix for retest: only from active, and the operator must be the vulnerability's fixer; agent Keys additionally need the vuln:request_retest scope), fixed (=mark_fixed, verification passed: only from retesting), false_positive (=mark_false_positive) or risk_accepted (=mark_risk_accepted) (from active or retesting), active (=reopen: from fixed, false_positive or risk_accepted). mark_fixed/mark_false_positive/mark_risk_accepted/reopen require vuln:close_findings (for agents, the Key scope must include it). Every transition is reversible via reopen; message is recorded in the event stream.
//	@Tags	vuln
//	@Accept	json
//	@Produce	json
//	@Param	public_id	path	string	true	"Vulnerability public ID"
//	@Param	body	body	transitionStatusRequest	true	"Target status and optional message"
//	@Success	200	{object}	response.APIResponse	"Success; data is the transitioned vulnerability (vulnmodel.Vulnerability)"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/{public_id}/transition [post]
func (h *Handler) TransitionStatus(c *gin.Context) {
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	var payload transitionStatusRequest
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}

	actorName, actorID := currentActor(c)

	vuln, err := h.service.TransitionStatus(c.Request.Context(), vuln.ID, payload.Status, payload.Message, actorName, actorID)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}

	h.invalidateListCaches(c.Request.Context())
	response.Success(c, vuln)
}

func (h *Handler) UpdateMeta(c *gin.Context) {
	var payload struct {
		OwnerEmployeeID   *uint64  `json:"owner_employee_id"`
		Tags              []string `json:"tags"`
		VerificationSteps *string  `json:"verification_steps"`
		POCText           *string  `json:"poc_text"`
	}
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}

	actorName, actorID := currentActor(c)

	if err := h.service.ValidateEmployeeIDs(c.Request.Context(), payload.OwnerEmployeeID); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	vuln, err := h.service.UpdateMeta(
		c.Request.Context(),
		vuln.ID,
		payload.OwnerEmployeeID,
		payload.Tags,
		payload.VerificationSteps,
		payload.POCText,
		actorName,
		actorID,
	)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, vuln)
}

// Update edits vulnerability details.
//
//	@Summary	Update vulnerability details
//	@Description	Partially update a vulnerability by public_id; only fields present in the body are changed (an explicit null clears a nullable field). Requires vuln:edit_details (for agents, the Key scope must include it). Agent (API Key) constraint: any change to poc_text, verification_steps, has_poc_verification, submitted_at or sla_deadline is rejected with 403 for the whole request. To embed new images in description, upload them first via POST /api/v1/vulnerabilities/uploads + POST /api/v1/vulnerabilities/uploads/confirm and reference them with data-sc-attachment-id.
//	@Tags	vuln
//	@Accept	json
//	@Produce	json
//	@Param	public_id	path	string	true	"Vulnerability public ID"
//	@Param	body	body	updateVulnerabilityPayload	true	"Fields to update"
//	@Success	200	{object}	response.APIResponse	"Success; data is the updated vulnerability (vulnmodel.Vulnerability)"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/{public_id} [put]
func (h *Handler) Update(c *gin.Context) {
	var payload updateVulnerabilityPayload
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}

	if payload.Title.Set && (payload.Title.Value == nil || strings.TrimSpace(*payload.Title.Value) == "") {
		response.Error(c, http.StatusBadRequest, "title is required")
		return
	}
	if payload.SeverityLevel.Set && payload.SeverityLevel.Value == "" {
		response.Error(c, http.StatusBadRequest, "severity_level is required")
		return
	}
	if payload.DiscovererEmployeeID.Set && payload.DiscovererEmployeeID.Value == nil {
		response.Error(c, http.StatusBadRequest, "discoverer_employee_id is required")
		return
	}
	if payload.SubmittedAt.Set && payload.SubmittedAt.Value == nil {
		response.Error(c, http.StatusBadRequest, "submitted_at is required")
		return
	}
	if payload.SLADeadline.Set && payload.SLADeadline.Value == nil {
		response.Error(c, http.StatusBadRequest, "sla_deadline is required")
		return
	}

	actorName, actorID := currentActor(c)
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	var attachmentIDs []uint64
	if payload.Description.Set && payload.Description.Value != nil {
		attachmentIDs = extractAttachmentIDsFromHTML(*payload.Description.Value)
		if len(attachmentIDs) > 0 {
			if actorID == nil || *actorID == 0 {
				response.Error(c, http.StatusUnauthorized, "invalid token")
				return
			}
			if err := h.service.ValidateDescriptionAttachments(c.Request.Context(), *actorID, vuln.ID, attachmentIDs); err != nil {
				h.respondAttachmentBindingError(c, err)
				return
			}
		}
	}
	input := vulnservice.UpdateVulnerabilityInput{
		Title:                payload.Title.Value,
		SeverityLevel:        payload.SeverityLevel.Ptr(),
		Application:          payload.Application.Value,
		CodeRepository:       payload.CodeRepository.Value,
		VulnerabilityType:    payload.VulnerabilityType.Value,
		DiscoveryChannel:     payload.DiscoveryChannel.Value,
		DiscoveryPhase:       payload.DiscoveryPhase.Value,
		NetworkEnv:           payload.NetworkEnv.Value,
		InterfacePath:        payload.InterfacePath.Value,
		Source:               payload.Source.Value,
		AffectedAssetsCount:  payload.AffectedAssetsCount.Ptr(),
		Tags:                 payload.Tags.Ptr(),
		HasPOCVerification:   payload.HasPOCVerification.Ptr(),
		IsThirdParty:         payload.IsThirdParty.Ptr(),
		DependencyName:       payload.DependencyName.Value,
		DependencyVersion:    payload.DependencyVersion.Value,
		DiscovererEmployeeID: payload.DiscovererEmployeeID.Value,
		FixerEmployeeID:      payload.FixerEmployeeID.Value,
		FixerEmployeeIDSet:   payload.FixerEmployeeID.Set,
		OwnerEmployeeID:      payload.OwnerEmployeeID.Value,
		OwnerEmployeeIDSet:   payload.OwnerEmployeeID.Set,
		DepartmentID:         payload.DepartmentID.Value,
		DepartmentIDSet:      payload.DepartmentID.Set,
		Description:          payload.Description.Value,
		VerificationSteps:    payload.VerificationSteps.Value,
		POCText:              payload.POCText.Value,
		SubmittedAt:          payload.SubmittedAt.Value,
		SLADeadline:          payload.SLADeadline.Value,
	}

	vuln, err := h.service.UpdateVulnerability(c.Request.Context(), vuln.ID, input, actorName, actorID)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	if len(attachmentIDs) > 0 {
		if err := h.service.ValidateAndAttachDescriptionAttachments(c.Request.Context(), *actorID, vuln.ID, attachmentIDs); err != nil {
			h.respondAttachmentBindingError(c, err)
			return
		}
	}
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, vuln)
}

func (h *Handler) respondAttachmentBindingError(c *gin.Context, err error) {
	if errors.Is(err, vulnservice.ErrInvalidAttachmentReference) {
		response.Error(c, http.StatusBadRequest, vulnservice.ErrInvalidAttachmentReference.Error())
		return
	}
	response.Error(c, http.StatusInternalServerError, err.Error())
}

func (h *Handler) UpdateAuthorizedViewers(c *gin.Context) {
	var payload struct {
		AuthorizedViewerEmployeeIDs []uint64 `json:"authorized_viewer_employee_ids"`
	}
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}
	actorName, actorID := currentActor(c)
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	updated, err := h.service.UpdateAuthorizedViewers(
		c.Request.Context(),
		vuln.ID,
		payload.AuthorizedViewerEmployeeIDs,
		actorName,
		actorID,
	)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, updated)
}

func (h *Handler) UpdatePublicVisibility(c *gin.Context) {
	var payload struct {
		IsPublicViewable bool `json:"is_public_viewable"`
	}
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}
	actorName, actorID := currentActor(c)
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	updated, err := h.service.UpdatePublicVisibility(
		c.Request.Context(),
		vuln.ID,
		payload.IsPublicViewable,
		actorName,
		actorID,
	)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, updated)
}

// SearchAuthorizedViewerCandidates searches authorized viewer candidates.
//
//	@Summary	Search authorized viewer candidates
//	@Description	Search employee candidates (name/email/department, etc.) that can be granted as authorized viewers of the vulnerability, by public_id.
//	@Tags	vuln
//	@Produce	json
//	@Param	public_id	path	string	true	"Vulnerability public ID"
//	@Param	q	query	string	false	"Keyword (name/email)"
//	@Param	ids	query	string	false	"Employee ID list (comma-separated)"
//	@Success	200	{object}	response.APIResponse{data=[]authorizedViewerCandidateDTO}	"Success; data is an array of employee candidates"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/{public_id}/viewers/candidates [get]
func (h *Handler) SearchAuthorizedViewerCandidates(c *gin.Context) {
	_, actorID := currentActor(c)
	if err := h.service.EnsureCanSearchAuthorizedViewerCandidates(c.Request.Context(), actorID); err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	employees, err := h.service.SearchAuthorizedViewerCandidates(c.Request.Context(), vuln.ID, c.Query("q"), parseUint64CSV(c.Query("ids")), actorID)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	response.Success(c, toAuthorizedViewerCandidateDTOs(employees))
}

func toAuthorizedViewerCandidateDTOs(employees []directory.Employee) []authorizedViewerCandidateDTO {
	if len(employees) == 0 {
		return []authorizedViewerCandidateDTO{}
	}
	out := make([]authorizedViewerCandidateDTO, 0, len(employees))
	for _, employee := range employees {
		out = append(out, authorizedViewerCandidateDTO{
			ID:             employee.ID,
			Name:           employee.Name,
			Email:          employee.Email,
			AvatarURL:      employee.AvatarURL,
			DepartmentID:   employee.DepartmentID,
			DepartmentName: employee.DepartmentName,
			JobTitle:       employee.JobTitle,
		})
	}
	return out
}

type nullableStringField struct {
	Set   bool
	Value *string
}

func (f *nullableStringField) UnmarshalJSON(data []byte) error {
	f.Set = true
	if string(data) == "null" {
		f.Value = nil
		return nil
	}
	var value string
	if err := json.Unmarshal(data, &value); err != nil {
		return err
	}
	f.Value = &value
	return nil
}

type nullableUint64Field struct {
	Set   bool
	Value *uint64
}

func (f *nullableUint64Field) UnmarshalJSON(data []byte) error {
	f.Set = true
	if string(data) == "null" {
		f.Value = nil
		return nil
	}
	var value uint64
	if err := json.Unmarshal(data, &value); err != nil {
		return err
	}
	f.Value = &value
	return nil
}

type optionalBoolField struct {
	Set   bool
	Value bool
}

func (f *optionalBoolField) UnmarshalJSON(data []byte) error {
	f.Set = true
	return json.Unmarshal(data, &f.Value)
}

func (f optionalBoolField) Ptr() *bool {
	if !f.Set {
		return nil
	}
	return &f.Value
}

type optionalIntField struct {
	Set   bool
	Value int
}

func (f *optionalIntField) UnmarshalJSON(data []byte) error {
	f.Set = true
	return json.Unmarshal(data, &f.Value)
}

func (f optionalIntField) Ptr() *int {
	if !f.Set {
		return nil
	}
	return &f.Value
}

type optionalStringSliceField struct {
	Set   bool
	Value []string
}

func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
	f.Set = true
	return json.Unmarshal(data, &f.Value)
}

func (f optionalStringSliceField) Ptr() *[]string {
	if !f.Set {
		return nil
	}
	return &f.Value
}

type optionalTimeField struct {
	Set   bool
	Value *time.Time
}

func (f *optionalTimeField) UnmarshalJSON(data []byte) error {
	f.Set = true
	if string(data) == "null" {
		f.Value = nil
		return nil
	}
	var value time.Time
	if err := json.Unmarshal(data, &value); err != nil {
		return err
	}
	f.Value = &value
	return nil
}

type optionalSeverityLevelField struct {
	Set   bool
	Value vulnmodel.SeverityLevel
}

func (f *optionalSeverityLevelField) UnmarshalJSON(data []byte) error {
	f.Set = true
	return json.Unmarshal(data, &f.Value)
}

func (f optionalSeverityLevelField) Ptr() *vulnmodel.SeverityLevel {
	if !f.Set {
		return nil
	}
	return &f.Value
}

// createVulnerabilityRequest is the JSON body of POST /api/v1/vulnerabilities.
type createVulnerabilityRequest struct {
	Title                string                        `json:"title" binding:"required"`
	SeverityLevel        vulnmodel.SeverityLevel       `json:"severity_level" binding:"required"`
	Status               vulnmodel.VulnerabilityStatus `json:"status" binding:"required"`
	Application          string                        `json:"application"`
	CodeRepository       string                        `json:"code_repository"`
	VulnerabilityType    string                        `json:"vulnerability_type"`
	DiscoveryChannel     string                        `json:"discovery_channel"`
	DiscoveryPhase       string                        `json:"discovery_phase"`
	NetworkEnv           string                        `json:"network_env"`
	IsThirdParty         bool                          `json:"is_third_party"`
	HasPOCVerification   bool                          `json:"has_poc_verification"`
	AffectedAssetsCount  int                           `json:"affected_assets_count"`
	Tags                 []string                      `json:"tags"`
	DependencyName       string                        `json:"dependency_name"`
	DependencyVersion    string                        `json:"dependency_version"`
	BatchGroupID         *uint64                       `json:"batch_group_id"`
	DiscovererEmployeeID *uint64                       `json:"discoverer_employee_id" binding:"required"`
	FixerEmployeeID      *uint64                       `json:"fixer_employee_id"`
	OwnerEmployeeID      *uint64                       `json:"owner_employee_id"`
	DepartmentID         *string                       `json:"department_id"`
	SubmitterUserID      uint64                        `json:"submitter_user_id"`
	InterfacePath        string                        `json:"interface_path"`
	Source               string                        `json:"source"`
	Description          string                        `json:"description"`
	VerificationSteps    string                        `json:"verification_steps"`
	POCText              string                        `json:"poc_text"`
	SLADeadline          *time.Time                    `json:"sla_deadline"`
	SubmittedAt          *time.Time                    `json:"submitted_at"`
}

// uploadAttachmentRequest is the JSON body of POST /api/v1/vulnerabilities/uploads.
type uploadAttachmentRequest struct {
	Filename    string `json:"filename" binding:"required"`
	ContentType string `json:"content_type"`
}

// confirmAttachmentUploadRequest is the JSON body of
// POST /api/v1/vulnerabilities/uploads/confirm.
type confirmAttachmentUploadRequest struct {
	AttachmentID uint64 `json:"attachment_id" binding:"required"`
}

// transitionStatusRequest is the JSON body of
// POST /api/v1/vulnerabilities/:public_id/transition.
type transitionStatusRequest struct {
	Status  vulnmodel.VulnerabilityStatus `json:"status" binding:"required"`
	Message string                        `json:"message"`
}

// addCommentRequest is the JSON body of
// POST /api/v1/vulnerabilities/:public_id/comments.
type addCommentRequest struct {
	Content string `json:"content" binding:"required"`
}

// updateVulnerabilityPayload is the JSON body of PUT
// /api/v1/vulnerabilities/:public_id. The wrapper field types distinguish
// "field absent" from "explicit null"; the swaggertype tags document the wire
// type of each field so the OpenAPI contract shows plain JSON scalars.
type updateVulnerabilityPayload struct {
	Title                nullableStringField        `json:"title" swaggertype:"string"`
	SeverityLevel        optionalSeverityLevelField `json:"severity_level" swaggertype:"string"`
	Application          nullableStringField        `json:"application" swaggertype:"string"`
	CodeRepository       nullableStringField        `json:"code_repository" swaggertype:"string"`
	VulnerabilityType    nullableStringField        `json:"vulnerability_type" swaggertype:"string"`
	DiscoveryChannel     nullableStringField        `json:"discovery_channel" swaggertype:"string"`
	DiscoveryPhase       nullableStringField        `json:"discovery_phase" swaggertype:"string"`
	NetworkEnv           nullableStringField        `json:"network_env" swaggertype:"string"`
	InterfacePath        nullableStringField        `json:"interface_path" swaggertype:"string"`
	Source               nullableStringField        `json:"source" swaggertype:"string"`
	AffectedAssetsCount  optionalIntField           `json:"affected_assets_count" swaggertype:"integer"`
	Tags                 optionalStringSliceField   `json:"tags" swaggertype:"array,string"`
	HasPOCVerification   optionalBoolField          `json:"has_poc_verification" swaggertype:"boolean"`
	IsThirdParty         optionalBoolField          `json:"is_third_party" swaggertype:"boolean"`
	DependencyName       nullableStringField        `json:"dependency_name" swaggertype:"string"`
	DependencyVersion    nullableStringField        `json:"dependency_version" swaggertype:"string"`
	DiscovererEmployeeID nullableUint64Field        `json:"discoverer_employee_id" swaggertype:"integer"`
	FixerEmployeeID      nullableUint64Field        `json:"fixer_employee_id" swaggertype:"integer"`
	OwnerEmployeeID      nullableUint64Field        `json:"owner_employee_id" swaggertype:"integer"`
	DepartmentID         nullableStringField        `json:"department_id" swaggertype:"string"`
	Description          nullableStringField        `json:"description" swaggertype:"string"`
	VerificationSteps    nullableStringField        `json:"verification_steps" swaggertype:"string"`
	POCText              nullableStringField        `json:"poc_text" swaggertype:"string"`
	SubmittedAt          optionalTimeField          `json:"submitted_at" swaggertype:"string"`
	SLADeadline          optionalTimeField          `json:"sla_deadline" swaggertype:"string"`
}

func currentActor(c *gin.Context) (string, *uint64) {
	// agent 请求归因到具体 Key (plan 002 §3.5 缺口二): 作者显示 AgentName, 空则回退 agent:<AgentID>;
	// actorID 仍为 Key 主人的 UserID, 与人类请求口径一致。
	if claims, ok := middleware.AgentClaimsFromContext(c.Request.Context()); ok {
		name := claims.AgentName
		if name == "" {
			name = fmt.Sprintf("agent:%d", claims.AgentID)
		}
		return name, &claims.UserID
	}
	claimsValue, exists := c.Get("current_user")
	if !exists {
		return "System", nil
	}
	if claims, ok := claimsValue.(*jwt.Claims); ok && claims != nil {
		return claims.Email, &claims.UserID
	}
	return "System", nil
}

func currentUserHasPermission(c *gin.Context, permission string) bool {
	claimsValue, exists := c.Get("current_user")
	if !exists {
		return false
	}
	claims, ok := claimsValue.(*jwt.Claims)
	if !ok || claims == nil {
		return false
	}
	if hasRole(claims.Roles, "admin") {
		return true
	}
	for _, candidate := range claims.Permissions {
		if candidate == permission {
			return true
		}
	}
	return false
}

var attachmentIDAttrRe = regexp.MustCompile(`data-sc-attachment-id\s*=\s*["'](\d+)["']`)

func extractAttachmentIDsFromHTML(html string) []uint64 {
	if html == "" {
		return nil
	}
	matches := attachmentIDAttrRe.FindAllStringSubmatch(html, -1)
	if len(matches) == 0 {
		return nil
	}
	ids := make([]uint64, 0, len(matches))
	seen := map[uint64]struct{}{}
	for _, m := range matches {
		if len(m) < 2 {
			continue
		}
		id, err := strconv.ParseUint(m[1], 10, 64)
		if err != nil || id == 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		ids = append(ids, id)
	}
	return ids
}

func isAllowedImageContentType(ct string) bool {
	switch strings.ToLower(strings.TrimSpace(ct)) {
	case "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif":
		return true
	default:
		return false
	}
}

// ListComments lists vulnerability comments.
//
//	@Summary	List vulnerability comments
//	@Description	Get comments of a vulnerability by public_id.
//	@Tags	vuln
//	@Produce	json
//	@Param	public_id	path	string	true	"Vulnerability public ID"
//	@Success	200	{object}	response.APIResponse{data=[]vulnmodel.VulnerabilityComment}	"Success; data is an array of comments"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/{public_id}/comments [get]
func (h *Handler) ListComments(c *gin.Context) {
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	if err := h.ensureCanView(c, vuln.ID); err != nil {
		return
	}
	comments, err := h.service.ListComments(c.Request.Context(), vuln.ID)
	if err != nil {
		response.Error(c, http.StatusInternalServerError, err.Error())
		return
	}
	response.Success(c, comments)
}

// AddComment adds a comment to a vulnerability.
//
//	@Summary	Add a vulnerability comment
//	@Description	Add a comment to a vulnerability by public_id. The operator must be the vulnerability's submitter, its fixer, or hold a security role; agent (API Key) comments are attributed to the Key name in the event stream.
//	@Tags	vuln
//	@Accept	json
//	@Produce	json
//	@Param	public_id	path	string	true	"Vulnerability public ID"
//	@Param	body	body	addCommentRequest	true	"Comment content"
//	@Success	200	{object}	response.APIResponse	"Success; data is the created comment (vulnmodel.VulnerabilityComment)"
//	@Security	agentApiKey
//	@Security	bearerAuth
//	@Router	/api/v1/vulnerabilities/{public_id}/comments [post]
func (h *Handler) AddComment(c *gin.Context) {
	var payload addCommentRequest
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}

	actorName, actorID := currentActor(c)
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	if err := h.ensureCanView(c, vuln.ID); err != nil {
		return
	}

	comment, err := h.service.AddComment(c.Request.Context(), vuln.ID, actorName, actorID, payload.Content)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	_ = h.service.AppendEvent(c.Request.Context(), vuln.ID, "comment_added", "Comment added", map[string]interface{}{
		"actor_name": actorName,
		"actor_id":   actorID,
		"comment_id": comment.ID,
	})
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, comment)
}

func (h *Handler) Claim(c *gin.Context) {
	actorName, actorID := currentActor(c)
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	vuln, err := h.service.Claim(c.Request.Context(), vuln.ID, actorName, actorID)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, vuln)
}

func (h *Handler) Assign(c *gin.Context) {
	var payload struct {
		OwnerEmployeeID *uint64 `json:"owner_employee_id" binding:"required"`
	}
	if err := c.ShouldBindJSON(&payload); err != nil {
		response.Error(c, http.StatusBadRequest, "invalid payload")
		return
	}
	actorName, actorID := currentActor(c)
	if err := h.service.ValidateEmployeeIDs(c.Request.Context(), payload.OwnerEmployeeID); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}
	vuln := h.loadVulnerabilityByPublicID(c)
	if vuln == nil {
		return
	}
	updated, err := h.service.Assign(c.Request.Context(), vuln.ID, payload.OwnerEmployeeID, actorName, actorID)
	if err != nil {
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return
	}
	h.invalidateListCaches(c.Request.Context())
	response.Success(c, updated)
}

func (h *Handler) invalidateListCaches(ctx context.Context) {
	if h.cache == nil {
		return
	}
	_ = h.cache.InvalidateByPattern(ctx, "api:v1:vulnerabilities:list:*")
}

func parseCSV(value string) []string {
	if value == "" {
		return nil
	}
	parts := strings.Split(value, ",")
	result := make([]string, 0, len(parts))
	for _, part := range parts {
		if trimmed := strings.TrimSpace(part); trimmed != "" {
			result = append(result, trimmed)
		}
	}
	return result
}

func getMultiValueParam(c *gin.Context, key string) []string {
	values := c.QueryArray(key)
	if len(values) == 0 {
		return parseCSV(c.Query(key))
	}
	var combined []string
	for _, value := range values {
		combined = append(combined, parseCSV(value)...)
	}
	return combined
}

func parseIntDefault(value string, fallback int) int {
	if value == "" {
		return fallback
	}
	if n, err := strconv.Atoi(value); err == nil && n > 0 {
		return n
	}
	return fallback
}

func parseUint64Pointer(value string) *uint64 {
	if value == "" {
		return nil
	}
	n, err := strconv.ParseUint(value, 10, 64)
	if err != nil {
		return nil
	}
	u := uint64(n)
	return &u
}

func parseUint64CSV(value string) []uint64 {
	parts := parseCSV(value)
	if len(parts) == 0 {
		return []uint64{}
	}
	out := make([]uint64, 0, len(parts))
	seen := make(map[uint64]struct{}, len(parts))
	for _, part := range parts {
		n, err := strconv.ParseUint(part, 10, 64)
		if err != nil || n == 0 {
			continue
		}
		if _, ok := seen[n]; ok {
			continue
		}
		seen[n] = struct{}{}
		out = append(out, n)
	}
	return out
}

func parseBoolPointer(value string) *bool {
	if value == "" {
		return nil
	}
	switch strings.ToLower(value) {
	case "true", "1", "yes":
		v := true
		return &v
	case "false", "0", "no":
		v := false
		return &v
	default:
		return nil
	}
}

func parseTimePointer(value string) *time.Time {
	if value == "" {
		return nil
	}
	layouts := []string{time.RFC3339, "2006-01-02"}
	for _, layout := range layouts {
		if t, err := time.Parse(layout, value); err == nil {
			return &t
		}
	}
	return nil
}

func toStatuses(values []string) []vulnmodel.VulnerabilityStatus {
	if len(values) == 0 {
		return nil
	}
	statuses := make([]vulnmodel.VulnerabilityStatus, 0, len(values))
	for _, v := range values {
		statuses = append(statuses, vulnmodel.VulnerabilityStatus(v))
	}
	return statuses
}

func toSeverities(values []string) []vulnmodel.SeverityLevel {
	if len(values) == 0 {
		return nil
	}
	severities := make([]vulnmodel.SeverityLevel, 0, len(values))
	for _, v := range values {
		severities = append(severities, vulnmodel.SeverityLevel(v))
	}
	return severities
}

func defaultSLADuration(severity vulnmodel.SeverityLevel) time.Duration {
	switch severity {
	case vulnmodel.SeverityCritical:
		return 24 * time.Hour
	case vulnmodel.SeverityHigh:
		return 72 * time.Hour
	case vulnmodel.SeverityMedium:
		return 7 * 24 * time.Hour
	default:
		return 14 * 24 * time.Hour
	}
}

func (h *Handler) availableActions(ctx context.Context, actorID *uint64, v *vulnmodel.Vulnerability) []string {
	if v == nil {
		return []string{}
	}
	if actorID == nil || *actorID == 0 {
		return []string{"view"}
	}
	actions, err := h.service.AvailableActions(ctx, *actorID, v)
	if err != nil || len(actions) == 0 {
		return []string{"view"}
	}
	return actions
}

func currentUserIDFromVulnClaims(c *gin.Context) *uint64 {
	_, actorID := currentActor(c)
	return actorID
}

func (h *Handler) loadVulnerabilityByPublicID(c *gin.Context) *vulnmodel.Vulnerability {
	publicID := strings.TrimSpace(c.Param("public_id"))
	if publicID == "" {
		response.Error(c, http.StatusBadRequest, "invalid public_id")
		return nil
	}

	vuln, err := h.service.GetByPublicID(c.Request.Context(), publicID)
	if err != nil {
		if strings.Contains(err.Error(), "record not found") {
			response.Error(c, http.StatusNotFound, "not found")
		} else if strings.Contains(err.Error(), "public_id required") {
			response.Error(c, http.StatusBadRequest, "invalid public_id")
		} else {
			response.Error(c, http.StatusInternalServerError, err.Error())
		}
		return nil
	}
	return vuln
}

func (h *Handler) ensureCanView(c *gin.Context, vulnID uint64) error {
	actorID := currentUserIDFromVulnClaims(c)
	if actorID == nil || *actorID == 0 {
		response.Error(c, http.StatusForbidden, "not authorized to view vulnerability")
		return fmt.Errorf("not authorized to view vulnerability")
	}
	canView, err := h.service.CanViewVulnerability(c.Request.Context(), *actorID, vulnID)
	if err != nil {
		if strings.Contains(err.Error(), "record not found") {
			response.Error(c, http.StatusNotFound, "not found")
			return err
		}
		response.Error(c, vulnerabilityErrorStatus(err), err.Error())
		return err
	}
	if !canView {
		response.Error(c, http.StatusForbidden, "not authorized to view vulnerability")
		return fmt.Errorf("not authorized to view vulnerability")
	}
	return nil
}

func vulnerabilityErrorStatus(err error) int {
	if err == nil {
		return http.StatusOK
	}
	switch {
	case strings.Contains(err.Error(), "record not found"), strings.Contains(err.Error(), "not found"):
		return http.StatusNotFound
	case strings.Contains(err.Error(), "not authorized"),
		strings.Contains(err.Error(), "employee link required"),
		strings.Contains(err.Error(), "actor required"),
		strings.Contains(err.Error(), "org sync snapshot is expired"):
		return http.StatusForbidden
	default:
		return http.StatusInternalServerError
	}
}
