package handlers

import (
	"context"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"html/template"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/gorilla/websocket"
	"github.com/skip2/go-qrcode"
	"golang.org/x/crypto/ssh"
	localmiddleware "maintainarr/internal/middleware"
	"maintainarr/internal/models"
	"maintainarr/internal/services"
	"maintainarr/internal/views"
)

type Handler struct {
	repo     *services.Repository
	auth     *services.AuthService
	sessions *services.SessionService
	nodes    *services.NodeService
	renderer *views.Renderer
	org      models.Organization
	baseURL  string
	upgrader websocket.Upgrader
}

type dashboardData struct {
	Groups    []dashboardNodeGroup
	Ungrouped []models.Node
}

type dashboardNodeGroup struct {
	Name  string
	Icon  string
	Nodes []models.Node
}

type groupsPageData struct {
	Groups []groupSummary
}

type groupSummary struct {
	Group   models.VMGroup
	VMCount int
}

type nodePageData struct {
	Node            *models.Node
	CommandRuns     []models.CommandRun
	ConsoleToken    string
	SelectedGroupID int64
}

type settingsData struct {
	Logs        []settingsCommandLog
	Page        int
	TotalPages  int
	HasPrev     bool
	HasNext     bool
	PrevPage    int
	NextPage    int
	CurrentPath string
}

type settingsCommandLog struct {
	Target      string
	StartedAt   time.Time
	Status      string
	CommandText string
	Output      string
	Duration    string
}

type userSettingsData struct {
	CurrentMode string
	OTPEnabled  bool
	OTPSecret   string
	OTPQRCode   string
	Message     string
	Error       string
}

type jobsPageData struct {
	Jobs   []models.AutomationJob
	Nodes  []models.Node
	Groups []models.VMGroup
	Tags   []string
	Runs   []models.CommandRun
}

type updatesPageData struct {
	TotalUpdates      int64
	NodesWithUpdates  int
	ScannedNodes      int
	AutoUpdateNodes   int
	GlobalWindowStart string
	GlobalWindowEnd   string
	GlobalUpdateDays  string
	Groups            []updatesGroup
	Ungrouped         []models.Node
}

type updatesGroup struct {
	ID               int64
	Name             string
	Nodes            []models.Node
	UpdatesAvailable int64
	NodesWithUpdates int
	ScanEnabled      bool
	AutoUpdate       bool
	WindowStart      string
	WindowEnd        string
	UpdateDays       string
}

type uptimePageData struct {
	Summary   uptimeSummary
	Chart     uptimeChartData
	Periods   []uptimePeriodRow
	Monitors  []uptimeMonitorCard
	Incidents []models.UptimeIncident
}

type uptimeSummary struct {
	TotalMonitors int
	UpMonitors    int
	DownMonitors  int
	AvgLatencyMS  int64
}

type uptimePeriodRow struct {
	Label            string
	AvailabilityText string
	DowntimeText     string
	Incidents        int64
	LongestText      string
	AverageText      string
}

type uptimeMonitorCard struct {
	Monitor           models.UptimeMonitor
	Availability      float64
	AvailabilityText  string
	LastCheckedText   string
	StateDurationText string
	IntervalText      string
	RecentChecks      []models.UptimeCheck
}

type uptimeChartPoint struct {
	Label              string
	Availability       float64
	AvailabilityText   string
	UpCount            int
	DownCount          int
	TotalCount         int
	AvailabilityHeight int
	X                  float64
	Y                  float64
	ShowLabel          bool
}

type uptimeChartData struct {
	Points          []uptimeChartPoint
	LabelsJSON      template.JS
	ValuesJSON      template.JS
	PointColorsJSON template.JS
}

func New(repo *services.Repository, auth *services.AuthService, sessions *services.SessionService, nodes *services.NodeService, renderer *views.Renderer, org models.Organization, baseURL string) *Handler {
	return &Handler{
		repo:     repo,
		auth:     auth,
		sessions: sessions,
		nodes:    nodes,
		renderer: renderer,
		org:      org,
		baseURL:  baseURL,
		upgrader: websocket.Upgrader{
			CheckOrigin: func(r *http.Request) bool { return true },
		},
	}
}

func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
	if user := localmiddleware.CurrentUser(r); user != nil {
		http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) {
	h.render(w, r, "login", "Sign In", nil)
}

func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	user, err := h.repo.GetUserByEmail(r.Context(), strings.TrimSpace(r.FormValue("email")))
	if err != nil || h.auth.ComparePassword(user.PasswordHash, r.FormValue("password")) != nil {
		h.render(w, r, "login", "Sign In", map[string]any{"Error": "Invalid credentials"})
		return
	}

	session, _ := h.sessions.Get(r)
	if user.OTPEnabled {
		session.Values["pending_user_id"] = user.ID
		delete(session.Values, "user_id")
		_ = session.Save(r, w)
		http.Redirect(w, r, "/login/otp", http.StatusSeeOther)
		return
	}

	session.Values["user_id"] = user.ID
	delete(session.Values, "pending_user_id")
	_ = session.Save(r, w)

	if !user.OTPEnabled {
		http.Redirect(w, r, "/setup-2fa", http.StatusSeeOther)
		return
	}

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

func (h *Handler) LoginOTPPage(w http.ResponseWriter, r *http.Request) {
	h.render(w, r, "login_otp", "Two-Factor Verification", nil)
}

