package api

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"git.jabali-panel.com/shukivaknin/jabali2/panel-api/internal/agent"
	"git.jabali-panel.com/shukivaknin/jabali2/panel-api/internal/ginctx"
	"git.jabali-panel.com/shukivaknin/jabali2/panel-api/internal/ids"
	"git.jabali-panel.com/shukivaknin/jabali2/panel-api/internal/middleware"
	"git.jabali-panel.com/shukivaknin/jabali2/panel-api/internal/models"
	"git.jabali-panel.com/shukivaknin/jabali2/panel-api/internal/repository"
)

// M33 — admin Security tab "Malware" sub-tab. ADR-0072.
//
// Routes mounted alongside RegisterSecurityCrowdSecRoutes /
// RegisterSecurityUFWRoutes. The /event ingest endpoint uses
// RequireLocalhost (NOT RequireAdmin) — agent dials over loopback.

const malwareCallTimeout = 15 * time.Second

// SecurityMalwareHandlerConfig is the dependency bundle. Agent is
// optional only for tests that mock specific routes; in production it
// is always set. All four repos are required.
type SecurityMalwareHandlerConfig struct {
	Agent      agent.AgentInterface
	Quarantine repository.MalwareQuarantineRepository
	Events     repository.MalwareEventRepository
	Settings   repository.MalwareSettingsRepository
	YARARules  repository.YARACustomRuleRepository
	Users      repository.UserRepository
	UserScans  repository.MalwareUserScanRepository
	Log        *slog.Logger
}

// RegisterSecurityMalwareRoutes mounts the malware admin routes plus
// the loopback /event ingest. See ADR-0072 + plans/m33-malware-detection.md.
func RegisterSecurityMalwareRoutes(rg *gin.RouterGroup, cfg SecurityMalwareHandlerConfig) {
	h := &securityMalwareHandler{cfg: cfg}

	g := rg.Group("/admin/security/malware", middleware.RequireAdmin())
	g.GET("/status", h.getStatus)
	g.GET("/quarantine", h.listQuarantine)
	g.POST("/quarantine/:id/restore", h.restoreQuarantine)
	g.DELETE("/quarantine/:id", h.deleteQuarantine)
	g.GET("/events", h.listEvents)
	g.POST("/scan", h.startScan)
	g.GET("/scan/:id/status", h.scanStatus) // poll fallback when SSE unavailable
	g.GET("/scan/users", h.listUserScans)   // M33 follow-up: per-user job tracking
	g.POST("/update-signatures", h.updateSignatures)
	g.GET("/settings", h.getSettings)
	g.PUT("/settings", h.putSettings)

	// YARA admin (M33 Step 8) — agent owns the filesystem (drop-in
	// /etc/jabali/yara/), DB row tracks audit (uploaded_by + sha).
	g.GET("/yara", h.listYARA)
	g.POST("/yara", h.uploadYARA)
	g.PATCH("/yara/:filename", h.toggleYARA)
	g.DELETE("/yara/:filename", h.deleteYARA)

	// Exec audit (M39 ADR-0085) — narrow auditd rule key
	// jabali_susp_exec, surfaced read-only via ausearch shell-out.
	g.GET("/audit/recent", h.listAuditRecent)
}

// RegisterSecurityMalwareInternalRoutes mounts the loopback ingest
// endpoint OFF the root /api/v1 group so it bypasses Kratos session
// middleware (matches RegisterNotificationsInternalRoutes pattern).
// RequireLocalhost is the sole security boundary — agent dials over
// the M25 unix socket restricted to root + jabali group.
func RegisterSecurityMalwareInternalRoutes(rg *gin.RouterGroup, cfg SecurityMalwareHandlerConfig) {
	h := &securityMalwareHandler{cfg: cfg}
	rg.POST("/admin/security/malware/event", middleware.RequireLocalhost(), h.ingestEvent)
}

type securityMalwareHandler struct {
	cfg SecurityMalwareHandlerConfig
}

