package web

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"html/template"
	"log/slog"
	"net"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/cereda-systems/nexus-scanner/internal/config"
	"github.com/cereda-systems/nexus-scanner/internal/db"
	"github.com/cereda-systems/nexus-scanner/internal/module"
)

// LogEntry is a single captured log line shown on the /logs page.
type LogEntry struct {
	Time       string
	Level      string
	LevelClass string
	Message    string
}

// LogBuffer is a fixed-size in-memory ring of log entries.
type LogBuffer struct {
	mu      sync.Mutex
	entries []LogEntry
	max     int
}

// NewLogBuffer creates a log ring buffer with capacity max.
func NewLogBuffer(max int) *LogBuffer { return &LogBuffer{max: max} }

func (b *LogBuffer) Add(e LogEntry) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.entries = append(b.entries, e)
	if len(b.entries) > b.max {
		b.entries = b.entries[len(b.entries)-b.max:]
	}
}

func (b *LogBuffer) All(level string) []LogEntry {
	b.mu.Lock()
	defer b.mu.Unlock()
	if level == "" {
		out := make([]LogEntry, len(b.entries))
		copy(out, b.entries)
		return out
	}
	var out []LogEntry
	for _, e := range b.entries {
		if e.Level == level {
			out = append(out, e)
		}
	}
	return out
}

func (b *LogBuffer) Clear() {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.entries = b.entries[:0]
}

// NewSlogHandler wraps an existing handler and mirrors records into buf.
func NewSlogHandler(inner slog.Handler, buf *LogBuffer) *SlogHandler {
	return &SlogHandler{inner: inner, buffer: buf}
}

// SlogHandler captures log records into a LogBuffer.
type SlogHandler struct {
	inner  slog.Handler
	buffer *LogBuffer
}

func (h *SlogHandler) Enabled(ctx context.Context, level slog.Level) bool {
	return h.inner.Enabled(ctx, level)
}
func (h *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return &SlogHandler{inner: h.inner.WithAttrs(attrs), buffer: h.buffer}
}
func (h *SlogHandler) WithGroup(name string) slog.Handler {
	return &SlogHandler{inner: h.inner.WithGroup(name), buffer: h.buffer}
}
func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error {
	lvl := r.Level.String()
	cls := strings.ToLower(lvl)
	if cls == "debug" {
		cls = "ok"
	}
	h.buffer.Add(LogEntry{
		Time:       r.Time.Format("15:04:05"),
		Level:      lvl,
		LevelClass: cls,
		Message:    r.Message,
	})
	return h.inner.Handle(ctx, r)
}

// sessionStore is a simple in-memory session store.
type sessionStore struct {
	mu       sync.Mutex
	sessions map[string]time.Time
}

func (s *sessionStore) Create() string {
	b := make([]byte, 16)
	rand.Read(b)
	id := hex.EncodeToString(b)
	s.mu.Lock()
	s.sessions[id] = time.Now().Add(24 * time.Hour)
	s.mu.Unlock()
	return id
}

func (s *sessionStore) Valid(id string) bool {
	s.mu.Lock()
	exp, ok := s.sessions[id]
	s.mu.Unlock()
	return ok && time.Now().Before(exp)
}

func (s *sessionStore) Delete(id string) {
	s.mu.Lock()
	delete(s.sessions, id)
	s.mu.Unlock()
}

// ModuleInfo is used in templates.
type ModuleInfo struct {
	Name        string
	DisplayName string
	Description string
	Interval    string
	Enabled     bool
	LastRun     string
}

// HostView extends db.Host with formatted fields for templates.
type HostView struct {
	db.Host
	LastSeenFmt string
	OpenPorts   []int
}

// Server is the embedded HTTP server.
type Server struct {
	addr     string
	cfgPath  string // path to config.yaml on disk (for saving after setup)
	cfg      *config.Config
	store    *db.Store
	registry *module.Registry
	sessions *sessionStore
	logBuf   *LogBuffer
	tmpl     *template.Template
	mux      *http.ServeMux

	scanMu   sync.Mutex
	scanning bool
}

// NewServer creates and wires up the server.
func NewServer(cfgPath string, cfg *config.Config, store *db.Store, registry *module.Registry, logBuf *LogBuffer) *Server {
	s := &Server{
		addr:     cfg.Web.Addr,
		cfgPath:  cfgPath,
		cfg:      cfg,
		store:    store,
		registry: registry,
		sessions: &sessionStore{sessions: make(map[string]time.Time)},
		logBuf:   logBuf,
		mux:      http.NewServeMux(),
	}

	s.loadTemplates()
	s.routes()
	return s
}

// LogBuffer returns the server's log buffer so main.go can wire slog into it.
func (s *Server) LogBuffer() *LogBuffer { return s.logBuf }

func (s *Server) loadTemplates() {
	funcMap := template.FuncMap{
		"inc":        func(i int) int { return i + 1 },
		"dec":        func(i int) int { return i - 1 },
		"mul":        func(a, b int) int { return a * b },
		"formatTime": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
	}
	t := template.New("").Funcs(funcMap)

	for _, name := range []string{
		"templates/layout.html",
		"templates/login.html",
		"templates/setup.html",
		"templates/dashboard.html",
		"templates/assets.html",
		"templates/modules.html",
		"templates/logs.html",
		"templates/settings.html",
		"templates/monitoring.html",
		"templates/snmp.html",
	} {
		template.Must(t.ParseFS(webFS, name))
	}

	s.tmpl = t
}