func (h *Handler) LoginOTP(w http.ResponseWriter, r *http.Request) {
	session, _ := h.sessions.Get(r)
	pendingUserID, ok := session.Values["pending_user_id"].(int64)
	if !ok {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	user, err := h.repo.GetUserByID(r.Context(), pendingUserID)
	if err != nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	if !h.auth.VerifyOTP(user.OTPSecret, strings.TrimSpace(r.FormValue("code"))) {
		h.render(w, r, "login_otp", "Two-Factor Verification", map[string]any{"Error": "Invalid OTP code"})
		return
	}

	session.Values["user_id"] = user.ID
	delete(session.Values, "pending_user_id")
	_ = session.Save(r, w)
	http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

func (h *Handler) RegisterPage(w http.ResponseWriter, r *http.Request) {
	h.render(w, r, "register", "Register", nil)
}

func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	password := r.FormValue("password")
	if len(password) < 8 {
		h.render(w, r, "register", "Register", map[string]any{"Error": "Password must be at least 8 characters"})
		return
	}

	key, err := h.auth.NewOTP(strings.TrimSpace(r.FormValue("email")))
	if err != nil {
		http.Error(w, "failed to generate OTP", http.StatusInternalServerError)
		return
	}

	hash, err := h.auth.HashPassword(password)
	if err != nil {
		http.Error(w, "failed to hash password", http.StatusInternalServerError)
		return
	}

	role := models.RoleViewer
	count, err := h.repo.CountUsers(r.Context())
	if err == nil && count == 0 {
		role = models.RoleAdmin
	}

	user := &models.User{
		Organization: h.org.ID,
		Name:         strings.TrimSpace(r.FormValue("name")),
		Email:        strings.TrimSpace(r.FormValue("email")),
		PasswordHash: hash,
		Role:         role,
		OTPSecret:    key.Secret(),
		OTPEnabled:   false,
	}
	if err := h.repo.CreateUser(r.Context(), user); err != nil {
		h.render(w, r, "register", "Register", map[string]any{"Error": "Registration failed. Email may already exist."})
		return
	}

	session, _ := h.sessions.Get(r)
	session.Values["user_id"] = user.ID
	_ = session.Save(r, w)
	http.Redirect(w, r, "/setup-2fa", http.StatusSeeOther)
}

func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
	session, _ := h.sessions.Get(r)
	session.Options.MaxAge = -1
	_ = session.Save(r, w)
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

func (h *Handler) SetupOTPPage(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	if user == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	uri := h.auth.BuildOTPURI(user.Email, user.OTPSecret)
	png, _ := qrcode.Encode(uri, qrcode.Medium, 256)
	dataURI := "data:image/png;base64," + encodeBase64(png)

	h.render(w, r, "setup_otp", "Secure Your Account", map[string]any{
		"Secret": user.OTPSecret,
		"QRCode": dataURI,
	})
}

func (h *Handler) SetupOTP(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	if user == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	if !h.auth.VerifyOTP(user.OTPSecret, strings.TrimSpace(r.FormValue("code"))) {
		uri := h.auth.BuildOTPURI(user.Email, user.OTPSecret)
		png, _ := qrcode.Encode(uri, qrcode.Medium, 256)
		h.render(w, r, "setup_otp", "Secure Your Account", map[string]any{
			"Error":  "Invalid code. Check your authenticator app.",
			"Secret": user.OTPSecret,
			"QRCode": "data:image/png;base64," + encodeBase64(png),
		})
		return
	}

	if err := h.repo.EnableUserOTP(r.Context(), user.ID); err != nil {
		http.Error(w, "failed to enable otp", http.StatusInternalServerError)
		return
	}

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

func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	h.render(w, r, "dashboard", "Fleet Dashboard", nil, user)
}

func (h *Handler) DashboardNodes(w http.ResponseWriter, r *http.Request) {
	h.renderer.RenderPageTemplate(w, "dashboard", "dashboardNodes", views.ViewData{
		User:    localmiddleware.CurrentUser(r),
		Content: h.dashboardData(r.Context()),
	})
}

func (h *Handler) CreateNode(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseMultipartForm(10 << 20); err != nil {
		http.Error(w, "invalid form", http.StatusBadRequest)
		return
	}

	ip := strings.TrimSpace(r.FormValue("ip_address"))
	username := strings.TrimSpace(r.FormValue("ssh_username"))
	password := r.FormValue("ssh_password")
	name := strings.TrimSpace(r.FormValue("name"))
	if name == "" {
		name = ip
	}

	if ip == "" || username == "" {
		http.Error(w, "ip address and username are required", http.StatusBadRequest)
		return
	}

	keyName, err := saveKeyUpload(r, "ssh_key")
	if err != nil {
		http.Error(w, "failed to save uploaded key", http.StatusInternalServerError)
		return
	}
	if password == "" && keyName == "" {
		http.Error(w, "provide a password or upload a key", http.StatusBadRequest)
		return
	}

	notes := strings.TrimSpace(r.FormValue("notes"))
	tag := strings.TrimSpace(r.FormValue("tag"))
	var groupID *int64
	if raw := strings.TrimSpace(r.FormValue("group_id")); raw != "" {
		parsed, err := strconv.ParseInt(raw, 10, 64)
		if err == nil {
			groupID = &parsed
		}
	}
	if keyName != "" {
		if notes != "" {
			notes += "\n"
		}
		notes += "Uploaded key: " + keyName
	}

	node := &models.Node{
		OrganizationID:     h.org.ID,
		GroupID:            groupID,
		Tag:                tag,
		Name:               name,
		Distro:             "Linux",
		Hostname:           ip,
		IPAddress:          ip,
		SSHPort:            22,
		SSHUsername:        username,
		SSHPassword:        password,
		UpdatesScanEnabled: true,
		AutoUpdatesEnabled: r.FormValue("auto_updates_enabled") == "on",
		Notes:              notes,
	}

	if err := h.nodes.SaveNode(r.Context(), node); err != nil {
		http.Error(w, "failed to create node", http.StatusInternalServerError)
		return
	}
	if err := h.nodes.EnsureUptimeMonitorForNode(r.Context(), node); err != nil {
		http.Error(w, "failed to create node monitor", http.StatusInternalServerError)
		return
	}

	if node.SSHUsername != "" && node.SSHPassword != "" {
		_, _ = h.nodes.RefreshNodeInventory(r.Context(), node)
		_, _ = h.nodes.RefreshNodeStats(r.Context(), node)
	}
	_ = h.nodes.RunAllUptimeChecks(r.Context(), h.org.ID)

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

func (h *Handler) UpdateNode(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid form", http.StatusBadRequest)
		return
	}

	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	ip := strings.TrimSpace(r.FormValue("ip_address"))
	name := strings.TrimSpace(r.FormValue("name"))
	username := strings.TrimSpace(r.FormValue("ssh_username"))
	password := r.FormValue("ssh_password")

	if name == "" {
		name = ip
	}
	if ip == "" || username == "" {
		http.Error(w, "ip address and username are required", http.StatusBadRequest)
		return
	}

	var groupID *int64
	if raw := strings.TrimSpace(r.FormValue("group_id")); raw != "" {
		parsed, parseErr := strconv.ParseInt(raw, 10, 64)
		if parseErr == nil {
			groupID = &parsed
		}
	}

	oldIP := node.IPAddress
	oldUsername := node.SSHUsername
	oldPassword := node.SSHPassword
	node.GroupID = groupID
	node.Tag = strings.TrimSpace(r.FormValue("tag"))
	node.Name = name
	node.IPAddress = ip
	if node.Hostname == "" || node.Hostname == oldIP {
		node.Hostname = ip
	}
	node.SSHUsername = username
	if strings.TrimSpace(password) != "" {
		node.SSHPassword = password
	}
	node.Notes = strings.TrimSpace(r.FormValue("notes"))
	node.AutoUpdatesEnabled = r.FormValue("auto_updates_enabled") == "on"
	node.UpdatesScanEnabled = r.FormValue("updates_scan_enabled") == "on"

	if err := h.nodes.SaveNode(r.Context(), node); err != nil {
		http.Error(w, "failed to update node", http.StatusInternalServerError)
		return
	}
	if err := h.nodes.EnsureUptimeMonitorForNode(r.Context(), node); err != nil {
		http.Error(w, "failed to sync node monitor", http.StatusInternalServerError)
		return
	}

	if node.SSHUsername != "" && node.SSHPassword != "" {
		credentialsChanged := node.IPAddress != oldIP || oldUsername != username || (strings.TrimSpace(password) != "" && password != oldPassword)
		if credentialsChanged || strings.TrimSpace(node.PackageManager) == "" || strings.EqualFold(strings.TrimSpace(node.Distro), "linux") {
			_, _ = h.nodes.RefreshNodeInventory(r.Context(), node)
		}
		_, _ = h.nodes.RefreshNodeStats(r.Context(), node)
	}

	http.Redirect(w, r, "/nodes/"+strconv.FormatInt(nodeID, 10), http.StatusSeeOther)
}

func (h *Handler) NodeOverview(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	if node.SSHUsername != "" && node.SSHPassword != "" && (strings.TrimSpace(node.MACAddress) == "" || strings.TrimSpace(node.PackageManager) == "" || strings.TrimSpace(node.Distro) == "" || strings.EqualFold(strings.TrimSpace(node.Distro), "linux")) {
		_, _ = h.nodes.RefreshNodeInventory(r.Context(), node)
	}
	if node.SSHUsername != "" && node.SSHPassword != "" && h.nodes.StatsStale(node, time.Second) {
		_, _ = h.nodes.RefreshNodeStats(r.Context(), node)
	}

	runs := h.listRuns(r.Context(), nodeID)
	selectedGroupID := int64(0)
	if node.GroupID != nil {
		selectedGroupID = *node.GroupID
	}
	h.render(w, r, "node", node.Name, nodePageData{
		Node:            node,
		CommandRuns:     runs,
		SelectedGroupID: selectedGroupID,
	}, user)
}

func (h *Handler) NodeStatsAPI(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	if node.SSHUsername != "" && node.SSHPassword != "" && h.nodes.StatsStale(node, time.Second) {
		_, _ = h.nodes.RefreshNodeStats(r.Context(), node)
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"cpu_usage":      node.CPUUsage,
		"ram_usage":      node.RAMUsage,
		"disk_usage":     node.DiskUsage,
		"uptime_seconds": node.UptimeSeconds,
		"last_seen_at":   node.LastSeenAt,
	})
}