// ---- /status ----

func (h *securityMalwareHandler) getStatus(c *gin.Context) {
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.malware.status", map[string]any{})
	if err != nil {
		status, body := translateAgentError(err)
		c.JSON(status, body)
		return
	}
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

// ---- /quarantine ----

func (h *securityMalwareHandler) listQuarantine(c *gin.Context) {
	limit, offset := paginationFromQuery(c, 50, 200)
	ctx := c.Request.Context()
	rows, total, err := h.cfg.Quarantine.ListActive(ctx, limit, offset)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_list_quarantine"})
		return
	}
	// Decorate with username so the UI can show "shukivaknin" instead of
	// the raw 26-char ULID. Batch-fetch the users referenced by this
	// page's rows; rows with no user_id (system files) get an empty
	// username and the UI falls back to the "system" tag.
	type quarantineRowWithUser struct {
		models.MalwareQuarantine
		Username string `json:"username,omitempty"`
	}
	out := make([]quarantineRowWithUser, len(rows))
	idSet := make(map[string]struct{}, len(rows))
	for i, r := range rows {
		out[i] = quarantineRowWithUser{MalwareQuarantine: r}
		if r.UserID != nil && *r.UserID != "" {
			idSet[*r.UserID] = struct{}{}
		}
	}
	if h.cfg.Users != nil && len(idSet) > 0 {
		ids := make([]string, 0, len(idSet))
		for id := range idSet {
			ids = append(ids, id)
		}
		if users, uerr := h.cfg.Users.FindByIDs(ctx, ids); uerr == nil {
			byID := make(map[string]string, len(users))
			for _, u := range users {
				if u.Username != nil {
					byID[u.ID] = *u.Username
				}
			}
			for i := range out {
				if out[i].UserID != nil {
					out[i].Username = byID[*out[i].UserID]
				}
			}
		} else if h.cfg.Log != nil {
			h.cfg.Log.Warn("malware: enrich quarantine usernames failed", "err", uerr)
		}
	}
	c.JSON(http.StatusOK, gin.H{
		"data":      out,
		"total":     total,
		"page":      offset/limit + 1,
		"page_size": limit,
	})
}

