package commands

import (
	"bytes"
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"git.jabali-panel.com/shukivaknin/jabali2/agentwire"
)

// M33 — malware detection stack. ADR-0072.
// Surfaces ClamAV + Linux Malware Detect + YARA to the
// admin Security tab. Wired into M14 notifications via the
// loopback POST /admin/security/malware/event ingest path.

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

func mwInvalidArg(msg string) error {
	return &agentwire.AgentError{Code: agentwire.CodeInvalidArgument, Message: msg}
}

func mwInternal(msg string, err error) error {
	return &agentwire.AgentError{
		Code:    agentwire.CodeInternal,
		Message: fmt.Sprintf("%s: %v", msg, err),
	}
}

// scanPathRoots are the only filesystem prefixes a manual scan may
// target. Hard-fail otherwise — never trust a client-sent path verbatim
// against root-privileged tooling.
var scanPathRoots = []string{
	"/home/",
	"/var/www/",
	"/usr/share/jabali-applications/",
	"/tmp/",
}

func validateScanPath(p string) error {
	if p == "" {
		return mwInvalidArg("path is required")
	}
	if !filepath.IsAbs(p) {
		return mwInvalidArg("path must be absolute")
	}
	clean := filepath.Clean(p)
	if strings.Contains(clean, "..") {
		return mwInvalidArg("path must not contain '..'")
	}
	if strings.HasPrefix(clean, "/usr/local/maldetect/") {
		return mwInvalidArg("scanning the maldet quarantine path is not allowed")
	}
	for _, root := range scanPathRoots {
		if strings.HasPrefix(clean, root) {
			return nil
		}
	}
	return mwInvalidArg(fmt.Sprintf("path must live under one of %v", scanPathRoots))
}

// ---- security.malware.status -----------------------------------------------

type mwStatusResponse struct {
	MaldetVersion       string    `json:"maldet_version,omitempty"`
	MaldetSigVersion    string    `json:"maldet_sig_version,omitempty"`
	MonitorActive       bool      `json:"monitor_active"`
	WatchCountConfigured int       `json:"watch_count_configured,omitempty"`
	WatchCeiling        int       `json:"watch_ceiling,omitempty"`
	// YARA engine — yara-x (`yr`) is the maldet 2.0.1 native scanner.
	// Empty string = no engine binary on PATH (degraded to clamscan).
	YARAEngineVersion string `json:"yara_engine_version,omitempty"`

	// rfxn YARA rule pack — shipped with maldet under sigs/rfxn.yara.
	// Counted via strings.Count newline+'rule '; file is ~125k lines
	// but a single linear scan finishes in <50ms.
	RFXNRulesPresent bool      `json:"rfxn_rules_present"`
	RFXNRulesCount   int       `json:"rfxn_rules_count,omitempty"`
	RFXNRulesUpdated time.Time `json:"rfxn_rules_updated,omitempty"`
	RFXNRulesPath    string    `json:"rfxn_rules_path,omitempty"`

	// signature-base (Neo23x0) — symlinked into maldet's custom.yara.d/
	// drop-in dir; refreshed daily by jabali-signature-base-update.timer.
	// Count is the number of .yar files under /opt/jabali/signature-base/yara.
	SignatureBasePresent bool      `json:"signature_base_present"`
	SignatureBaseFiles   int       `json:"signature_base_files,omitempty"`
	SignatureBaseUpdated time.Time `json:"signature_base_updated,omitempty"`
	SignatureBasePath    string    `json:"signature_base_path,omitempty"`
}