func (s *Server) routes() {
	s.mux.Handle("GET /static/", http.FileServer(http.FS(webFS)))
	s.mux.HandleFunc("GET /healthz", s.handleHealthz)

	// Setup wizard — only accessible before setup_complete=true
	s.mux.HandleFunc("GET /setup", s.setupGuard(s.handleSetupForm))
	s.mux.HandleFunc("POST /setup", s.setupGuard(s.handleSetupPost))
	s.mux.HandleFunc("POST /setup/complete", s.setupGuard(s.handleSetupComplete))

	// Login (only accessible after setup)
	s.mux.HandleFunc("GET /login", s.requireSetup(s.handleLoginForm))
	s.mux.HandleFunc("POST /login", s.requireSetup(s.handleLogin))
	s.mux.HandleFunc("GET /logout", s.handleLogout)

	// Protected app pages
	s.mux.HandleFunc("GET /", s.requireSetup(s.auth(s.handleRoot)))
	s.mux.HandleFunc("GET /dashboard", s.requireSetup(s.auth(s.handleDashboard)))
	s.mux.HandleFunc("GET /assets", s.requireSetup(s.auth(s.handleAssets)))
	s.mux.HandleFunc("GET /assets/export.csv", s.requireSetup(s.auth(s.handleAssetsCSV)))
	s.mux.HandleFunc("GET /modules", s.requireSetup(s.auth(s.handleModules)))
	s.mux.HandleFunc("POST /modules/{name}/toggle", s.requireSetup(s.auth(s.handleModuleToggle)))
	s.mux.HandleFunc("POST /modules/arp_discovery/config", s.requireSetup(s.auth(s.handleARPConfig)))
	s.mux.HandleFunc("GET /snmp", s.requireSetup(s.auth(s.handleSNMP)))
	s.mux.HandleFunc("POST /snmp/targets/add", s.requireSetup(s.auth(s.handleSNMPAddTarget)))
	s.mux.HandleFunc("POST /snmp/targets/{id}/delete", s.requireSetup(s.auth(s.handleSNMPDeleteTarget)))
	s.mux.HandleFunc("GET /monitoring", s.requireSetup(s.auth(s.handleMonitoring)))
	s.mux.HandleFunc("POST /monitoring/checks/add", s.requireSetup(s.auth(s.handleMonitoringAddCheck)))
	s.mux.HandleFunc("POST /monitoring/checks/{id}/delete", s.requireSetup(s.auth(s.handleMonitoringDeleteCheck)))
	s.mux.HandleFunc("POST /monitoring/scan", s.requireSetup(s.auth(s.handleMonitoringScan)))
	s.mux.HandleFunc("GET /ad", s.requireSetup(s.auth(s.handleAD)))
	s.mux.HandleFunc("GET /logs", s.requireSetup(s.auth(s.handleLogs)))
	s.mux.HandleFunc("GET /logs/rows", s.requireSetup(s.auth(s.handleLogsRows)))
	s.mux.HandleFunc("POST /logs/clear", s.requireSetup(s.auth(s.handleLogsClear)))
	s.mux.HandleFunc("GET /settings", s.requireSetup(s.auth(s.handleSettings)))
	s.mux.HandleFunc("POST /settings", s.requireSetup(s.auth(s.handleSettingsSave)))
	s.mux.HandleFunc("POST /scan/start", s.requireSetup(s.auth(s.handleScanStart)))
}

// requireSetup redirects to /setup if first-run wizard hasn't been completed.
func (s *Server) requireSetup(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if !s.cfg.SetupComplete {
			http.Redirect(w, r, "/setup", http.StatusSeeOther)
			return
		}
		next(w, r)
	}
}

// setupGuard redirects to /dashboard if setup is already done.
func (s *Server) setupGuard(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if s.cfg.SetupComplete {
			http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
			return
		}
		next(w, r)
	}
}

// ListenAndServe starts the HTTP server and blocks until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context) error {
	srv := &http.Server{
		Addr:         s.addr,
		Handler:      s.mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  60 * time.Second,
	}
	go func() {
		<-ctx.Done()
		shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		srv.Shutdown(shutCtx)
	}()
	slog.Info("web server listening", "addr", s.addr)
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		return fmt.Errorf("web: %w", err)
	}
	return nil
}

/* ── Auth ─────────────────────────────────────────────────────────── */

func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		cookie, err := r.Cookie("nx_session")
		if err != nil || !s.sessions.Valid(cookie.Value) {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		next(w, r)
	}
}

func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
	// Already logged in?
	if cookie, err := r.Cookie("nx_session"); err == nil && s.sessions.Valid(cookie.Value) {
		http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
		return
	}
	s.renderLogin(w, "")
}