func (h *Handler) NodeConsole(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	h.render(w, r, "console", "SSH Console", map[string]any{"Node": node}, user)
}

func (h *Handler) NodeConsoleWebSocket(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	conn, err := h.upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}
	defer conn.Close()

	config := &ssh.ClientConfig{
		User:            node.SSHUsername,
		Auth:            []ssh.AuthMethod{ssh.Password(node.SSHPassword)},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
		Timeout:         15 * time.Second,
	}

	client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", node.IPAddress, node.SSHPort), config)
	if err != nil {
		_ = conn.WriteMessage(websocket.TextMessage, []byte("SSH connection failed: "+err.Error()))
		return
	}
	defer client.Close()

	session, err := client.NewSession()
	if err != nil {
		_ = conn.WriteMessage(websocket.TextMessage, []byte("SSH session failed: "+err.Error()))
		return
	}
	defer session.Close()

	stdin, _ := session.StdinPipe()
	stdout, _ := session.StdoutPipe()
	stderr, _ := session.StderrPipe()

	_ = session.RequestPty("xterm", 24, 80, ssh.TerminalModes{})
	if err := session.Shell(); err != nil {
		_ = conn.WriteMessage(websocket.TextMessage, []byte("SSH shell failed: "+err.Error()))
		return
	}

	copyOutput := func(reader httpReader) {
		buffer := make([]byte, 2048)
		for {
			n, readErr := reader.Read(buffer)
			if n > 0 {
				_ = conn.WriteMessage(websocket.TextMessage, buffer[:n])
			}
			if readErr != nil {
				return
			}
		}
	}

	go copyOutput(stdout)
	go copyOutput(stderr)

	for {
		_, message, readErr := conn.ReadMessage()
		if readErr != nil {
			return
		}
		if _, writeErr := stdin.Write(message); writeErr != nil {
			return
		}
	}
}

func (h *Handler) NodeAction(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	user := localmiddleware.CurrentUser(r)
	var userID *int64
	if user != nil {
		userID = &user.ID
	}

	action := chi.URLParam(r, "action")
	output, actionErr := h.nodes.RunAction(r.Context(), node, action, userID)
	if actionErr != nil {
		http.Error(w, output+"\n"+actionErr.Error(), http.StatusBadGateway)
		return
	}

	http.Redirect(w, r, "/nodes/"+strconv.FormatInt(nodeID, 10), http.StatusSeeOther)
}