func mwStatusHandler(ctx context.Context, _ json.RawMessage) (any, error) {
	r := mwStatusResponse{}
	if out, err := exec.CommandContext(ctx, "maldet", "--version").Output(); err == nil {
		r.MaldetVersion = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
	}
	if data, err := os.ReadFile("/usr/local/maldetect/sigs/maldet.sigs.ver"); err == nil {
		r.MaldetSigVersion = strings.TrimSpace(string(data))
	}
	// ClamAV removed (M33 amendment 3, 2026-04-29) — maldet 2.0 native
	// HEX/MD5/SHA-256 scanner replaces clamscan. No clamav_* status
	// fields surfaced anymore; UI Overview tile dropped to match.
	r.MonitorActive = systemctlIsActive(ctx, "jabali-maldet-monitor")
	if data, err := os.ReadFile("/proc/sys/fs/inotify/max_user_watches"); err == nil {
		r.WatchCeiling, _ = strconv.Atoi(strings.TrimSpace(string(data)))
	}
	if entries, err := os.ReadDir("/usr/local/maldetect/sess"); err == nil {
		r.WatchCountConfigured = len(entries)
	}
	// YARA engine version — maldet 2.0.1 prefers yara-x (`yr`); fall
	// back to legacy `yara` if yr is missing. Empty string surfaces as
	// "no YARA engine" in the UI.
	// CombinedOutput because some yara-x releases write --version to stderr.
	if out, err := exec.CommandContext(ctx, "yr", "--version").CombinedOutput(); err == nil {
		if v := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]); v != "" {
			r.YARAEngineVersion = "yara-x " + v
		}
	} else if out, err := exec.CommandContext(ctx, "yara", "--version").CombinedOutput(); err == nil {
		if v := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]); v != "" {
			r.YARAEngineVersion = "yara " + v
		}
	}
	// rfxn YARA pack — shipped + refreshed with maldet itself.
	const rfxnPath = "/usr/local/maldetect/sigs/rfxn.yara"
	r.RFXNRulesPath = rfxnPath
	if st, err := os.Stat(rfxnPath); err == nil && st.Size() > 0 {
		r.RFXNRulesPresent = true
		r.RFXNRulesUpdated = st.ModTime()
		if data, err := os.ReadFile(rfxnPath); err == nil {
			r.RFXNRulesCount = strings.Count(string(data), "\nrule ")
			if strings.HasPrefix(string(data), "rule ") {
				r.RFXNRulesCount++
			}
		}
	}
	// signature-base — Florian Roth / Neo23x0 webshell + crime YARA rules.
	// Count .yar files under the symlinked dir; the deepest single update
	// signal is the symlink target's mtime (`git pull` sets that on HEAD).
	const sigBasePath = "/opt/jabali/signature-base/yara"
	r.SignatureBasePath = sigBasePath
	if entries, err := os.ReadDir(sigBasePath); err == nil {
		count := 0
		for _, e := range entries {
			if !e.IsDir() && (strings.HasSuffix(e.Name(), ".yar") || strings.HasSuffix(e.Name(), ".yara")) {
				count++
			}
		}
		if count > 0 {
			r.SignatureBasePresent = true
			r.SignatureBaseFiles = count
			if st, err := os.Stat("/opt/jabali/signature-base/.git/HEAD"); err == nil {
				r.SignatureBaseUpdated = st.ModTime()
			}
		}
	}
	return r, nil
}

func systemctlIsActive(ctx context.Context, unit string) bool {
	return exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", unit).Run() == nil
}

// ---- security.malware.scan_path / scan_user / scan_status ------------------

type mwScanPathRequest struct {
	Path string `json:"path"`
}

type mwScanUserRequest struct {
	User string `json:"user"`
}

type mwScanStartResponse struct {
	SessionID string `json:"session_id"`
	Unit      string `json:"unit"`
}