func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
	pw := r.FormValue("password")
	if pw == "" || pw != s.cfg.Web.Token {
		s.renderLogin(w, "Falsches Passwort.")
		return
	}
	id := s.sessions.Create()
	http.SetCookie(w, &http.Cookie{
		Name: "nx_session", Value: id,
		Path: "/", MaxAge: 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode,
	})
	http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
	if cookie, err := r.Cookie("nx_session"); err == nil {
		s.sessions.Delete(cookie.Value)
	}
	http.SetCookie(w, &http.Cookie{Name: "nx_session", Path: "/", MaxAge: -1})
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	s.tmpl.ExecuteTemplate(w, "login", map[string]any{
		"Error": errMsg,
	})
}

/* ── Setup ────────────────────────────────────────────────────────── */

func (s *Server) handleSetupForm(w http.ResponseWriter, r *http.Request) {
	stepStr := r.URL.Query().Get("step")
	step, _ := strconv.Atoi(stepStr)
	s.renderSetup(w, step, nil, nil)
}

func (s *Server) handleSetupPost(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()
	step, _ := strconv.Atoi(r.FormValue("step"))
	next, _ := strconv.Atoi(r.FormValue("next"))

	switch step {
	case 1: // validate subnet
		subnet := r.FormValue("subnet")
		if !strings.Contains(subnet, "/") {
			s.renderSetup(w, step, fmt.Errorf("Ungültige CIDR-Notation (Beispiel: 192.168.0.0/24)"), r.Form)
			return
		}
	case 3: // validate password
		pw := r.FormValue("password")
		confirm := r.FormValue("confirm")
		if len(pw) < 6 {
			s.renderSetup(w, step, fmt.Errorf("Passwort muss mindestens 6 Zeichen haben"), r.Form)
			return
		}
		if pw != confirm {
			s.renderSetup(w, step, fmt.Errorf("Passwörter stimmen nicht überein"), r.Form)
			return
		}
	}

	s.renderSetup(w, next, nil, r.Form)
}

func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()

	// Apply wizard values to the in-memory config.
	if site := r.FormValue("site"); site != "" {
		s.cfg.Site = site
	}
	if subnet := r.FormValue("subnet"); subnet != "" {
		s.cfg.Modules.ARPDiscovery.Subnets = []string{subnet}
		s.cfg.Modules.ARPDiscovery.Enabled = true
	}
	if iface := r.FormValue("interface"); iface != "" {
		s.cfg.Modules.ARPDiscovery.Interface = iface
	}
	if url := r.FormValue("nexus_url"); url != "" {
		s.cfg.Nexus.URL = url
	}
	if key := r.FormValue("nexus_api_key"); key != "" {
		s.cfg.Nexus.APIKey = key
	}
	if pw := r.FormValue("password"); len(pw) >= 6 {
		s.cfg.Web.Token = pw
	}

	s.cfg.SetupComplete = true

	// Persist to disk so the wizard doesn't re-appear after restart.
	if err := config.Save(s.cfgPath, s.cfg); err != nil {
		slog.Error("save config after setup", "err", err)
		// Don't block the user — continue anyway.
	} else {
		slog.Info("config saved after setup", "path", s.cfgPath, "site", s.cfg.Site)
	}

	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

func (s *Server) renderSetup(w http.ResponseWriter, step int, err error, form map[string][]string) {
	steps := []int{0, 1, 2, 3, 4}

	// Detect available network interfaces (up, non-loopback, with IP).
	ifaces := detectInterfaces()
	selectedIface := getForm(form, "interface")
	if selectedIface == "" && len(ifaces) > 0 {
		selectedIface = ifaces[0]
	}

	data := map[string]any{
		"Step":  step,
		"Steps": steps,
		"Error": func() string {
			if err != nil {
				return err.Error()
			}
			return ""
		}(),
		"Site":        getForm(form, "site"),
		"Subnet":      getForm(form, "subnet"),
		"Interface":   selectedIface,
		"Interfaces":  ifaces,
		"NexusURL":    getForm(form, "nexus_url"),
		"NexusAPIKey": getForm(form, "nexus_api_key"),
		"Password":    getForm(form, "password"),
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	s.tmpl.ExecuteTemplate(w, "setup", data)
}

/* ── Pages ─────────────────────────────────────────────────────────── */

func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
	count, _ := s.store.CountHosts()
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"status": "ok", "hosts": count, "time": time.Now().UTC(),
	})
}

func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
	hosts, _ := s.store.ListHosts("")
	count, _ := s.store.CountHosts()
	online, offline := 0, 0
	for _, h := range hosts {
		if h.Status == "online" {
			online++
		} else {
			offline++
		}
	}
	recent := hosts
	if len(recent) > 5 {
		recent = recent[:5]
	}
	recentViews := make([]HostView, len(recent))
	for i, h := range recent {
		recentViews[i] = HostView{Host: h, LastSeenFmt: formatAgo(h.LastSeen)}
	}

	lastScan, _ := s.store.LastScanTime("sys_arp")
	lastScanStr := "—"
	if !lastScan.IsZero() {
		lastScanStr = formatAgo(lastScan)
	}

	// Count hosts added in last hour as "recent"
	recentCount := 0
	for _, h := range hosts {
		if time.Since(h.FirstSeen) < time.Hour {
			recentCount++
		}
	}

	s.scanMu.Lock()
	scanning := s.scanning
	s.scanMu.Unlock()

	s.renderPage(w, "dashboard", map[string]any{
		"Page":         "dashboard",
		"Title":        "Übersicht",
		"Scanning":     scanning,
		"HostCount":    count,
		"OnlineCount":  online,
		"OfflineCount": offline,
		"RecentCount":  recentCount,
		"LastScan":     lastScanStr,
		"ARPInterval":  s.cfg.Modules.ARPDiscovery.Interval.String(),
		"RecentHosts":  recentViews,
		"Modules":      s.moduleInfos(),
	})
}