func (h *Handler) DeleteNode(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	if err := h.nodes.DeleteNode(r.Context(), h.org.ID, nodeID); err != nil {
		if err == sql.ErrNoRows {
			http.NotFound(w, r)
			return
		}
		http.Error(w, "failed to delete node", http.StatusInternalServerError)
		return
	}

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

func (h *Handler) NodeQuickCommand(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	command := strings.TrimSpace(r.FormValue("command"))
	label := strings.TrimSpace(r.FormValue("label"))
	if command == "" {
		http.Error(w, "command required", http.StatusBadRequest)
		return
	}
	if label == "" {
		label = "custom-command"
	}

	user := localmiddleware.CurrentUser(r)
	var userID *int64
	if user != nil {
		userID = &user.ID
	}

	output, runErr := h.nodes.RunAdHocCommand(r.Context(), node, label, command, userID)
	if runErr != nil {
		http.Error(w, output, http.StatusBadGateway)
		return
	}

	http.Redirect(w, r, "/nodes/"+strconv.FormatInt(nodeID, 10), http.StatusSeeOther)
}

func (h *Handler) GroupsPage(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	nodes, _ := h.nodes.ListNodes(r.Context(), h.org.ID)
	groups, _ := h.repo.ListGroups(r.Context(), h.org.ID)
	groupCounts := make(map[int64]int, len(groups))
	for _, node := range nodes {
		if node.GroupID == nil {
			continue
		}
		groupCounts[*node.GroupID]++
	}

	summaries := make([]groupSummary, 0, len(groups))
	for _, group := range groups {
		summaries = append(summaries, groupSummary{
			Group:   group,
			VMCount: groupCounts[group.ID],
		})
	}

	h.render(w, r, "groups", "VM Groups", groupsPageData{Groups: summaries}, user)
}

func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	name := strings.TrimSpace(r.FormValue("name"))
	if name == "" {
		http.Error(w, "group name required", http.StatusBadRequest)
		return
	}

	group := &models.VMGroup{
		OrganizationID: h.org.ID,
		Name:           name,
		Description:    strings.TrimSpace(r.FormValue("description")),
		ColorToken:     "primary",
		Icon:           defaultIfEmpty(strings.TrimSpace(r.FormValue("icon")), "ti ti-stack-2"),
	}
	if err := h.repo.CreateGroup(r.Context(), group); err != nil {
		http.Error(w, "failed to create group", http.StatusInternalServerError)
		return
	}

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

func (h *Handler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
	groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	name := strings.TrimSpace(r.FormValue("name"))
	if name == "" {
		http.Error(w, "group name required", http.StatusBadRequest)
		return
	}

	group := &models.VMGroup{
		ID:             groupID,
		OrganizationID: h.org.ID,
		Name:           name,
		Description:    strings.TrimSpace(r.FormValue("description")),
		ColorToken:     "primary",
		Icon:           defaultIfEmpty(strings.TrimSpace(r.FormValue("icon")), "ti ti-stack-2"),
	}

	if err := h.repo.UpdateGroup(r.Context(), group); err != nil {
		http.Error(w, "failed to update group", http.StatusInternalServerError)
		return
	}

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

func (h *Handler) AutomationsPage(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	jobs, _ := h.nodes.ListAutomations(r.Context(), h.org.ID)
	nodes, _ := h.nodes.ListNodes(r.Context(), h.org.ID)
	groups, _ := h.repo.ListGroups(r.Context(), h.org.ID)
	tags, _ := h.repo.ListTags(r.Context(), h.org.ID)
	runs, _ := h.nodes.ListJobRuns(r.Context(), h.org.ID)
	h.render(w, r, "automations", "Jobs", jobsPageData{
		Jobs:   jobs,
		Nodes:  nodes,
		Groups: groups,
		Tags:   tags,
		Runs:   runs,
	}, user)
}

func (h *Handler) CreateAutomation(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	var nodeID *int64
	if raw := r.FormValue("node_id"); raw != "" {
		parsed, _ := strconv.ParseInt(raw, 10, 64)
		nodeID = &parsed
	}

	var groupID *int64
	if raw := r.FormValue("group_id"); raw != "" {
		parsed, _ := strconv.ParseInt(raw, 10, 64)
		groupID = &parsed
	}

	tag := strings.TrimSpace(r.FormValue("tag"))
	targetCount := 0
	if nodeID != nil {
		targetCount++
	}
	if groupID != nil {
		targetCount++
	}
	if tag != "" {
		targetCount++
	}
	if targetCount != 1 {
		http.Error(w, "select exactly one target: node, group, or tag", http.StatusBadRequest)
		return
	}

	commandSteps := r.Form["command_steps[]"]
	var commands []string
	for _, step := range commandSteps {
		step = strings.TrimSpace(step)
		if step != "" {
			commands = append(commands, step)
		}
	}
	if len(commands) == 0 {
		http.Error(w, "at least one command is required", http.StatusBadRequest)
		return
	}

	triggerType := r.FormValue("trigger_type")
	schedule := buildSchedule(
		triggerType,
		r.FormValue("schedule_kind"),
		r.FormValue("schedule_hour"),
		r.FormValue("schedule_minute"),
		r.FormValue("schedule_weekday"),
		r.FormValue("interval_value"),
		r.FormValue("interval_unit"),
	)

	job := &models.AutomationJob{
		OrganizationID: h.org.ID,
		NodeID:         nodeID,
		GroupID:        groupID,
		Tag:            tag,
		Name:           r.FormValue("name"),
		TriggerType:    triggerType,
		Schedule:       schedule,
		Command:        strings.Join(commands, "\n"),
		Enabled:        true,
	}
	if err := h.nodes.CreateAutomation(r.Context(), job); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

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

func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	page := 1
	if rawPage := strings.TrimSpace(r.URL.Query().Get("page")); rawPage != "" {
		if parsedPage, err := strconv.Atoi(rawPage); err == nil && parsedPage > 0 {
			page = parsedPage
		}
	}

	logs := h.settingsLogs(r.Context())
	const pageSize = 25
	totalPages := len(logs) / pageSize
	if len(logs)%pageSize != 0 {
		totalPages++
	}
	if totalPages == 0 {
		totalPages = 1
	}
	if page > totalPages {
		page = totalPages
	}

	start := (page - 1) * pageSize
	end := start + pageSize
	if start > len(logs) {
		start = len(logs)
	}
	if end > len(logs) {
		end = len(logs)
	}

	h.render(w, r, "settings", "Logs", settingsData{
		Logs:        logs[start:end],
		Page:        page,
		TotalPages:  totalPages,
		HasPrev:     page > 1,
		HasNext:     page < totalPages,
		PrevPage:    page - 1,
		NextPage:    page + 1,
		CurrentPath: r.URL.Path,
	}, user)
}

func (h *Handler) UserSettingsPage(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	if user == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	data := userSettingsData{
		CurrentMode: effectiveThemeMode(user, h.currentOrganization(r.Context())),
		OTPEnabled:  user.OTPEnabled,
		Message:     strings.TrimSpace(r.URL.Query().Get("message")),
		Error:       strings.TrimSpace(r.URL.Query().Get("error")),
	}

	if !user.OTPEnabled {
		uri := h.auth.BuildOTPURI(user.Email, user.OTPSecret)
		png, _ := qrcode.Encode(uri, qrcode.Medium, 256)
		data.OTPSecret = user.OTPSecret
		data.OTPQRCode = "data:image/png;base64," + encodeBase64(png)
	}

	h.render(w, r, "user_settings", "User Settings", data, user)
}

func (h *Handler) UptimePage(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	h.render(w, r, "uptime", "Uptime", h.uptimePageData(r.Context()), user)
}

func (h *Handler) UpdatesPage(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	h.render(w, r, "updates", "Updates", h.updatesPageData(r.Context()), user)
}

func (h *Handler) RunUptimeChecks(w http.ResponseWriter, r *http.Request) {
	if err := h.nodes.EnsureUptimeMonitors(r.Context(), h.org.ID); err != nil {
		http.Error(w, "failed to sync monitors", http.StatusInternalServerError)
		return
	}
	if err := h.nodes.RunAllUptimeChecks(r.Context(), h.org.ID); err != nil {
		http.Error(w, "failed to run uptime checks", http.StatusBadGateway)
		return
	}
	http.Redirect(w, r, "/uptime", http.StatusSeeOther)
}

func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	theme := strings.TrimSpace(r.FormValue("theme"))
	mode := strings.TrimSpace(r.FormValue("mode"))
	if mode == "" {
		mode = theme
	}
	if !isAllowedTheme(theme) || !isAllowedMode(mode) {
		http.Error(w, "invalid theme selection", http.StatusBadRequest)
		return
	}

	if err := h.repo.UpdateOrganizationTheme(r.Context(), theme, mode); err != nil {
		http.Error(w, "failed to update theme", http.StatusInternalServerError)
		return
	}

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

func (h *Handler) ScanAllUpdates(w http.ResponseWriter, r *http.Request) {
	_ = h.nodes.ScanAllNodeUpdates(r.Context(), h.org.ID)
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) ApplyAllUpdates(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	var userID *int64
	if user != nil {
		userID = &user.ID
	}
	_ = h.nodes.ApplyAllNodeUpdates(r.Context(), h.org.ID, userID)
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) UpdateGlobalUpdateWindow(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	windowStart := normalizeClockValue(r.FormValue("auto_update_window_start"))
	windowEnd := normalizeClockValue(r.FormValue("auto_update_window_end"))
	updateDays := joinWeekdaySelection(r.Form["auto_update_days"])
	if err := h.repo.UpdateOrganizationAutoUpdateWindow(r.Context(), windowStart, windowEnd, updateDays); err != nil {
		http.Error(w, "failed to update global schedule", http.StatusInternalServerError)
		return
	}

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

func (h *Handler) UpdateNodePolicy(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	scanEnabled := r.FormValue("updates_scan_enabled") == "on"
	autoUpdate := r.FormValue("auto_updates_enabled") == "on"
	windowStart := normalizeClockValue(r.FormValue("auto_update_window_start"))
	windowEnd := normalizeClockValue(r.FormValue("auto_update_window_end"))
	updateDays := joinWeekdaySelection(r.Form["auto_update_days"])
	if err := h.nodes.UpdateNodeUpdatePolicy(r.Context(), h.org.ID, nodeID, scanEnabled, autoUpdate, windowStart, windowEnd, updateDays); err != nil {
		http.Error(w, "failed to update policy", http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) ScanNodeUpdates(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	_, _, _ = h.nodes.RefreshNodeUpdates(r.Context(), node)
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) ApplyNodeUpdates(w http.ResponseWriter, r *http.Request) {
	nodeID, err := strconv.ParseInt(chi.URLParam(r, "nodeID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	node, err := h.nodes.GetNode(r.Context(), h.org.ID, nodeID)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	user := localmiddleware.CurrentUser(r)
	var userID *int64
	if user != nil {
		userID = &user.ID
	}
	_, _ = h.nodes.ApplyNodeUpdates(r.Context(), node, userID)
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) UpdateGroupPolicy(w http.ResponseWriter, r *http.Request) {
	groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	scanEnabled := r.FormValue("updates_scan_enabled") == "on"
	autoUpdate := r.FormValue("auto_updates_enabled") == "on"
	windowStart := normalizeClockValue(r.FormValue("auto_update_window_start"))
	windowEnd := normalizeClockValue(r.FormValue("auto_update_window_end"))
	updateDays := joinWeekdaySelection(r.Form["auto_update_days"])
	if err := h.nodes.UpdateGroupUpdatePolicy(r.Context(), h.org.ID, groupID, scanEnabled, autoUpdate, windowStart, windowEnd, updateDays); err != nil {
		http.Error(w, "failed to update policy", http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) ScanGroupUpdates(w http.ResponseWriter, r *http.Request) {
	groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	_ = h.nodes.ScanGroupNodeUpdates(r.Context(), h.org.ID, groupID)
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) ApplyGroupUpdates(w http.ResponseWriter, r *http.Request) {
	groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	user := localmiddleware.CurrentUser(r)
	var userID *int64
	if user != nil {
		userID = &user.ID
	}
	_ = h.nodes.ApplyGroupNodeUpdates(r.Context(), h.org.ID, groupID, userID)
	http.Redirect(w, r, "/updates", http.StatusSeeOther)
}

func (h *Handler) UpdateUserTheme(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	if user == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther)
		return
	}

	mode := strings.TrimSpace(r.FormValue("mode"))
	if !isAllowedMode(mode) {
		http.Redirect(w, r, "/settings/user?error=Invalid+theme+mode", http.StatusSeeOther)
		return
	}

	if err := h.repo.UpdateUserThemeMode(r.Context(), user.ID, mode); err != nil {
		http.Redirect(w, r, "/settings/user?error=Failed+to+save+theme", http.StatusSeeOther)
		return
	}

	http.Redirect(w, r, "/settings/user?message=Theme+updated", http.StatusSeeOther)
}

func (h *Handler) UpdateUserPassword(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	if user == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther)
		return
	}

	currentPassword := r.FormValue("current_password")
	newPassword := r.FormValue("new_password")
	confirmPassword := r.FormValue("confirm_password")

	if h.auth.ComparePassword(user.PasswordHash, currentPassword) != nil {
		http.Redirect(w, r, "/settings/user?error=Current+password+is+incorrect", http.StatusSeeOther)
		return
	}
	if len(newPassword) < 8 {
		http.Redirect(w, r, "/settings/user?error=New+password+must+be+at+least+8+characters", http.StatusSeeOther)
		return
	}
	if newPassword != confirmPassword {
		http.Redirect(w, r, "/settings/user?error=Passwords+do+not+match", http.StatusSeeOther)
		return
	}

	hash, err := h.auth.HashPassword(newPassword)
	if err != nil {
		http.Redirect(w, r, "/settings/user?error=Failed+to+update+password", http.StatusSeeOther)
		return
	}
	if err := h.repo.UpdateUserPassword(r.Context(), user.ID, hash); err != nil {
		http.Redirect(w, r, "/settings/user?error=Failed+to+save+password", http.StatusSeeOther)
		return
	}

	http.Redirect(w, r, "/settings/user?message=Password+updated", http.StatusSeeOther)
}

func (h *Handler) UpdateUserOTP(w http.ResponseWriter, r *http.Request) {
	user := localmiddleware.CurrentUser(r)
	if user == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	action := chi.URLParam(r, "action")
	switch action {
	case "disable":
		if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, false); err != nil {
			http.Redirect(w, r, "/settings/user?error=Failed+to+disable+2FA", http.StatusSeeOther)
			return
		}
		http.Redirect(w, r, "/settings/user?message=2FA+disabled", http.StatusSeeOther)
		return
	case "reset":
		key, err := h.auth.NewOTP(user.Email)
		if err != nil {
			http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther)
			return
		}
		if err := h.repo.UpdateUserOTP(r.Context(), user.ID, key.Secret(), false); err != nil {
			http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther)
			return
		}
		http.Redirect(w, r, "/settings/user?message=Scan+the+new+QR+code+to+enable+2FA", http.StatusSeeOther)
		return
	case "enable":
		if err := r.ParseForm(); err != nil {
			http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther)
			return
		}
		if !h.auth.VerifyOTP(user.OTPSecret, strings.TrimSpace(r.FormValue("code"))) {
			http.Redirect(w, r, "/settings/user?error=Invalid+OTP+code", http.StatusSeeOther)
			return
		}
		if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, true); err != nil {
			http.Redirect(w, r, "/settings/user?error=Failed+to+enable+2FA", http.StatusSeeOther)
			return
		}
		http.Redirect(w, r, "/settings/user?message=2FA+enabled", http.StatusSeeOther)
		return
	default:
		http.NotFound(w, r)
	}
}