func (h *securityMalwareHandler) restoreQuarantine(c *gin.Context) {
	id := c.Param("id")
	var body struct {
		Reason string `json:"reason"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || len(body.Reason) < 5 {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "reason_required",
			"detail": "reason is required (min 5 chars) for audit"})
		return
	}
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	row, err := h.cfg.Quarantine.Get(ctx, id)
	if err != nil {
		if errors.Is(err, repository.ErrNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "not_found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_get_quarantine"})
		return
	}
	if _, err := h.cfg.Agent.Call(ctx, "security.malware.quarantine.restore", map[string]any{
		"quarantine_path": row.QuarantinePath,
		"original_path":   row.OriginalPath,
		"reason":          body.Reason,
	}); err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	claims := ginctx.Claims(c)
	restoredBy := ""
	if claims != nil {
		restoredBy = claims.UserID
	}
	if err := h.cfg.Quarantine.Restore(ctx, id, restoredBy, body.Reason); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_restore"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"status": "ok", "restored": true})
}

func (h *securityMalwareHandler) deleteQuarantine(c *gin.Context) {
	id := c.Param("id")
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	row, err := h.cfg.Quarantine.Get(ctx, id)
	if err != nil {
		if errors.Is(err, repository.ErrNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "not_found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_get_quarantine"})
		return
	}
	if _, err := h.cfg.Agent.Call(ctx, "security.malware.quarantine.delete", map[string]any{
		"quarantine_path": row.QuarantinePath,
	}); err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	if err := h.cfg.Quarantine.MarkDeleted(ctx, id); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_delete"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted": true})
}

// ---- /events ----

func (h *securityMalwareHandler) listEvents(c *gin.Context) {
	limit, offset := paginationFromQuery(c, 100, 500)
	f := repository.MalwareEventListFilter{
		Source:   c.Query("source"),
		Severity: c.Query("severity"),
		UserID:   c.Query("user"),
		Limit:    limit,
		Offset:   offset,
	}
	rows, total, err := h.cfg.Events.List(c.Request.Context(), f)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_list_events"})
		return
	}
	c.JSON(http.StatusOK, gin.H{
		"data":      rows,
		"total":     total,
		"page":      offset/limit + 1,
		"page_size": limit,
	})
}

// ---- /scan ----

func (h *securityMalwareHandler) startScan(c *gin.Context) {
	var body struct {
		Path string `json:"path,omitempty"`
		User string `json:"user,omitempty"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid_json"})
		return
	}
	if body.Path == "" && body.User == "" {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "path_or_user_required"})
		return
	}
	if body.Path != "" && body.User != "" {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "exactly_one_of_path_or_user"})
		return
	}
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	cmd := "security.malware.scan_path"
	params := map[string]any{"path": body.Path}
	if body.User != "" {
		cmd = "security.malware.scan_user"
		params = map[string]any{"user": body.User}
	}
	if h.cfg.Log != nil {
		h.cfg.Log.Info("malware: scan request", "user", body.User, "path", body.Path, "user_scans_wired", h.cfg.UserScans != nil)
	}
	raw, err := h.cfg.Agent.Call(ctx, cmd, params)
	if err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	if h.cfg.Log != nil {
		h.cfg.Log.Info("malware: scan agent ok", "raw_len", len(raw), "raw_head", string(raw[:min(120, len(raw))]))
	}
	// Track per-user scan job (mig 000097). Decode the agent reply to
	// recover the session_id; on parse failure we still return 202 since
	// the scan IS running — the table just won't show this row until the
	// next manual scan.
	if h.cfg.UserScans != nil {
		var startResp struct {
			SessionID string `json:"session_id"`
		}
		if jerr := json.Unmarshal(raw, &startResp); jerr == nil && startResp.SessionID != "" {
			username := body.User
			targetPath := body.Path
			if username != "" {
				targetPath = "/home/" + username
			} else {
				username = userFromPath(body.Path)
			}
			if username != "" {
				var userID *string
				if h.cfg.Users != nil {
					if u, lerr := h.cfg.Users.FindByUsername(c.Request.Context(), username); lerr == nil && u != nil {
						id := u.ID
						userID = &id
					}
				}
				uerr := h.cfg.UserScans.StartScan(c.Request.Context(), username, startResp.SessionID, targetPath, userID)
				if h.cfg.Log != nil {
					if uerr != nil {
						h.cfg.Log.Warn("malware: track user scan failed", "username", username, "session_id", startResp.SessionID, "err", uerr)
					} else {
						h.cfg.Log.Info("malware: track user scan started", "username", username, "session_id", startResp.SessionID, "user_id", userID)
					}
				}
			}
		}
	}
	c.Data(http.StatusAccepted, "application/json; charset=utf-8", raw)
}