func (s *Server) handleAssets(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query().Get("q")
	siteF := r.URL.Query().Get("site")
	statusF := r.URL.Query().Get("status")
	pageStr := r.URL.Query().Get("page")
	page, _ := strconv.Atoi(pageStr)
	if page < 1 {
		page = 1
	}
	const perPage = 20

	all, _ := s.store.ListHosts(siteF)

	// Filter
	var filtered []db.Host
	for _, h := range all {
		if q != "" {
			lower := strings.ToLower(q)
			if !strings.Contains(strings.ToLower(h.IP), lower) &&
				!strings.Contains(strings.ToLower(h.MAC), lower) &&
				!strings.Contains(strings.ToLower(h.Hostname), lower) {
				continue
			}
		}
		if statusF != "" && h.Status != statusF {
			continue
		}
		filtered = append(filtered, h)
	}

	total := len(filtered)
	totalPages := (total + perPage - 1) / perPage
	start := (page - 1) * perPage
	end := start + perPage
	if end > total {
		end = total
	}
	var pageHosts []db.Host
	if start < total {
		pageHosts = filtered[start:end]
	}

	allPorts, _ := s.store.ListAllHostPorts()
	views := make([]HostView, len(pageHosts))
	for i, h := range pageHosts {
		views[i] = HostView{Host: h, LastSeenFmt: formatAgo(h.LastSeen), OpenPorts: allPorts[h.IP]}
	}

	pages := make([]int, totalPages)
	for i := range pages {
		pages[i] = i + 1
	}

	qs := fmt.Sprintf("q=%s&site=%s&status=%s", q, siteF, statusF)

	lastScan, _ := s.store.LastScanTime("sys_arp")
	lastScanStr := "gerade eben"
	if !lastScan.IsZero() {
		lastScanStr = formatAgo(lastScan)
	}

	s.renderPage(w, "assets", map[string]any{
		"Page":         "assets",
		"Title":        "Assets",
		"Hosts":        views,
		"TotalHosts":   total,
		"LastScan":     lastScanStr,
		"SiteFilter":   siteF,
		"StatusFilter": statusF,
		"Query":        q,
		"PageNum":      page,
		"TotalPages":   totalPages,
		"Pages":        pages,
		"QueryString":  qs,
	})
}

func (s *Server) handleAssetsCSV(w http.ResponseWriter, r *http.Request) {
	hosts, _ := s.store.ListHosts("")
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", `attachment; filename="assets.csv"`)
	fmt.Fprintln(w, "IP,MAC,Hersteller,Hostname,Standort,Zuletzt gesehen,Status")
	for _, h := range hosts {
		fmt.Fprintf(w, "%s,%s,%s,%s,%s,%s,%s\n",
			h.IP, h.MAC, h.Vendor, h.Hostname, h.Site,
			h.LastSeen.Format("2006-01-02 15:04:05"), h.Status)
	}
}

type ADComputerView struct {
	db.ADComputer
	SeenInNetwork bool
}

func (s *Server) handleAD(w http.ResponseWriter, r *http.Request) {
	computers, _ := s.store.ListADComputers()
	hosts, _ := s.store.ListHosts("")

	// Build hostname set for quick lookup.
	knownHostnames := make(map[string]struct{})
	for _, h := range hosts {
		if h.Hostname != "" {
			knownHostnames[strings.ToLower(h.Hostname)] = struct{}{}
		}
	}

	views := make([]ADComputerView, len(computers))
	seen, missing := 0, 0
	for i, c := range computers {
		_, inNet := knownHostnames[strings.ToLower(c.CN)]
		views[i] = ADComputerView{ADComputer: c, SeenInNetwork: inNet}
		if inNet {
			seen++
		} else {
			missing++
		}
	}

	funcMap := template.FuncMap{
		"inc":        func(i int) int { return i + 1 },
		"dec":        func(i int) int { return i - 1 },
		"formatTime": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
		"not":        func(b bool) bool { return !b },
	}
	t, err := template.New("").Funcs(funcMap).ParseFS(webFS,
		"templates/layout.html",
		"templates/ad.html",
	)
	if err != nil {
		http.Error(w, "template error", 500)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	t.ExecuteTemplate(w, "layout", map[string]any{
		"Page":         "ad",
		"Title":        "Active Directory",
		"SiteID":       s.cfg.Site,
		"Addr":         s.cfg.Web.Addr,
		"Computers":    views,
		"Domain":       s.cfg.Modules.ADSync.SearchBase,
		"ADEnabled":    s.cfg.Modules.ADSync.Enabled,
		"SeenCount":    seen,
		"MissingCount": missing,
	})
}

func (s *Server) handleModules(w http.ResponseWriter, r *http.Request) {
	s.renderPage(w, "modules", map[string]any{
		"Page":         "modules",
		"Title":        "Module",
		"Modules":      s.moduleInfos(),
		"ARPSubnets":   s.cfg.Modules.ARPDiscovery.Subnets,
		"ARPInterface": s.cfg.Modules.ARPDiscovery.Interface,
	})
}

func (s *Server) handleModuleToggle(w http.ResponseWriter, r *http.Request) {
	name := r.PathValue("name")
	current := s.store.GetModuleEnabled(name)
	enabled := !current
	if err := s.store.SetModuleEnabled(name, enabled); err != nil {
		slog.Error("toggle module", "module", name, "err", err)
	} else {
		slog.Info("module toggled", "module", name, "enabled", enabled)
	}
	http.Redirect(w, r, "/modules", http.StatusSeeOther)
}

func (s *Server) handleARPConfig(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()
	subnets := r.Form["subnet"]
	iface := r.FormValue("interface")
	if len(subnets) > 0 {
		s.cfg.Modules.ARPDiscovery.Subnets = subnets
	}
	if iface != "" {
		s.cfg.Modules.ARPDiscovery.Interface = iface
	}
	if err := config.Save(s.cfgPath, s.cfg); err != nil {
		slog.Error("save arp config", "err", err)
	} else {
		slog.Info("arp config saved", "subnets", subnets, "interface", iface)
	}
	http.Redirect(w, r, "/modules", http.StatusSeeOther)
}

func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
	level := r.URL.Query().Get("level")
	logs := s.logBuf.All(level)
	reverseLogs(logs)
	s.renderPage(w, "logs", map[string]any{
		"Page":        "logs",
		"Title":       "Logs",
		"Logs":        logs,
		"LevelFilter": level,
	})
}