func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title string, content any, user ...*models.User) {
	var currentUser *models.User
	if len(user) > 0 {
		currentUser = user[0]
	}
	if currentUser == nil {
		currentUser = localmiddleware.CurrentUser(r)
	}

	org := h.currentOrganization(r.Context())
	groups, _ := h.repo.ListGroups(r.Context(), org.ID)
	tags, _ := h.repo.ListTags(r.Context(), org.ID)
	mode := effectiveThemeMode(currentUser, org)
	theme := normalizeTheme(org.Theme, mode)

	h.renderer.Render(w, page, views.ViewData{
		Title:           title,
		Shell:           shellForPage(page),
		ThemeClass:      theme,
		ThemeMode:       mode,
		CurrentPath:     r.URL.Path,
		User:            currentUser,
		Organization:    org,
		AvailableGroups: groups,
		AvailableTags:   tags,
		Content:         content,
	})
}

func (h *Handler) dashboardData(ctx context.Context) dashboardData {
	nodes, _ := h.nodes.ListNodes(ctx, h.org.ID)
	vmGroups, _ := h.repo.ListGroups(ctx, h.org.ID)
	groupIcons := make(map[string]string, len(vmGroups))
	for _, group := range vmGroups {
		groupIcons[group.Name] = group.Icon
	}
	grouped := make(map[string][]models.Node)
	var ungrouped []models.Node
	for _, node := range nodes {
		if strings.TrimSpace(node.GroupName) == "" {
			ungrouped = append(ungrouped, node)
			continue
		}
		grouped[node.GroupName] = append(grouped[node.GroupName], node)
	}

	groupNames := make([]string, 0, len(grouped))
	for name := range grouped {
		groupNames = append(groupNames, name)
	}
	sort.Strings(groupNames)

	groups := make([]dashboardNodeGroup, 0, len(groupNames))
	for _, name := range groupNames {
		groups = append(groups, dashboardNodeGroup{
			Name:  name,
			Icon:  defaultIfEmpty(groupIcons[name], "ti ti-stack-2"),
			Nodes: grouped[name],
		})
	}

	return dashboardData{
		Groups:    groups,
		Ungrouped: ungrouped,
	}
}