// scanIDRE constrains the session ID we accept back from clients. Used
// to build a systemd unit name; reject anything that is not a 26-char
// ULID.
var scanIDRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`)

// usernameRE constrains scan_user.user — must be a plain Linux username.
var usernameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)

func mwScanPathHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var req mwScanPathRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if err := validateScanPath(req.Path); err != nil {
		return nil, err
	}
	return startMaldetScan(ctx, req.Path)
}

func mwScanUserHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var req mwScanUserRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if !usernameRE.MatchString(req.User) {
		return nil, mwInvalidArg("user must match ^[a-z][a-z0-9_-]{0,31}$")
	}
	homePath := filepath.Join("/home", req.User)
	if _, err := os.Stat(homePath); err != nil {
		return nil, mwInvalidArg(fmt.Sprintf("user home not found: %s", homePath))
	}
	return startMaldetScan(ctx, homePath)
}

func startMaldetScan(ctx context.Context, target string) (mwScanStartResponse, error) {
	// Mint a 26-char ULID locally — same alphabet panel-api uses.
	sessionID, err := newULID()
	if err != nil {
		return mwScanStartResponse{}, mwInternal("ulid", err)
	}
	unit := fmt.Sprintf("jabali-maldet-scan-%s.service", sessionID)
	// --no-block: enqueue the start job and return immediately. Without
	// it, systemd-run waits for the unit to become ACTIVE which under
	// Type=oneshot means waiting for ExecStart to finish — a full
	// /home/<user> maldet -a scan takes minutes. The RPC then exceeds
	// the panel-api 15s timeout, the scan keeps running in the background,
	// but panel-api's startScan handler bails before writing the tracking
	// row to malware_user_scans. UI shows 'idle' for a user that visibly
	// has clamscan churning (caught on first M33 follow-up smoke). With
	// --no-block we're back to: systemd-run returns as soon as the job is
	// queued, agent returns session_id, panel-api writes the tracking row,
	// the scan_status poller picks up the unit's lifecycle from there.
	cmd := exec.CommandContext(ctx, //nolint:gosec // argv form, no shell
		"systemd-run",
		"--quiet",
		"--collect",
		"--no-block",
		"--unit="+unit,
		"--property=Type=oneshot",
		"--property=Nice=15",
		"--property=IOSchedulingClass=idle",
		"/usr/local/maldetect/maldet",
		"-a", target,
	)
	if out, err := cmd.CombinedOutput(); err != nil {
		return mwScanStartResponse{}, mwInternal(fmt.Sprintf("systemd-run failed: %s", string(out)), err)
	}
	return mwScanStartResponse{SessionID: sessionID, Unit: unit}, nil
}

type mwScanStatusRequest struct {
	SessionID string `json:"session_id"`
}

type mwScanStatusResponse struct {
	State    string   `json:"state"` // active / failed / inactive (= done)
	ExitCode int      `json:"exit_code"`
	LogTail  []string `json:"log_tail"`
	Done     bool     `json:"done"`
}

func mwScanStatusHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var req mwScanStatusRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if !scanIDRE.MatchString(req.SessionID) {
		return nil, mwInvalidArg("session_id must be a 26-char ULID")
	}
	unit := fmt.Sprintf("jabali-maldet-scan-%s.service", req.SessionID)
	r := mwScanStatusResponse{}
	if out, err := exec.CommandContext(ctx, "systemctl", "show", unit, "--property=ActiveState,ExecMainStatus").Output(); err == nil {
		for _, line := range strings.Split(string(out), "\n") {
			if strings.HasPrefix(line, "ActiveState=") {
				r.State = strings.TrimPrefix(line, "ActiveState=")
			} else if strings.HasPrefix(line, "ExecMainStatus=") {
				if v, e := strconv.Atoi(strings.TrimPrefix(line, "ExecMainStatus=")); e == nil {
					r.ExitCode = v
				}
			}
		}
	}
	r.Done = r.State == "inactive" || r.State == "failed"
	if out, err := exec.CommandContext(ctx, "journalctl", "-u", unit, "--no-pager", "-n", "200").Output(); err == nil {
		for _, line := range strings.Split(string(out), "\n") {
			if line != "" {
				r.LogTail = append(r.LogTail, line)
			}
		}
	}
	return r, nil
}

// ---- security.malware.quarantine.{list,restore,delete} ---------------------

type mwQuarantineEntry struct {
	QuarantinePath string `json:"quarantine_path"`
	OriginalPath   string `json:"original_path,omitempty"`
	SHA256         string `json:"sha256"`
	Size           int64  `json:"size"`
	ModTime        string `json:"mtime"`
}

type mwQuarantineListResponse struct {
	Entries []mwQuarantineEntry `json:"entries"`
}

func mwQuarantineListHandler(ctx context.Context, _ json.RawMessage) (any, error) {
	r := mwQuarantineListResponse{Entries: []mwQuarantineEntry{}}
	root := "/usr/local/maldetect/quarantine"
	entries, err := os.ReadDir(root)
	if err != nil {
		if os.IsNotExist(err) {
			return r, nil
		}
		return nil, mwInternal("readdir quarantine", err)
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		full := filepath.Join(root, e.Name())
		info, err := e.Info()
		if err != nil {
			continue
		}
		sum, _ := sha256File(full)
		r.Entries = append(r.Entries, mwQuarantineEntry{
			QuarantinePath: full,
			SHA256:         sum,
			Size:           info.Size(),
			ModTime:        info.ModTime().UTC().Format(time.RFC3339),
		})
	}
	return r, nil
}

type mwQuarantineRestoreRequest struct {
	QuarantinePath string `json:"quarantine_path"`
	OriginalPath   string `json:"original_path"`
	Reason         string `json:"reason"`
}

func mwQuarantineRestoreHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var req mwQuarantineRestoreRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if !strings.HasPrefix(req.QuarantinePath, "/usr/local/maldetect/quarantine/") {
		return nil, mwInvalidArg("quarantine_path must be under /usr/local/maldetect/quarantine/")
	}
	if strings.Contains(req.QuarantinePath, "..") {
		return nil, mwInvalidArg("quarantine_path must not contain '..'")
	}
	if strings.TrimSpace(req.Reason) == "" {
		return nil, mwInvalidArg("reason is required for restore (audit)")
	}
	cmd := exec.CommandContext(ctx, "/usr/local/maldetect/maldet", "--restore", req.QuarantinePath) //nolint:gosec
	if out, err := cmd.CombinedOutput(); err != nil {
		return nil, mwInternal(fmt.Sprintf("maldet --restore failed: %s", string(out)), err)
	}
	return map[string]any{"restored": true}, nil
}

type mwQuarantineDeleteRequest struct {
	QuarantinePath string `json:"quarantine_path"`
}

func mwQuarantineDeleteHandler(_ context.Context, raw json.RawMessage) (any, error) {
	var req mwQuarantineDeleteRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if !strings.HasPrefix(req.QuarantinePath, "/usr/local/maldetect/quarantine/") {
		return nil, mwInvalidArg("quarantine_path must be under /usr/local/maldetect/quarantine/")
	}
	if strings.Contains(req.QuarantinePath, "..") {
		return nil, mwInvalidArg("quarantine_path must not contain '..'")
	}
	if err := os.Remove(req.QuarantinePath); err != nil {
		return nil, mwInternal("remove quarantine file", err)
	}
	return map[string]any{"deleted": true}, nil
}

// ---- security.malware.update_signatures ------------------------------------

type mwUpdateSignaturesResponse struct {
	// FreshclamOK / FreshclamOutput retained for wire-compat after
	// ClamAV removal (M33 amendment 2026-04-27). Always true / empty.
	FreshclamOK     bool   `json:"freshclam_ok"`
	FreshclamOutput string `json:"freshclam_output,omitempty"`
	MaldetOK        bool   `json:"maldet_ok"`
	MaldetOutput    string `json:"maldet_output,omitempty"`
	PMFOK           bool   `json:"pmf_ok"`
	PMFOutput       string `json:"pmf_output,omitempty"`
	Duration        string `json:"duration"`
}

func mwUpdateSignaturesHandler(ctx context.Context, _ json.RawMessage) (any, error) {
	start := time.Now()
	r := mwUpdateSignaturesResponse{FreshclamOK: true}
	if out, err := exec.CommandContext(ctx, "/usr/local/maldetect/maldet", "-u").CombinedOutput(); err == nil {
		r.MaldetOK = true
		r.MaldetOutput = string(out)
	} else {
		r.MaldetOutput = string(out)
	}
	// Trigger PMF rule refresh by starting the systemd unit. We run it
	// rather than fetching inline so the agent doesn't need to know the
	// upstream URL or write /etc/jabali/yara/ directly.
	if out, err := exec.CommandContext(ctx, "systemctl", "start", "--no-block", "jabali-pmf-update.service").CombinedOutput(); err == nil {
		r.PMFOK = true
		r.PMFOutput = string(out)
	} else {
		r.PMFOutput = string(out)
	}
	r.Duration = time.Since(start).Truncate(time.Second).String()
	return r, nil
}

// ---- security.malware.monitor.set_enabled ----------------------------------

type mwMonitorSetEnabledRequest struct {
	Enabled bool `json:"enabled"`
}

// mwMonitorSetEnabledHandler starts/stops jabali-maldet-monitor.service
// based on the admin's opt-in. Per ADR-0072 amendment 2 the unit is
// install-disabled and only runs when the operator flips the toggle —
// the inotify monitor's clamscan invocations peak at ~1.7GB RAM, too
// costly to leave on by default for shared hosting.
func mwMonitorSetEnabledHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var req mwMonitorSetEnabledRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	const unit = "jabali-maldet-monitor.service"
	if req.Enabled {
		out, err := exec.CommandContext(ctx, "systemctl", "enable", "--now", unit).CombinedOutput()
		if err != nil {
			return nil, mwInternal(fmt.Sprintf("systemctl enable --now %s: %s", unit, string(out)), err)
		}
	} else {
		out, err := exec.CommandContext(ctx, "systemctl", "disable", "--now", unit).CombinedOutput()
		if err != nil {
			return nil, mwInternal(fmt.Sprintf("systemctl disable --now %s: %s", unit, string(out)), err)
		}
	}
	return map[string]any{
		"enabled":        req.Enabled,
		"monitor_active": systemctlIsActive(ctx, "jabali-maldet-monitor"),
	}, nil
}

// ---- security.malware.monitor.reload ---------------------------------------

func mwMonitorReloadHandler(ctx context.Context, _ json.RawMessage) (any, error) {
	// LMD --monitor RELOAD signals a running monitor to re-read /etc/passwd
	// for users with UID >= inotify_minuid and adjust watches. No file to
	// manage; LMD handles the discovery.
	out, err := exec.CommandContext(ctx, "/usr/local/maldetect/maldet", "--monitor", "RELOAD").CombinedOutput()
	if err != nil {
		return nil, mwInternal(fmt.Sprintf("maldet --monitor RELOAD: %s", string(out)), err)
	}
	return map[string]any{"reloaded": true, "output": string(out)}, nil
}

// ---- security.malware.event (loopback ingest from session-watcher / relay) -

// MalwareEventIngest is the panel-api ingest payload. Mirrored on
// the panel-api side; both packages MUST keep field names in sync
// (the wire contract is defined here, panel-api decodes into a matching
// struct). Per repo convention, types live in the agent commands file.
type MalwareEventIngest struct {
	Source     string                 `json:"source"`     // maldet|clamav|yara
	EventType  string                 `json:"event_type"` // file_hit|process_exec_alert|chmod_suspicious|scan_completed|suspicious_syscall
	Severity   string                 `json:"severity"`   // info|warn|critical
	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       []MalwareIngestHit     `json:"hits,omitempty"`
	RawJSON    map[string]any         `json:"raw_json"`
	OccurredAt time.Time              `json:"occurred_at"`
}

// MalwareIngestHit is one quarantined file from a multi-hit scan session.
type MalwareIngestHit 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 mwEventHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var ev MalwareEventIngest
	if err := json.Unmarshal(raw, &ev); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if ev.Source == "" || ev.EventType == "" {
		return nil, mwInvalidArg("source and event_type are required")
	}
	if ev.OccurredAt.IsZero() {
		ev.OccurredAt = time.Now().UTC()
	}
	// Enrich: derive user from /home/<user>/ paths if not set on hits.
	for i := range ev.Hits {
		if ev.Hits[i].Username == "" {
			if u := userFromPath(ev.Hits[i].OriginalPath); u != "" {
				ev.Hits[i].Username = u
			}
		}
	}
	if ev.UserID == "" && ev.TargetPath != "" {
		if u := userFromPath(ev.TargetPath); u != "" {
			ev.UserID = u
		}
	}
	body, err := json.Marshal(ev)
	if err != nil {
		return nil, mwInternal("marshal", err)
	}
	req, err := buildPanelAPIRequest(ctx, body, "jabali-agent/malware-ingest")
	if err != nil {
		return nil, mwInternal("build request", err)
	}
	resp, err := panelAPIClient.Do(req)
	if err != nil {
		return nil, mwInternal("post event to panel-api", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		bodyBytes, _ := io.ReadAll(resp.Body)
		return nil, mwInternal(fmt.Sprintf("panel-api %d: %s", resp.StatusCode, string(bodyBytes)), nil)
	}
	return map[string]any{"ingested": true}, nil
}

// buildPanelAPIRequest builds a malware ingest POST. The endpoint host
// is nominal — panelAPIClient (notifications_send.go) dials the unix
// socket via Transport.DialContext regardless of host.
func buildPanelAPIRequest(ctx context.Context, body []byte, ua string) (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"http://panel-api/api/v1/admin/security/malware/event", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("User-Agent", ua)
	return req, nil
}

// userFromPath extracts <user> from /home/<user>/... ; returns "" for
// non-/home paths (system or /tmp callers).
func userFromPath(p string) string {
	const prefix = "/home/"
	if !strings.HasPrefix(p, prefix) {
		return ""
	}
	rest := strings.TrimPrefix(p, prefix)
	if i := strings.Index(rest, "/"); i > 0 {
		return rest[:i]
	}
	return rest
}

// ---- helpers ---------------------------------------------------------------

func sha256File(path string) (string, error) {
	f, err := os.Open(path) //nolint:gosec // path is validated upstream + agent runs as root
	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
}

// newULID returns a 26-char Crockford-base32 ULID. We mint locally so
// the agent does not depend on the panel-api ids package; the regex
// scanIDRE pins the alphabet to match.
//
// Format = timestamp-ms (10 chars) + random (16 chars). For agent's
// usage (systemd unit suffix) we use crypto/rand.
func newULID() (string, error) {
	const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
	var buf [16]byte
	if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil {
		return "", err
	}
	tsMs := uint64(time.Now().UnixMilli())
	out := make([]byte, 26)
	for i := 9; i >= 0; i-- {
		out[i] = alphabet[tsMs&0x1F]
		tsMs >>= 5
	}
	for i, b := range buf {
		if 10+i >= 26 {
			break
		}
		out[10+i] = alphabet[b&0x1F]
	}
	return string(out), nil
}

// ---- security.malware.yara.{list,upload,toggle,delete} --------------------

// yaraDir is the admin-editable YARA rule drop-in installed by
// install.sh. Filenames are validated against yaraNameRE; uploads
// are pre-flight parse-checked via `yara -w`.
const yaraDir = "/etc/jabali/yara"

// yaraNameRE pins filenames to a safe charset. Two suffixes accepted:
// .yar (enabled) and .yar.disabled (disabled). The base regex is
// applied to the bare filename only — directory traversal is rejected
// upstream by the path containment check.
var yaraNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}\.yar(\.disabled)?$`)