func (s *Server) handleLogsRows(w http.ResponseWriter, r *http.Request) {
	level := r.URL.Query().Get("level")
	logs := s.logBuf.All(level)
	reverseLogs(logs)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	s.tmpl.ExecuteTemplate(w, "log-rows", map[string]any{
		"Logs":        logs,
		"LevelFilter": level,
	})
}

func (s *Server) handleLogsClear(w http.ResponseWriter, r *http.Request) {
	s.logBuf.Clear()
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) settingsData(extra map[string]any) map[string]any {
	data := map[string]any{
		"Page":         "settings",
		"Title":        "Einstellungen",
		"Site":         s.cfg.Site,
		"NexusURL":     s.cfg.Nexus.URL,
		"NexusAPIKey":  s.cfg.Nexus.APIKey,
		"ARPInterval":  s.cfg.Modules.ARPDiscovery.Interval.String(),
		"ADEnabled":    s.cfg.Modules.ADSync.Enabled,
		"ADServer":     s.cfg.Modules.ADSync.Server,
		"ADPort":       s.cfg.Modules.ADSync.Port,
		"ADBindDN":     s.cfg.Modules.ADSync.BindDN,
		"ADBindPW":     s.cfg.Modules.ADSync.BindPassword,
		"ADSearchBase": s.cfg.Modules.ADSync.SearchBase,
		"ADInterval":    s.cfg.Modules.ADSync.Interval.String(),
		"OfflineAfter":  s.cfg.OfflineAfter.String(),
		"AlertNexus":   s.cfg.Alert.NexusEnabled,
		"SMTPEnabled":  s.cfg.Alert.SMTP.Enabled,
		"SMTPHost":     s.cfg.Alert.SMTP.Host,
		"SMTPPort":     s.cfg.Alert.SMTP.Port,
		"SMTPUser":     s.cfg.Alert.SMTP.Username,
		"SMTPPass":     s.cfg.Alert.SMTP.Password,
		"SMTPFrom":     s.cfg.Alert.SMTP.From,
		"SMTPTo":       s.cfg.Alert.SMTP.To,
	}
	for k, v := range extra {
		data[k] = v
	}
	return data
}

func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
	s.renderPage(w, "settings", s.settingsData(nil))
}