func (h *Handler) updatesPageData(ctx context.Context) updatesPageData {
	nodes, _ := h.nodes.ListNodes(ctx, h.org.ID)
	grouped := make(map[string][]models.Node)
	groupIDs := make(map[string]int64)
	var ungrouped []models.Node
	var totalUpdates int64
	var nodesWithUpdates int
	var scannedNodes int
	var autoNodes int

	for _, node := range nodes {
		totalUpdates += node.UpdatesAvailable
		if node.UpdatesAvailable > 0 {
			nodesWithUpdates++
		}
		if node.UpdatesLastChecked != nil {
			scannedNodes++
		}
		if node.AutoUpdatesEnabled {
			autoNodes++
		}
		if strings.TrimSpace(node.GroupName) == "" || node.GroupID == nil {
			ungrouped = append(ungrouped, node)
			continue
		}
		grouped[node.GroupName] = append(grouped[node.GroupName], node)
		groupIDs[node.GroupName] = *node.GroupID
	}

	var groupNames []string
	for name := range grouped {
		groupNames = append(groupNames, name)
	}
	sort.Strings(groupNames)

	var groups []updatesGroup
	for _, name := range groupNames {
		groupNodes := grouped[name]
		group := updatesGroup{
			ID:          groupIDs[name],
			Name:        name,
			Nodes:       groupNodes,
			ScanEnabled: true,
			AutoUpdate:  true,
		}
		if len(groupNodes) > 0 {
			group.WindowStart = groupNodes[0].AutoUpdateWindowStart
			group.WindowEnd = groupNodes[0].AutoUpdateWindowEnd
			group.UpdateDays = groupNodes[0].AutoUpdateDays
		}
		for _, node := range groupNodes {
			group.UpdatesAvailable += node.UpdatesAvailable
			if node.UpdatesAvailable > 0 {
				group.NodesWithUpdates++
			}
			group.ScanEnabled = group.ScanEnabled && node.UpdatesScanEnabled
			group.AutoUpdate = group.AutoUpdate && node.AutoUpdatesEnabled
			if group.WindowStart != node.AutoUpdateWindowStart {
				group.WindowStart = ""
			}
			if group.WindowEnd != node.AutoUpdateWindowEnd {
				group.WindowEnd = ""
			}
			if group.UpdateDays != node.AutoUpdateDays {
				group.UpdateDays = ""
			}
		}
		groups = append(groups, group)
	}

	return updatesPageData{
		TotalUpdates:      totalUpdates,
		NodesWithUpdates:  nodesWithUpdates,
		ScannedNodes:      scannedNodes,
		AutoUpdateNodes:   autoNodes,
		GlobalWindowStart: h.org.AutoUpdateWindowStart,
		GlobalWindowEnd:   h.org.AutoUpdateWindowEnd,
		GlobalUpdateDays:  h.org.AutoUpdateDays,
		Groups:            groups,
		Ungrouped:         ungrouped,
	}
}

func (h *Handler) uptimePageData(ctx context.Context) uptimePageData {
	_ = h.nodes.EnsureUptimeMonitors(ctx, h.org.ID)

	monitors, _ := h.nodes.ListUptimeMonitors(ctx, h.org.ID)
	checksByMonitor, _ := h.nodes.ListRecentUptimeChecks(ctx, h.org.ID, 24)
	incidents, _ := h.nodes.ListRecentUptimeIncidents(ctx, h.org.ID, 20)

	summary := uptimeSummary{
		TotalMonitors: len(monitors),
	}
	var latencyTotal int64
	var latencyCount int64
	cards := make([]uptimeMonitorCard, 0, len(monitors))
	for _, monitor := range monitors {
		if monitor.LastStatus == "down" {
			summary.DownMonitors++
		} else if monitor.LastStatus == "up" {
			summary.UpMonitors++
		}
		if monitor.LastStatus == "up" && monitor.LastLatencyMS > 0 {
			latencyTotal += monitor.LastLatencyMS
			latencyCount++
		}

		recentChecks := checksByMonitor[monitor.ID]
		card := uptimeMonitorCard{
			Monitor:           monitor,
			Availability:      availabilityForChecks(recentChecks),
			AvailabilityText:  fmt.Sprintf("%.2f%%", availabilityForChecks(recentChecks)),
			LastCheckedText:   relativeTime(monitor.LastCheckedAt),
			StateDurationText: monitorStateDuration(monitor),
			IntervalText:      humanizeInterval(monitor.IntervalSeconds),
			RecentChecks:      reverseChecks(recentChecks),
		}
		cards = append(cards, card)
	}
	if latencyCount > 0 {
		summary.AvgLatencyMS = latencyTotal / latencyCount
	}

	now := time.Now()
	periods := []uptimePeriodRow{
		h.periodRow(ctx, "Today", ptrTime(now.Add(-24*time.Hour))),
		h.periodRow(ctx, "Last 7 days", ptrTime(now.Add(-7*24*time.Hour))),
		h.periodRow(ctx, "Last 30 days", ptrTime(now.Add(-30*24*time.Hour))),
		h.periodRow(ctx, "Last 365 days", ptrTime(now.Add(-365*24*time.Hour))),
		h.periodRow(ctx, "All time", nil),
	}

	return uptimePageData{
		Summary:   summary,
		Chart:     buildUptimeChart(checksByMonitor),
		Periods:   periods,
		Monitors:  cards,
		Incidents: incidents,
	}
}