// listUserScans returns the per-username last-scan tracking rows. The
// Manual Scan UI joins this against the user list to render the Last
// scanned + Status columns. Rows for users that never had a manual
// scan simply don't appear; the UI defaults missing rows to "—".
func (h *securityMalwareHandler) listUserScans(c *gin.Context) {
	if h.cfg.UserScans == nil {
		c.JSON(http.StatusOK, gin.H{"data": []any{}})
		return
	}
	rows, err := h.cfg.UserScans.List(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_list_user_scans"})
		return
	}
	// Reconcile state=running rows against the agent: clamscan/maldet
	// scans run as transient systemd units. Without an ingest event
	// (clamscan-only mode never posts one) the row stays running
	// forever. Poll scan_status here — if the unit is inactive/failed,
	// finalise the row inline so the UI reflects reality.
	if h.cfg.Agent != nil {
		for i := range rows {
			r := &rows[i]
			if r.State != models.MalwareUserScanStateRunning || r.SessionID == "" {
				continue
			}
			callCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
			raw, callErr := h.cfg.Agent.Call(callCtx, "security.malware.scan_status",
				map[string]any{"session_id": r.SessionID})
			cancel()
			if callErr != nil {
				continue
			}
			var st struct {
				State    string `json:"state"`
				ExitCode int    `json:"exit_code"`
				Done     bool   `json:"done"`
			}
			if jsonErr := json.Unmarshal(raw, &st); jsonErr != nil {
				continue
			}
			if !st.Done {
				continue
			}
			finishState := models.MalwareUserScanStateDone
			if st.State == "failed" || st.ExitCode != 0 {
				finishState = models.MalwareUserScanStateFailed
			}
			ec := st.ExitCode
			if err := h.cfg.UserScans.FinishScan(c.Request.Context(), r.SessionID,
				finishState, r.Hits, &ec); err == nil {
				r.State = finishState
				r.ExitCode = &ec
				now := time.Now().UTC()
				r.FinishedAt = &now
			}
		}
	}
	c.JSON(http.StatusOK, gin.H{"data": rows})
}

func (h *securityMalwareHandler) scanStatus(c *gin.Context) {
	id := c.Param("id")
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.malware.scan_status", map[string]any{"session_id": id})
	if err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

// ---- /update-signatures ----

func (h *securityMalwareHandler) updateSignatures(c *gin.Context) {
	// Signature pulls can take ~60s on a fresh DB; allow extra budget.
	ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.malware.update_signatures", map[string]any{})
	if err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

// ---- /settings ----

func (h *securityMalwareHandler) getSettings(c *gin.Context) {
	if err := h.cfg.Settings.EnsureDefault(c.Request.Context()); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_seed"})
		return
	}
	s, err := h.cfg.Settings.Get(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_get_settings"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"data": s})
}

func (h *securityMalwareHandler) putSettings(c *gin.Context) {
	var body models.MalwareSettings
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid_json"})
		return
	}
	// Bound retention to a sane window — purges are irreversible.
	if body.QuarantineRetainDays < 1 || body.QuarantineRetainDays > 365 {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "retain_days_out_of_range"})
		return
	}
	if body.MaxScanMB < 1 || body.MaxScanMB > 10240 {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "max_scan_mb_out_of_range"})
		return
	}
	switch body.NotifyThreshold {
	case models.MalwareSeverityInfo, models.MalwareSeverityWarn, models.MalwareSeverityCritical:
	default:
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid_notify_threshold"})
		return
	}
	// Apply the realtime toggle to the host BEFORE persisting. If the
	// agent call fails after we save the new value, the next identical
	// PUT would short-circuit (prev == body) and the host would never
	// catch up — admin would see "off" in the UI but the unit would
	// keep running (or vice-versa). Apply-then-persist guarantees the
	// DB only advances when the host did.
	prev, prevErr := h.cfg.Settings.Get(c.Request.Context())
	if prevErr == nil && prev.RealtimeEnabled != body.RealtimeEnabled {
		ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
		defer cancel()
		if _, err := h.cfg.Agent.Call(ctx, "security.malware.monitor.set_enabled", map[string]any{
			"enabled": body.RealtimeEnabled,
		}); err != nil {
			status, errBody := translateAgentError(err)
			c.JSON(status, errBody)
			return
		}
	}
	if err := h.cfg.Settings.Upsert(c.Request.Context(), &body); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "db_upsert_settings"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"status": "ok", "data": body})
}

// ---- /yara (admin upload + toggle + delete) ----