func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()

	// Apply general settings.
	if site := r.FormValue("site"); site != "" {
		s.cfg.Site = site
	}
	s.cfg.Nexus.URL = r.FormValue("nexus_url")
	if key := r.FormValue("nexus_api_key"); key != "" {
		s.cfg.Nexus.APIKey = key
	}

	// Apply AD settings.
	s.cfg.Modules.ADSync.Enabled = r.FormValue("ad_enabled") == "on"
	s.cfg.Modules.ADSync.Server = r.FormValue("ad_server")
	s.cfg.Modules.ADSync.BindDN = r.FormValue("ad_bind_dn")
	if pw := r.FormValue("ad_bind_password"); pw != "" {
		s.cfg.Modules.ADSync.BindPassword = pw
	}
	if sb := r.FormValue("ad_search_base"); sb != "" {
		s.cfg.Modules.ADSync.SearchBase = sb
	}
	if port := r.FormValue("ad_port"); port != "" {
		if p, err := strconv.Atoi(port); err == nil && p > 0 {
			s.cfg.Modules.ADSync.Port = p
		}
	}
	if iv := r.FormValue("ad_interval"); iv != "" {
		if d, err := time.ParseDuration(iv); err == nil {
			s.cfg.Modules.ADSync.Interval = d
		}
	}

	// offline_after
	if oa := r.FormValue("offline_after"); oa != "" {
		if d, err := time.ParseDuration(oa); err == nil && d > 0 {
			s.cfg.OfflineAfter = d
		}
	}

	// Apply alert settings.
	s.cfg.Alert.NexusEnabled = r.FormValue("alert_nexus") == "on"
	s.cfg.Alert.SMTP.Enabled = r.FormValue("smtp_enabled") == "on"
	s.cfg.Alert.SMTP.Host = r.FormValue("smtp_host")
	s.cfg.Alert.SMTP.Username = r.FormValue("smtp_user")
	if p := r.FormValue("smtp_pass"); p != "" {
		s.cfg.Alert.SMTP.Password = p
	}
	s.cfg.Alert.SMTP.From = r.FormValue("smtp_from")
	s.cfg.Alert.SMTP.To = r.FormValue("smtp_to")
	if port := r.FormValue("smtp_port"); port != "" {
		if p, err := strconv.Atoi(port); err == nil {
			s.cfg.Alert.SMTP.Port = p
		}
	}

	// Apply password change.
	if pw := r.FormValue("new_password"); len(pw) >= 6 {
		if pw == r.FormValue("confirm_password") {
			s.cfg.Web.Token = pw
		}
	}

	// Persist to disk.
	var saveErr string
	if err := config.Save(s.cfgPath, s.cfg); err != nil {
		slog.Error("save config", "err", err)
		saveErr = err.Error()
	} else {
		slog.Info("settings saved", "site", s.cfg.Site, "ad_enabled", s.cfg.Modules.ADSync.Enabled)
	}

	s.renderPage(w, "settings", s.settingsData(map[string]any{
		"Saved":   saveErr == "",
		"SaveErr": saveErr,
	}))
}