type mwYARAEntry struct {
	Filename     string `json:"filename"`
	Enabled      bool   `json:"enabled"`
	SizeBytes    int64  `json:"size_bytes"`
	SHA256       string `json:"sha256"`
	LastModified string `json:"last_modified"`
}

type mwYARAListResponse struct {
	Entries []mwYARAEntry `json:"entries"`
}

func mwYARAListHandler(_ context.Context, _ json.RawMessage) (any, error) {
	r := mwYARAListResponse{Entries: []mwYARAEntry{}}
	entries, err := os.ReadDir(yaraDir)
	if err != nil {
		if os.IsNotExist(err) {
			return r, nil
		}
		return nil, mwInternal("readdir yara dir", err)
	}
	for _, e := range entries {
		if e.IsDir() || !yaraNameRE.MatchString(e.Name()) {
			continue
		}
		full := filepath.Join(yaraDir, e.Name())
		info, err := e.Info()
		if err != nil {
			continue
		}
		sum, _ := sha256File(full)
		r.Entries = append(r.Entries, mwYARAEntry{
			Filename:     e.Name(),
			Enabled:      !strings.HasSuffix(e.Name(), ".disabled"),
			SizeBytes:    info.Size(),
			SHA256:       sum,
			LastModified: info.ModTime().UTC().Format(time.RFC3339),
		})
	}
	return r, nil
}