func (h *securityMalwareHandler) listYARA(c *gin.Context) {
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.malware.yara.list", map[string]any{})
	if err != nil {
		status, body := translateAgentError(err)
		c.JSON(status, body)
		return
	}
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

func (h *securityMalwareHandler) uploadYARA(c *gin.Context) {
	var body struct {
		Filename      string `json:"filename"`
		ContentBase64 string `json:"content_base64"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid_json"})
		return
	}
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.malware.yara.upload", body)
	if err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	// Audit row in yara_custom_rules. Best-effort — agent has already
	// landed the file; failure here leaves the file on disk + audit gap.
	claims := ginctx.Claims(c)
	uploadedBy := ""
	if claims != nil {
		uploadedBy = claims.UserID
	}
	var resp struct {
		Filename string `json:"filename"`
		SHA256   string `json:"sha256"`
	}
	_ = json.Unmarshal(raw, &resp)
	if resp.Filename != "" {
		_ = h.cfg.YARARules.Upsert(c.Request.Context(), &models.YARACustomRule{
			ID:         ids.NewULID(),
			Filename:   resp.Filename,
			Enabled:    true,
			UploadedBy: uploadedBy,
			SHA256:     resp.SHA256,
		})
	}
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

func (h *securityMalwareHandler) toggleYARA(c *gin.Context) {
	filename := c.Param("filename")
	var body struct {
		Enabled bool `json:"enabled"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid_json"})
		return
	}
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.malware.yara.toggle", map[string]any{
		"filename": filename,
		"enabled":  body.Enabled,
	})
	if err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	_ = h.cfg.YARARules.SetEnabled(c.Request.Context(), filename, body.Enabled)
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

func (h *securityMalwareHandler) deleteYARA(c *gin.Context) {
	filename := c.Param("filename")
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.malware.yara.delete", map[string]any{
		"filename": filename,
	})
	if err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	_ = h.cfg.YARARules.Delete(c.Request.Context(), filename)
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

// listAuditRecent — M39 ADR-0085. Forwards to security.audit.recent
// which shells out to ausearch -k jabali_susp_exec. Read-only,
// admin-only. Limit clamps via ?limit=N (default 100, max 1000).
func (h *securityMalwareHandler) listAuditRecent(c *gin.Context) {
	limit := 100
	if q := c.Query("limit"); q != "" {
		if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 1000 {
			limit = n
		}
	}
	ctx, cancel := context.WithTimeout(c.Request.Context(), malwareCallTimeout)
	defer cancel()
	raw, err := h.cfg.Agent.Call(ctx, "security.audit.recent", map[string]any{
		"limit": limit,
	})
	if err != nil {
		status, errBody := translateAgentError(err)
		c.JSON(status, errBody)
		return
	}
	c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}

// ---- /event (loopback ingest) ----

// MalwareEventIngestPayload mirrors panel-agent's MalwareEventIngest
// struct. Both sides MUST keep field names in sync (panel-agent owns
// the contract source — see panel-agent/internal/commands/security_malware.go).
type MalwareEventIngestPayload struct {
	Source     string                  `json:"source"`
	EventType  string                  `json:"event_type"`
	Severity   string                  `json:"severity"`
	UserID     string                  `json:"user_id,omitempty"`
	TargetPath string                  `json:"target_path,omitempty"`
	TargetPID  uint32                  `json:"target_pid,omitempty"`
	Signature  string                  `json:"signature,omitempty"`
	SHA256     string                  `json:"sha256,omitempty"`
	SizeBytes  int64                   `json:"size_bytes,omitempty"`
	Hits       []MalwareEventIngestHit `json:"hits,omitempty"`
	RawJSON    map[string]any          `json:"raw_json"`
	OccurredAt time.Time               `json:"occurred_at"`
}

// MalwareEventIngestHit is one quarantined file from a multi-hit session.
type MalwareEventIngestHit struct {
	OriginalPath   string `json:"original_path"`
	QuarantinePath string `json:"quarantine_path"`
	Signature      string `json:"signature"`
	SHA256         string `json:"sha256"`
	SizeBytes      int64  `json:"size_bytes"`
	Username       string `json:"username,omitempty"`
}