func (h *Handler) periodRow(ctx context.Context, label string, since *time.Time) uptimePeriodRow {
	summary, _ := h.nodes.UptimePeriodSummary(ctx, h.org.ID, since)
	return uptimePeriodRow{
		Label:            label,
		AvailabilityText: availabilityText(summary),
		DowntimeText:     humanizeSeconds(summary.DowntimeSeconds),
		Incidents:        summary.IncidentCount,
		LongestText:      humanizeSeconds(summary.LongestIncidentSeconds),
		AverageText:      humanizeSeconds(summary.AvgIncidentSeconds),
	}
}

func (h *Handler) currentOrganization(ctx context.Context) models.Organization {
	org, err := h.repo.GetOrganization(ctx)
	if err != nil {
		return h.org
	}
	return org
}

func availabilityForChecks(checks []models.UptimeCheck) float64 {
	if len(checks) == 0 {
		return 0
	}
	var up int
	for _, check := range checks {
		if check.Status == "up" {
			up++
		}
	}
	return float64(up) * 100 / float64(len(checks))
}

func availabilityText(summary models.UptimePeriodSummary) string {
	if summary.TotalChecks == 0 {
		return "0.00%"
	}
	return fmt.Sprintf("%.2f%%", float64(summary.UpChecks)*100/float64(summary.TotalChecks))
}

func relativeTime(value *time.Time) string {
	if value == nil {
		return "Never"
	}
	diff := time.Since(*value).Round(time.Second)
	if diff < time.Minute {
		seconds := int(diff.Seconds())
		if seconds < 1 {
			seconds = 1
		}
		return fmt.Sprintf("%ds ago", seconds)
	}
	if diff < time.Hour {
		return fmt.Sprintf("%dm ago", int(diff.Minutes()))
	}
	if diff < 24*time.Hour {
		return fmt.Sprintf("%dh ago", int(diff.Hours()))
	}
	return value.Format("2006-01-02 15:04")
}

func humanizeInterval(seconds int64) string {
	if seconds <= 0 {
		return "-"
	}
	if seconds%3600 == 0 {
		hours := seconds / 3600
		if hours == 1 {
			return "Every hour"
		}
		return fmt.Sprintf("Every %d hours", hours)
	}
	if seconds%60 == 0 {
		minutes := seconds / 60
		if minutes == 1 {
			return "Every minute"
		}
		return fmt.Sprintf("Every %d minutes", minutes)
	}
	return fmt.Sprintf("Every %d seconds", seconds)
}

func monitorStateDuration(monitor models.UptimeMonitor) string {
	if monitor.LastStatus == "up" && monitor.UpSinceAt != nil {
		return "Up for " + humanizeSeconds(int64(time.Since(*monitor.UpSinceAt).Seconds()))
	}
	if monitor.LastStatus == "down" && monitor.CurrentOutageStartedAt != nil {
		return "Down for " + humanizeSeconds(int64(time.Since(*monitor.CurrentOutageStartedAt).Seconds()))
	}
	return "Awaiting checks"
}

func humanizeSeconds(seconds int64) string {
	if seconds <= 0 {
		return "-"
	}
	duration := time.Duration(seconds) * time.Second
	days := int(duration / (24 * time.Hour))
	duration -= time.Duration(days) * 24 * time.Hour
	hours := int(duration / time.Hour)
	duration -= time.Duration(hours) * time.Hour
	minutes := int(duration / time.Minute)
	if days > 0 {
		return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
	}
	if hours > 0 {
		return fmt.Sprintf("%dh %dm", hours, minutes)
	}
	return fmt.Sprintf("%dm", minutes)
}

func ptrTime(value time.Time) *time.Time {
	return &value
}

func reverseChecks(checks []models.UptimeCheck) []models.UptimeCheck {
	reversed := make([]models.UptimeCheck, len(checks))
	for i := range checks {
		reversed[len(checks)-1-i] = checks[i]
	}
	return reversed
}

func buildUptimeChart(checksByMonitor map[int64][]models.UptimeCheck) uptimeChartData {
	type bucket struct {
		up    int
		down  int
		total int
	}

	buckets := map[time.Time]*bucket{}
	for _, checks := range checksByMonitor {
		for _, check := range checks {
			minute := check.CheckedAt.Truncate(time.Minute)
			if buckets[minute] == nil {
				buckets[minute] = &bucket{}
			}
			buckets[minute].total++
			if check.Status == "up" {
				buckets[minute].up++
			} else {
				buckets[minute].down++
			}
		}
	}

	if len(buckets) == 0 {
		return uptimeChartData{}
	}

	timestamps := make([]time.Time, 0, len(buckets))
	for ts := range buckets {
		timestamps = append(timestamps, ts)
	}
	sort.Slice(timestamps, func(i, j int) bool {
		return timestamps[i].Before(timestamps[j])
	})
	if len(timestamps) > 30 {
		timestamps = timestamps[len(timestamps)-30:]
	}

	points := make([]uptimeChartPoint, 0, len(timestamps))
	for _, ts := range timestamps {
		current := buckets[ts]
		availability := 0.0
		if current.total > 0 {
			availability = float64(current.up) * 100 / float64(current.total)
		}
		height := int(availability)
		if current.total > 0 && height < 8 {
			height = 8
		}
		points = append(points, uptimeChartPoint{
			Label:              ts.Format("15:04"),
			Availability:       availability,
			AvailabilityText:   fmt.Sprintf("%.0f%%", availability),
			UpCount:            current.up,
			DownCount:          current.down,
			TotalCount:         current.total,
			AvailabilityHeight: height,
		})
	}

	labels := make([]string, 0, len(points))
	values := make([]float64, 0, len(points))
	pointColors := make([]string, 0, len(points))
	for i := range points {
		points[i].ShowLabel = i == 0 || i == len(points)-1 || i == len(points)/2 || i%6 == 0
		labels = append(labels, points[i].Label)
		values = append(values, points[i].Availability)
		if points[i].DownCount > 0 {
			pointColors = append(pointColors, "#ef4444")
		} else {
			pointColors = append(pointColors, "rgb(var(--color-primary-500))")
		}
	}

	labelsJSON, _ := json.Marshal(labels)
	valuesJSON, _ := json.Marshal(values)
	pointColorsJSON, _ := json.Marshal(pointColors)

	return uptimeChartData{
		Points:          points,
		LabelsJSON:      template.JS(labelsJSON),
		ValuesJSON:      template.JS(valuesJSON),
		PointColorsJSON: template.JS(pointColorsJSON),
	}
}