type mwYARAUploadRequest struct {
	Filename      string `json:"filename"`
	ContentBase64 string `json:"content_base64"`
}

func mwYARAUploadHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var req mwYARAUploadRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	// Force .yar suffix on upload (operator can toggle to .disabled later).
	if !strings.HasSuffix(req.Filename, ".yar") {
		return nil, mwInvalidArg("filename must end in .yar")
	}
	if !yaraNameRE.MatchString(req.Filename) {
		return nil, mwInvalidArg("filename must match ^[a-z0-9][a-z0-9._-]{0,63}\\.yar$")
	}
	if strings.Contains(req.Filename, "/") {
		return nil, mwInvalidArg("filename must not contain a path separator")
	}
	content, err := decodeBase64(req.ContentBase64)
	if err != nil {
		return nil, mwInvalidArg("content_base64 is not valid base64")
	}
	if len(content) == 0 {
		return nil, mwInvalidArg("content is empty")
	}
	if len(content) > 64*1024 {
		return nil, mwInvalidArg("content exceeds 64 KB cap")
	}
	if err := os.MkdirAll(yaraDir, 0o755); err != nil {
		return nil, mwInternal("mkdir yara dir", err)
	}

	// Pre-flight parse check — write to temp file + run `yara -w`.
	tmp, err := os.CreateTemp(yaraDir, ".upload-*.yar")
	if err != nil {
		return nil, mwInternal("temp file", err)
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)
	if _, err := tmp.Write(content); err != nil {
		tmp.Close()
		return nil, mwInternal("write tmp", err)
	}
	tmp.Close()
	if out, err := exec.CommandContext(ctx, "yara", "-w", tmpPath, "/dev/null").CombinedOutput(); err != nil { //nolint:gosec
		return nil, mwInvalidArg(fmt.Sprintf("yara parse error: %s", strings.TrimSpace(string(out))))
	}

	dest := filepath.Join(yaraDir, req.Filename)
	if err := os.Rename(tmpPath, dest); err != nil {
		return nil, mwInternal("install rule", err)
	}
	if err := os.Chmod(dest, 0o644); err != nil {
		return nil, mwInternal("chmod rule", err)
	}
	sum, _ := sha256File(dest)
	return map[string]any{"filename": req.Filename, "sha256": sum}, nil
}