func (h *securityMalwareHandler) ingestEvent(c *gin.Context) {
	var p MalwareEventIngestPayload
	if err := c.ShouldBindJSON(&p); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid_json"})
		return
	}
	res, status, errCode := IngestMalwareEventInProcess(c.Request.Context(), h.cfg, &p)
	if errCode != "" {
		c.JSON(status, gin.H{"status": "error", "error": errCode})
		return
	}
	c.JSON(status, res)
}

// IngestMalwareEventInProcess runs the same persistence path as the
// loopback /event handler without the Gin layer. M33.2 mailscan tick
// uses this to skip a self-loopback HTTP hop. Returns (responseBody,
// httpStatus, errCode) — errCode is empty on success.
func IngestMalwareEventInProcess(ctx context.Context, cfg SecurityMalwareHandlerConfig, p *MalwareEventIngestPayload) (any, int, string) {
	if p.Source == "" || p.EventType == "" {
		return nil, http.StatusBadRequest, "source_and_event_type_required"
	}
	if !validMalwareScanner(p.Source) {
		return nil, http.StatusBadRequest, "invalid_source"
	}
	if !validMalwareSeverity(p.Severity) {
		p.Severity = models.MalwareSeverityWarn
	}
	if p.OccurredAt.IsZero() {
		p.OccurredAt = time.Now().UTC()
	}
	rawJSON, _ := json.Marshal(p.RawJSON)

	// Insert one event row.
	ev := &models.MalwareEvent{
		ID:        ids.NewULID(),
		EventType: p.EventType,
		Source:    p.Source,
		Severity:  p.Severity,
		RawJSON:   rawJSON,
	}
	if p.UserID != "" {
		ev.UserID = &p.UserID
	}
	if p.TargetPath != "" {
		ev.TargetPath = &p.TargetPath
	}
	if p.TargetPID != 0 {
		ev.TargetPID = &p.TargetPID
	}
	if p.Signature != "" {
		ev.Signature = &p.Signature
	}
	// For multi-hit ingest payloads (maldet post_scan_hook emits one
	// event with hits[]), hoist signature/target_path from the first
	// hit onto the event row when the event-level fields are empty.
	// Lets the admin list page render "X.php (matched eicar.test)"
	// without joining quarantine rows. Multi-hit sessions show first
	// hit + the quarantine count tells the operator there are more.
	if len(p.Hits) > 0 {
		if ev.TargetPath == nil && p.Hits[0].OriginalPath != "" {
			op := p.Hits[0].OriginalPath
			ev.TargetPath = &op
		}
		if ev.Signature == nil && p.Hits[0].Signature != "" {
			sig := p.Hits[0].Signature
			ev.Signature = &sig
		}
	}
	if err := cfg.Events.Create(ctx, ev); err != nil {
		if cfg.Log != nil {
			cfg.Log.Warn("malware event insert failed",
				"event_id", ev.ID,
				"source", ev.Source,
				"event_type", ev.EventType,
				"raw_json_len", len(ev.RawJSON),
				"err", err)
		}
		return nil, http.StatusInternalServerError, "db_create_event"
	}

	// Insert N quarantine rows for file_hit events with hits[].
	for _, hit := range p.Hits {
		// Server-side enrichment for hooks that ship a minimal payload
		// (the maldet 2.0.1 post_scan_hook only knows signature +
		// original_path + quarantine_path; size, sha256, username are
		// recoverable here from the quarantined copy on disk).
		if hit.Username == "" {
			hit.Username = userFromPath(hit.OriginalPath)
		}
		if hit.QuarantinePath != "" {
			if hit.SizeBytes == 0 {
				if st, err := os.Stat(hit.QuarantinePath); err == nil {
					hit.SizeBytes = st.Size()
				}
			}
			if hit.SHA256 == "" {
				if sum, err := sha256File(hit.QuarantinePath); err == nil {
					hit.SHA256 = sum
				}
			}
		}
		row := &models.MalwareQuarantine{
			ID:             ids.NewULID(),
			OriginalPath:   hit.OriginalPath,
			QuarantinePath: hit.QuarantinePath,
			Signature:      hit.Signature,
			Scanner:        p.Source,
			SHA256:         hit.SHA256,
			SizeBytes:      uint64(hit.SizeBytes),
		}
		// Resolve username -> ULID. Persisting the username string in
		// user_id (the previous behavior) broke FK joins against the
		// users table — every other repo stores ULIDs there. Fall back
		// to NULL on lookup miss (system-owned files have no user).
		if hit.Username != "" && cfg.Users != nil {
			if u, err := cfg.Users.FindByUsername(ctx, hit.Username); err == nil && u != nil {
				row.UserID = &u.ID
			}
		} else if p.UserID != "" {
			row.UserID = &p.UserID
		}
		if err := cfg.Quarantine.Create(ctx, row); err != nil {
			if cfg.Log != nil {
				cfg.Log.Warn("malware quarantine insert failed",
					"event_id", ev.ID,
					"original_path", hit.OriginalPath,
					"err", err)
			}
		}
	}

	// M33 follow-up — finalise the per-user manual-scan tracking row
	// when the post-scan hook fires for a /home/<user> target. Match by
	// target_path (LMD generates its own scan_id, so we cannot match by
	// session_id; the path is what we wrote at scan-start time). Only
	// runs for maldet ingest events that look like they came from the
	// hook (raw_json carries the scan path).
	if cfg.UserScans != nil && p.Source == models.ScannerMaldet {
		if scanPath, _ := p.RawJSON["path"].(string); scanPath != "" && strings.HasPrefix(scanPath, "/home/") {
			finishState := models.MalwareUserScanStateDone
			var exitCode *int
			if v, ok := p.RawJSON["exit_code"].(float64); ok {
				ec := int(v)
				exitCode = &ec
				if ec != 0 {
					finishState = models.MalwareUserScanStateFailed
				}
			}
			if err := cfg.UserScans.FinishScanByPath(ctx, scanPath, finishState, uint(len(p.Hits)), exitCode); err != nil && cfg.Log != nil {
				cfg.Log.Warn("malware: finalise user scan failed", "path", scanPath, "err", err)
			}
		}
	}

	return map[string]any{
		"status":   "ok",
		"event_id": ev.ID,
		"hits":     len(p.Hits),
	}, http.StatusCreated, ""
}