func (s *Server) handleScanStart(w http.ResponseWriter, r *http.Request) {
	s.scanMu.Lock()
	if !s.scanning {
		s.scanning = true
		go func() {
			slog.Info("manual scan triggered")
			ctx := context.Background()
			for _, m := range s.registry.All() {
				if err := m.Run(ctx); err != nil {
					slog.Error("manual scan error", "module", m.Name(), "err", err)
				}
			}
			s.scanMu.Lock()
			s.scanning = false
			s.scanMu.Unlock()
			slog.Info("manual scan completed")
		}()
	}
	s.scanMu.Unlock()
	http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

/* ── SNMP ──────────────────────────────────────────────────────────── */

type SNMPTargetView struct {
	Target      db.SNMPTarget
	HasData     bool
	SysName     string
	SysDescr    string
	SysUpTime   string
	SysLocation string
	SysContact  string
	TonerLevel     string
	TonerMax       string
	TonerPct       int
	PageCount      string
	PrinterStatus  string
	Interval    string
	// Switch fields
	PortsTotal  string
	PortsUp     string
	TrafficIn   string
	TrafficOut  string
	MemUsed     string
	MemTotal    string
	MemPct      int
	CpuLoad     string
	IfNumber    string
	// Raw OID map for detail modal
	AllValues   map[string]string
}

func (s *Server) handleSNMP(w http.ResponseWriter, r *http.Request) {
	targets, _ := s.store.ListSNMPTargets()
	results, _ := s.store.LatestSNMPResults()

	var views []SNMPTargetView
	for _, t := range targets {
		v := SNMPTargetView{Target: t, Interval: s.cfg.Modules.SNMP.Interval.String()}
		if vals, ok := results[t.ID]; ok && len(vals) > 0 {
			v.HasData = true
			v.AllValues = vals
			v.SysName = vals["sysName"]
			v.SysDescr = vals["sysDescr"]
			v.SysUpTime = vals["sysUpTime"]
			v.SysLocation = vals["sysLocation"]
			v.SysContact = vals["sysContact"]
			v.TonerLevel = vals["tonerLevel"]
			v.TonerMax = vals["tonerMax"]
			v.PageCount = vals["pageCount"]
			switch vals["printerStatus"] {
			case "3":
				v.PrinterStatus = "Bereit"
			case "4":
				v.PrinterStatus = "Druckt"
			case "5":
				v.PrinterStatus = "Aufwärmen"
			default:
				v.PrinterStatus = ""
			}
			v.PortsTotal = vals["portsTotal"]
			v.PortsUp = vals["portsUp"]
			v.IfNumber = vals["ifNumber"]
			if cpu := vals["cpuLoad"]; cpu != "" && cpu != "<nil>" && !strings.Contains(cpu, "nil") {
				v.CpuLoad = cpu
			}
			if v.TonerLevel != "" {
				level, _ := strconv.Atoi(v.TonerLevel)
				if maxStr := vals["tonerMax"]; maxStr != "" {
					maxN, _ := strconv.Atoi(maxStr)
					if maxN > 0 {
						v.TonerPct = level * 100 / maxN
					}
				} else {
					v.TonerPct = level
				}
			}
			// Format traffic bytes → human readable, also update AllValues for modal
			if raw := vals["trafficIn"]; raw != "" {
				n, _ := strconv.ParseUint(raw, 10, 64)
				v.TrafficIn = formatBytes(n)
				vals["trafficIn"] = formatBytes(n)
			}
			if raw := vals["trafficOut"]; raw != "" {
				n, _ := strconv.ParseUint(raw, 10, 64)
				v.TrafficOut = formatBytes(n)
				vals["trafficOut"] = formatBytes(n)
			}
			// Memory
			if raw := vals["memUsed"]; raw != "" {
				n, _ := strconv.ParseUint(raw, 10, 64)
				v.MemUsed = formatBytes(n)
				vals["memUsed"] = formatBytes(n)
			}
			if raw := vals["memTotal"]; raw != "" {
				n, _ := strconv.ParseUint(raw, 10, 64)
				v.MemTotal = formatBytes(n)
				vals["memTotal"] = formatBytes(n)
				used, _ := strconv.ParseUint(vals["memUsed"], 10, 64)
				if n > 0 {
					v.MemPct = int(used * 100 / n)
				}
			}
			// Remove nil entries from detail modal
			for k, val := range vals {
				if val == "<nil>" || strings.Contains(val, "<nil>") {
					delete(vals, k)
				}
			}
		}
		views = append(views, v)
	}

	s.renderPage(w, "snmp", map[string]any{
		"Page":    "snmp",
		"Title":   "SNMP",
		"Targets": views,
		"AddErr":  r.URL.Query().Get("err"),
	})
}

func (s *Server) handleSNMPAddTarget(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()
	name := strings.TrimSpace(r.FormValue("name"))
	ip := strings.TrimSpace(r.FormValue("ip"))
	community := r.FormValue("community")
	version := r.FormValue("version")
	typ := r.FormValue("type")
	if community == "" {
		community = "public"
	}
	if name == "" || ip == "" {
		http.Redirect(w, r, "/snmp?err=Name+und+IP+erforderlich", http.StatusSeeOther)
		return
	}
	if _, err := s.store.AddSNMPTarget(name, ip, community, version, typ); err != nil {
		slog.Error("add snmp target", "err", err)
	}
	http.Redirect(w, r, "/snmp", http.StatusSeeOther)
}

func (s *Server) handleSNMPDeleteTarget(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	_ = s.store.DeleteSNMPTarget(id)
	http.Redirect(w, r, "/snmp", http.StatusSeeOther)
}

/* ── Monitoring ────────────────────────────────────────────────────── */

// MonitorCheckView combines a check definition with its latest result and history.
type MonitorCheckView struct {
	Check     db.MonitorCheck
	Latest    db.MonitorResult
	HasResult bool
	History   []db.MonitorResult
}

func (s *Server) handleMonitoring(w http.ResponseWriter, r *http.Request) {
	s.renderMonitoring(w, "")
}

func (s *Server) renderMonitoring(w http.ResponseWriter, addErr string) {
	checks, _ := s.store.ListMonitorChecks()
	latest, _ := s.store.LatestMonitorResults()

	var views []MonitorCheckView
	onlineCount, offlineCount := 0, 0

	for _, c := range checks {
		v := MonitorCheckView{Check: c}
		if r, ok := latest[c.ID]; ok {
			v.Latest = r
			v.HasResult = true
			history, _ := s.store.MonitorResultHistory(c.ID, 20)
			v.History = history
			if r.Status == "online" {
				onlineCount++
			} else {
				offlineCount++
			}
		}
		views = append(views, v)
	}

	allOnline := len(checks) > 0 && offlineCount == 0 && len(latest) == len(checks)

	s.renderPage(w, "monitoring", map[string]any{
		"Page":         "monitoring",
		"Title":        "Monitoring",
		"Checks":       views,
		"TotalChecks":  len(checks),
		"OnlineCount":  onlineCount,
		"OfflineCount": offlineCount,
		"AllOnline":    allOnline,
		"AddErr":       addErr,
	})
}

func (s *Server) handleMonitoringAddCheck(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()
	name := strings.TrimSpace(r.FormValue("name"))
	typ := r.FormValue("type")
	target := strings.TrimSpace(r.FormValue("target"))

	if name == "" || target == "" {
		s.renderMonitoring(w, "Name und Ziel dürfen nicht leer sein.")
		return
	}
	validTypes := map[string]bool{"ping": true, "http": true, "tcp": true}
	if !validTypes[typ] {
		s.renderMonitoring(w, "Ungültiger Typ.")
		return
	}

	if _, err := s.store.AddMonitorCheck(name, typ, target); err != nil {
		slog.Error("add monitor check", "err", err)
		s.renderMonitoring(w, "Fehler beim Speichern: "+err.Error())
		return
	}
	slog.Info("monitor check added", "name", name, "type", typ, "target", target)
	http.Redirect(w, r, "/monitoring", http.StatusSeeOther)
}

func (s *Server) handleMonitoringDeleteCheck(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}
	if err := s.store.DeleteMonitorCheck(id); err != nil {
		slog.Error("delete monitor check", "id", id, "err", err)
	}
	http.Redirect(w, r, "/monitoring", http.StatusSeeOther)
}