type mwYARAToggleRequest struct {
	Filename string `json:"filename"`
	Enabled  bool   `json:"enabled"`
}

func mwYARAToggleHandler(_ context.Context, raw json.RawMessage) (any, error) {
	var req mwYARAToggleRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if !yaraNameRE.MatchString(req.Filename) {
		return nil, mwInvalidArg("invalid filename")
	}
	src := filepath.Join(yaraDir, req.Filename)
	var dst string
	if req.Enabled {
		dst = strings.TrimSuffix(src, ".disabled")
		if dst == src && !strings.HasSuffix(src, ".yar") {
			return nil, mwInvalidArg("file already enabled")
		}
	} else {
		if strings.HasSuffix(src, ".disabled") {
			return nil, mwInvalidArg("file already disabled")
		}
		dst = src + ".disabled"
	}
	if _, err := os.Stat(src); err != nil {
		return nil, mwInvalidArg("file not found")
	}
	if err := os.Rename(src, dst); err != nil {
		return nil, mwInternal("rename", err)
	}
	return map[string]any{"filename": filepath.Base(dst), "enabled": req.Enabled}, nil
}

type mwYARADeleteRequest struct {
	Filename string `json:"filename"`
}

func mwYARADeleteHandler(_ context.Context, raw json.RawMessage) (any, error) {
	var req mwYARADeleteRequest
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, mwInvalidArg("malformed JSON body")
	}
	if !yaraNameRE.MatchString(req.Filename) {
		return nil, mwInvalidArg("invalid filename")
	}
	if strings.Contains(req.Filename, "/") {
		return nil, mwInvalidArg("filename must not contain '/'")
	}
	full := filepath.Join(yaraDir, req.Filename)
	if err := os.Remove(full); err != nil {
		return nil, mwInternal("remove rule", err)
	}
	return map[string]any{"deleted": true}, nil
}