func shellForPage(page string) string {
	switch page {
	case "login", "login_otp", "register":
		return "auth"
	case "console":
		return "console"
	default:
		return "app"
	}
}

func (h *Handler) listRuns(ctx context.Context, nodeID int64) []models.CommandRun {
	rows, err := h.nodesRunQuery(ctx, nodeID)
	if err != nil {
		return nil
	}
	defer rows.Close()

	var runs []models.CommandRun
	for rows.Next() {
		var run models.CommandRun
		if err := rows.Scan(&run.ID, &run.JobID, &run.NodeID, &run.Action, &run.CommandText, &run.Status, &run.Output, &run.TriggeredBy, &run.StartedAt, &run.FinishedAt); err == nil {
			runs = append(runs, run)
		}
	}
	return runs
}

func (h *Handler) nodesRunQuery(ctx context.Context, nodeID int64) (*sql.Rows, error) {
	return h.nodesDB().QueryContext(ctx, `
		SELECT id, job_id, node_id, action, command_text, status, output, triggered_by, started_at, finished_at
		FROM command_runs
		WHERE node_id = ?
		ORDER BY started_at DESC
		LIMIT 12
	`, nodeID)
}

func (h *Handler) settingsLogs(ctx context.Context) []settingsCommandLog {
	runs, err := h.nodes.ListCommandHistory(ctx, h.org.ID)
	if err != nil {
		return nil
	}
	if len(runs) == 0 {
		return nil
	}

	var logs []settingsCommandLog
	for _, run := range runs {
		target := run.NodeName
		if strings.TrimSpace(run.GroupName) != "" {
			target = run.GroupName + " / " + target
		}
		commandText := strings.TrimSpace(run.CommandText)
		if commandText == "" {
			commandText = strings.TrimSpace(run.Action)
		}

		if len(logs) > 0 {
			last := &logs[len(logs)-1]
			if last.Target == target && last.Status == run.Status && last.StartedAt.Equal(run.StartedAt) {
				if commandText != "" && !strings.Contains(last.CommandText, commandText) {
					if last.CommandText != "" {
						last.CommandText += "\n"
					}
					last.CommandText += commandText
				}
				output := strings.TrimSpace(run.Output)
				if output != "" && !strings.Contains(last.Output, output) {
					if last.Output != "" {
						last.Output += "\n\n"
					}
					last.Output += output
				}
				continue
			}
		}

		logs = append(logs, settingsCommandLog{
			Target:      target,
			StartedAt:   run.StartedAt,
			Status:      run.Status,
			CommandText: commandText,
			Output:      strings.TrimSpace(run.Output),
			Duration:    run.DurationText,
		})
	}

	return logs
}

func (h *Handler) nodesDB() *sql.DB {
	return h.repo.DB()
}

type httpReader interface {
	Read(p []byte) (n int, err error)
}

func encodeBase64(raw []byte) string {
	return base64.StdEncoding.EncodeToString(raw)
}

func buildSchedule(triggerType, kind, hour, minute, weekday, intervalValue, intervalUnit string) string {
	if triggerType == "triggered" {
		return ""
	}

	hour = defaultIfEmpty(hour, "0")
	minute = defaultIfEmpty(minute, "0")

	switch kind {
	case "hourly":
		return fmt.Sprintf("%s * * * *", minute)
	case "weekly":
		weekday = defaultIfEmpty(weekday, "1")
		return fmt.Sprintf("%s %s * * %s", minute, hour, weekday)
	case "interval":
		if intervalUnit == "hours" {
			value := defaultIfEmpty(intervalValue, "1")
			return fmt.Sprintf("%s */%s * * *", minute, value)
		}
		value := defaultIfEmpty(intervalValue, "30")
		return fmt.Sprintf("*/%s * * * *", value)
	default:
		return fmt.Sprintf("%s %s * * *", minute, hour)
	}
}

func normalizeClockValue(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return ""
	}
	var hour int
	var minute int
	if _, err := fmt.Sscanf(value, "%d:%d", &hour, &minute); err != nil {
		return ""
	}
	if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
		return ""
	}
	return fmt.Sprintf("%02d:%02d", hour, minute)
}

func joinWeekdaySelection(values []string) string {
	allowed := []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
	present := make(map[string]struct{}, len(values))
	for _, value := range values {
		trimmed := strings.ToLower(strings.TrimSpace(value))
		if trimmed != "" {
			present[trimmed] = struct{}{}
		}
	}
	var selected []string
	for _, day := range allowed {
		if _, ok := present[day]; ok {
			selected = append(selected, day)
		}
	}
	return strings.Join(selected, ",")
}

func defaultIfEmpty(value, fallback string) string {
	if strings.TrimSpace(value) == "" {
		return fallback
	}
	return value
}

func isAllowedTheme(value string) bool {
	switch value {
	case "dark", "light":
		return true
	default:
		return false
	}
}

func isAllowedMode(value string) bool {
	return value == "dark" || value == "light"
}

func normalizeTheme(theme, mode string) string {
	if isAllowedTheme(theme) {
		return theme
	}
	return normalizeMode(mode)
}

func normalizeMode(mode string) string {
	if isAllowedMode(mode) {
		return mode
	}
	return "dark"
}

func effectiveThemeMode(user *models.User, org models.Organization) string {
	if user != nil && strings.TrimSpace(user.ThemeMode) != "" {
		return normalizeMode(user.ThemeMode)
	}
	return normalizeMode(org.ThemeMode)
}

func saveKeyUpload(r *http.Request, field string) (string, error) {
	file, header, err := r.FormFile(field)
	if err != nil {
		if err == http.ErrMissingFile {
			return "", nil
		}
		return "", err
	}
	defer file.Close()

	if err := os.MkdirAll(filepath.Join("data", "keys"), 0o755); err != nil {
		return "", err
	}

	safeName := fmt.Sprintf("%d-%s", time.Now().UnixNano(), filepath.Base(header.Filename))
	targetPath := filepath.Join("data", "keys", safeName)
	target, err := os.Create(targetPath)
	if err != nil {
		return "", err
	}
	defer target.Close()

	if _, err := io.Copy(target, file); err != nil {
		return "", err
	}

	return safeName, nil
}