func (s *Server) handleMonitoringScan(w http.ResponseWriter, r *http.Request) {
	s.scanMu.Lock()
	if !s.scanning {
		s.scanning = true
		go func() {
			ctx := context.Background()
			for _, m := range s.registry.All() {
				if m.Name() == "site_monitoring" {
					if err := m.Run(ctx); err != nil {
						slog.Error("monitoring scan error", "err", err)
					}
				}
			}
			s.scanMu.Lock()
			s.scanning = false
			s.scanMu.Unlock()
		}()
	}
	s.scanMu.Unlock()
	http.Redirect(w, r, "/monitoring", http.StatusSeeOther)
}

/* ── Helpers ──────────────────────────────────────────────────────── */

func (s *Server) renderPage(w http.ResponseWriter, page string, data map[string]any) {
	// Inject shared layout data.
	if _, ok := data["Theme"]; !ok {
		data["Theme"] = "light"
	}
	if _, ok := data["SiteID"]; !ok {
		data["SiteID"] = s.cfg.Site
	}
	if _, ok := data["HostCount"]; !ok {
		n, _ := s.store.CountHosts()
		data["HostCount"] = n
	}
	if _, ok := data["Addr"]; !ok {
		data["Addr"] = s.cfg.Web.Addr
	}
	if _, ok := data["Scanning"]; !ok {
		s.scanMu.Lock()
		data["Scanning"] = s.scanning
		s.scanMu.Unlock()
	}

	// Parse layout + page template together so "content" block is available.
	funcMap := template.FuncMap{
		"inc":        func(i int) int { return i + 1 },
		"dec":        func(i int) int { return i - 1 },
		"mul":        func(a, b int) int { return a * b },
		"formatTime": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
		"not":        func(b bool) bool { return !b },
	}
	t, err := template.New("").Funcs(funcMap).ParseFS(webFS,
		"templates/layout.html",
		"templates/"+page+".html",
	)
	if err != nil {
		slog.Error("template parse", "page", page, "err", err)
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := t.ExecuteTemplate(w, "layout", data); err != nil {
		slog.Error("template exec", "page", page, "err", err)
	}
}

func (s *Server) moduleInfos() []ModuleInfo {
	var infos []ModuleInfo
	for _, m := range s.registry.All() {
		lastRun, _ := s.store.LastScanTime(m.Name())
		lastRunStr := ""
		if !lastRun.IsZero() {
			lastRunStr = formatAgo(lastRun)
		}
		infos = append(infos, ModuleInfo{
			Name:        m.Name(),
			DisplayName: displayName(m.Name()),
			Description: moduleDesc(m.Name()),
			Interval:    m.Interval().String(),
			Enabled:     s.store.GetModuleEnabled(m.Name()),
			LastRun:     lastRunStr,
		})
	}
	return infos
}

func formatAgo(t time.Time) string {
	d := time.Since(t)
	switch {
	case d < time.Minute:
		return fmt.Sprintf("vor %ds", int(d.Seconds()))
	case d < time.Hour:
		return fmt.Sprintf("vor %dm", int(d.Minutes()))
	case d < 24*time.Hour:
		return fmt.Sprintf("vor %dh", int(d.Hours()))
	default:
		return fmt.Sprintf("vor %dd", int(d.Hours()/24))
	}
}

func reverseLogs(logs []LogEntry) {
	sort.Slice(logs, func(i, j int) bool { return i > j })
}

func formatBytes(b uint64) string {
	const unit = 1024
	if b < unit {
		return fmt.Sprintf("%d B", b)
	}
	div, exp := uint64(unit), 0
	for n := b / unit; n >= unit; n /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}

func getForm(form map[string][]string, key string) string {
	if form == nil {
		return ""
	}
	vals := form[key]
	if len(vals) == 0 {
		return ""
	}
	return vals[0]
}

func displayName(name string) string {
	m := map[string]string{
		"arp_discovery": "ARP-Discovery",
		"dns_reverse":   "DNS-Reverse",
		"mac_vendor":    "MAC-Vendor",
		"ad_sync":       "Active Directory",
		"unifi_api":     "UniFi Controller",
	}
	if v, ok := m[name]; ok {
		return v
	}
	return name
}

// detectInterfaces returns names of active non-loopback network interfaces.
func detectInterfaces() []string {
	ifaces, err := net.Interfaces()
	if err != nil {
		return []string{"eth0"}
	}
	var names []string
	for _, iface := range ifaces {
		if iface.Flags&net.FlagLoopback != 0 {
			continue
		}
		if iface.Flags&net.FlagUp == 0 {
			continue
		}
		addrs, _ := iface.Addrs()
		hasIP := false
		for _, a := range addrs {
			if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
				hasIP = true
				break
			}
		}
		if hasIP {
			names = append(names, iface.Name)
		}
	}
	if len(names) == 0 {
		return []string{"eth0"}
	}
	return names
}

func moduleDesc(name string) string {
	m := map[string]string{
		"arp_discovery": "Findet alle Geräte per ARP-Broadcast",
		"dns_reverse":   "Löst IPs in Hostnamen auf",
		"mac_vendor":    "Bestimmt Hersteller aus MAC-Adresse",
		"ad_sync":       "Synchronisiert Computer aus winkel.local",
		"unifi_api":     "Liest Clients aus UniFi Controller",
	}
	if v, ok := m[name]; ok {
		return v
	}
	return ""
}