// ---- shared helpers ----

func paginationFromQuery(c *gin.Context, defaultLimit, maxLimit int) (int, int) {
	limit := defaultLimit
	if v := c.Query("limit"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
			limit = n
		}
	}
	page := 1
	if v := c.Query("page"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 {
			page = n
		}
	}
	return limit, (page - 1) * limit
}

func validMalwareScanner(s string) bool {
	switch s {
	case models.ScannerMaldet, models.ScannerClamAV, models.ScannerYARA:
		return true
	}
	return false
}

func validMalwareSeverity(s string) bool {
	switch s {
	case models.MalwareSeverityInfo, models.MalwareSeverityWarn, models.MalwareSeverityCritical:
		return true
	}
	return false
}

// userFromPath extracts <user> from /home/<user>/... ; returns "" for
// paths that don't live under a user docroot. Used to enrich hook
// payloads that ship only the original path.
func userFromPath(p string) string {
	const prefix = "/home/"
	if !strings.HasPrefix(p, prefix) {
		return ""
	}
	rest := p[len(prefix):]
	i := strings.IndexByte(rest, '/')
	if i <= 0 {
		return ""
	}
	return rest[:i]
}

// sha256File hashes a file's contents. Used by the malware ingest path
// to enrich hook payloads (hooks ship sig+paths only; the quarantined
// file is sitting in /usr/local/maldetect/quarantine/ ready to hash).
func sha256File(path string) (string, error) {
	f, err := os.Open(path) //nolint:gosec // path comes from maldet, which only writes to its own quarantine dir
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}