// decodeBase64 keeps the encoding/base64 import local to the YARA
// upload path (it is the only consumer in this file).
func decodeBase64(s string) ([]byte, error) {
	return base64.StdEncoding.DecodeString(s)
}

// The maldet ingest path is now driven by maldet 2.0.1's post_scan_hook
// (see /etc/jabali/maldet/post-scan-hook.sh). mwEventHandler
// (security.malware.event wire command above) remains the generic
// agent-side ingest entrypoint for future scanners. Tetragon was
// removed by M39 (2026-04-30) — see ADR-0072 amendment + ADR-0085.

// ---- registration ----------------------------------------------------------

func init() {
	Default.Register("security.malware.status", mwStatusHandler)
	Default.Register("security.malware.scan_path", mwScanPathHandler)
	Default.Register("security.malware.scan_user", mwScanUserHandler)
	Default.Register("security.malware.scan_status", mwScanStatusHandler)
	Default.Register("security.malware.quarantine.list", mwQuarantineListHandler)
	Default.Register("security.malware.quarantine.restore", mwQuarantineRestoreHandler)
	Default.Register("security.malware.quarantine.delete", mwQuarantineDeleteHandler)
	Default.Register("security.malware.update_signatures", mwUpdateSignaturesHandler)
	Default.Register("security.malware.monitor.reload", mwMonitorReloadHandler)
	Default.Register("security.malware.monitor.set_enabled", mwMonitorSetEnabledHandler)
	Default.Register("security.malware.event", mwEventHandler)
	Default.Register("security.malware.yara.list", mwYARAListHandler)
	Default.Register("security.malware.yara.upload", mwYARAUploadHandler)
	Default.Register("security.malware.yara.toggle", mwYARAToggleHandler)
	Default.Register("security.malware.yara.delete", mwYARADeleteHandler)
}
