package master

import (
	"bytes"
	"context"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/example/sessionguard/internal/auth"
	"github.com/example/sessionguard/internal/config"
	"github.com/example/sessionguard/internal/httpx"
	"github.com/example/sessionguard/internal/model"
)

const Version = "0.5.2"

type App struct {
	cfg    config.Master
	store  *store
	auth   *auth.Manager
	access *auth.AccessManager
	http   *http.Client
}

func New(ctx context.Context, cfg config.Master) (*App, error) {
	s, err := newStore(ctx, cfg)
	if err != nil {
		return nil, err
	}
	a, err := auth.New(ctx, cfg.OIDC)
	if err != nil {
		_ = s.close()
		return nil, fmt.Errorf("OIDC: %w", err)
	}
	access, err := auth.NewAccess(ctx, cfg.AccessAuth, authSessionStore{s: s})
	if err != nil {
		_ = s.close()
		return nil, fmt.Errorf("access auth: %w", err)
	}
	return &App{cfg: cfg, store: s, auth: a, access: access, http: &http.Client{Timeout: 8 * time.Second}}, nil
}

func (a *App) Run(ctx context.Context) error {
	mux := http.NewServeMux()
	a.auth.Register(mux)
	if a.access != nil {
		a.access.Register(mux)
	}
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version, "store": a.store.kind()})
	})
	mux.HandleFunc("GET /metrics", a.metrics)
	mux.HandleFunc("POST /api/v1/agents/enroll", a.enroll)
	mux.HandleFunc("POST /api/v1/agents/heartbeat", a.heartbeat)
	mux.HandleFunc("POST /api/v1/broker/resolve", a.brokerResolve)
	mux.HandleFunc("POST /api/v1/broker/tokens", a.brokerTokens)
	mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
		w.Header().Set("Cache-Control", "no-store, max-age=0")
		_, _ = fmt.Fprint(w, masterJS)
	})
	mux.Handle("GET /{$}", a.auth.Require(http.HandlerFunc(a.masterPage)))
	mux.Handle("GET /api/v1/me", a.auth.Require(http.HandlerFunc(a.me)))
	mux.Handle("GET /api/v1/dashboard", a.auth.Require(http.HandlerFunc(a.dashboard)))
	mux.Handle("GET /api/v1/agents/{id}", a.auth.Require(http.HandlerFunc(a.agentDetail)))
	mux.Handle("PUT /api/v1/agents/{id}/policy", a.auth.Require(a.require("policy", http.HandlerFunc(a.policy))))
	mux.Handle("POST /api/v1/agents/{id}/policy/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyRollback))))
	mux.Handle("PUT /api/v1/policy/all", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyAll))))
	mux.Handle("POST /api/v1/policy/global/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.globalPolicyRollback))))
	mux.Handle("PUT /api/v1/farms/{id}/policy", a.auth.Require(a.require("policy", http.HandlerFunc(a.farmPolicy))))
	mux.Handle("POST /api/v1/farms/{id}/policy/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.farmPolicyRollback))))
	mux.Handle("PATCH /api/v1/agents/{id}/control", a.auth.Require(a.require("maintenance", http.HandlerFunc(a.agentControl))))
	mux.Handle("POST /api/v1/agents/{id}/sessions/{session}/action", a.auth.Require(a.require("session", http.HandlerFunc(a.sessionAction))))
	mux.Handle("POST /api/v1/agents/{id}/sessions/bulk", a.auth.Require(a.require("session", http.HandlerFunc(a.sessionBulkAction))))
	mux.Handle("POST /api/v1/agents/{id}/processes/{pid}/kill", a.auth.Require(a.require("process", http.HandlerFunc(a.processKill))))
	mux.Handle("GET /api/v1/audit", a.auth.Require(a.require("audit", http.HandlerFunc(a.audit))))
	mux.Handle("GET /api/v1/history", a.auth.Require(http.HandlerFunc(a.history)))
	mux.Handle("GET /api/v1/policy/history", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyHistory))))
	mux.Handle("GET /api/v1/farms", a.auth.Require(http.HandlerFunc(a.farms)))
	mux.Handle("POST /api/v1/farms", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmCreate))))
	mux.Handle("PUT /api/v1/farms/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmUpdate))))
	mux.Handle("DELETE /api/v1/farms/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmDelete))))
	mux.Handle("GET /api/v1/resources", a.auth.Require(http.HandlerFunc(a.resources)))
	mux.Handle("POST /api/v1/resources", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceCreate))))
	mux.Handle("PUT /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceUpdate))))
	mux.Handle("DELETE /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceDelete))))
	mux.Handle("GET /api/v1/alerts", a.auth.Require(http.HandlerFunc(a.alerts)))
	mux.Handle("GET /api/v1/leases", a.auth.Require(http.HandlerFunc(a.leases)))
	mux.Handle("GET /api/v1/access/sessions", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessions))))
	mux.Handle("DELETE /api/v1/access/sessions/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessionRevoke))))
	server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second}
	go a.monitor(ctx)
	go func() {
		<-ctx.Done()
		c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = server.Shutdown(c)
		_ = a.store.close()
	}()
	log.Printf("master %s listening on %s (store=%s)", Version, a.cfg.Listen, a.store.kind())
	err := server.ListenAndServe()
	if err == http.ErrServerClosed {
		return nil
	}
	return err
}

func (a *App) enroll(w http.ResponseWriter, r *http.Request) {
	var req model.EnrollRequest
	if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	if !constantEqual(req.EnrollmentToken, a.cfg.EnrollmentToken) || strings.TrimSpace(req.MachineID) == "" {
		httpx.Error(w, 401, "invalid enrollment")
		return
	}
	now := time.Now().UTC()
	token, id := randomToken(32), randomToken(16)
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	for oldID, rec := range a.store.data.Agents {
		if rec.MachineID == req.MachineID {
			id = oldID
			break
		}
	}
	rec := a.store.data.Agents[id]
	newEnrollment := rec.EnrolledAt.IsZero()
	rec.ID = id
	rec.Name = req.Name
	rec.MachineID = req.MachineID
	rec.TokenHash = hashToken(token)
	if newEnrollment {
		rec.EnrolledAt = now
	}
	if rec.Tags == nil {
		rec.Tags = map[string]string{}
	}
	if rec.MaintenanceMode == "" {
		rec.MaintenanceMode = "online"
	}
	a.store.data.Agents[id] = rec
	action := "agent_reenroll"
	if newEnrollment {
		action = "agent_enroll"
	}
	a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "agent-bootstrap", Action: action, Target: req.Name, Result: "success", Details: req.MachineID})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, model.EnrollResponse{AgentID: id, Token: token})
}

func (a *App) heartbeat(w http.ResponseWriter, r *http.Request) {
	id := r.Header.Get("X-Agent-ID")
	token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
	if id == "" || token == "" {
		httpx.Error(w, 401, "missing agent credentials")
		return
	}
	var snap model.AgentSnapshot
	if err := httpx.DecodeJSON(r, &snap, 8<<20); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	if snap.ProtocolVersion != model.ProtocolVersion {
		httpx.Error(w, 409, "protocol version mismatch")
		return
	}
	now := time.Now().UTC()
	var notify []model.Alert
	a.store.mu.Lock()
	rec, ok := a.store.data.Agents[id]
	if !ok || !constantEqual(hashToken(token), rec.TokenHash) {
		a.store.mu.Unlock()
		httpx.Error(w, 401, "invalid agent credentials")
		return
	}
	previous := rec.Snapshot
	snap.AgentID = id
	rec.LastSeen = now
	rec.Snapshot = snap
	if snap.Server.Hostname != "" {
		rec.Name = snap.Server.Hostname
	}
	a.recordSessionHistoryLocked(rec, previous, snap, now)
	results := map[string]model.CommandResult{}
	for _, res := range snap.CommandResults {
		if res.ID != "" {
			results[res.ID] = res
		}
	}
	pending := make([]model.SessionCommand, 0, len(rec.PendingCommands))
	for _, cmd := range rec.PendingCommands {
		if result, found := results[cmd.ID]; found {
			status, details := "success", ""
			if !result.Success {
				status = "error"
				details = result.Error
			}
			a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "agent:" + rec.Name, Action: "command_result:" + cmd.Action, Target: commandTarget(rec, cmd), Result: status, Details: details})
			continue
		}
		if !cmd.ExpiresAt.IsZero() && now.After(cmd.ExpiresAt) {
			a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "system", Action: "command_expired:" + cmd.Action, Target: commandTarget(rec, cmd), Result: "expired"})
			continue
		}
		pending = append(pending, cmd)
	}
	rec.PendingCommands = pending
	if rec.RestartWhenDrained && userSessionCount(snap.Sessions) == 0 && !hasPendingAction(rec.PendingCommands, "restart_server") {
		cmd := model.SessionCommand{ID: randomToken(12), Action: "restart_server", RequestedBy: "system:drain", Message: "SessionGuard restart after drain", CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)}
		rec.PendingCommands = append(rec.PendingCommands, cmd)
		rec.RestartWhenDrained = false
		a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "system", Action: "restart_when_drained", Target: rec.Name, Result: "queued"})
	}
	a.store.data.Agents[id] = rec
	notify = a.evaluateAgentAlertsLocked(rec, now)
	desired := a.effectivePolicyLocked(rec)
	var sendPolicy *model.Policy
	if desired != nil && desired.Revision != snap.PolicyRevision {
		cp := *desired
		sendPolicy = &cp
	}
	desiredRemoteApps := a.desiredRemoteAppsLocked(id, rec)
	commands := append([]model.SessionCommand(nil), rec.PendingCommands...)
	if err := a.store.saveLocked(); err != nil {
		a.store.mu.Unlock()
		httpx.Error(w, 500, err.Error())
		return
	}
	a.store.mu.Unlock()
	for _, al := range notify {
		a.notifyAlert(al)
	}
	httpx.JSON(w, 200, model.HeartbeatResponse{DesiredPolicy: sendPolicy, DesiredRemoteApps: desiredRemoteApps, Commands: commands, ServerTime: now})
}

func (a *App) recordSessionHistoryLocked(rec model.AgentRecord, old, new model.AgentSnapshot, now time.Time) {
	om := map[uint32]model.Session{}
	nm := map[uint32]model.Session{}
	for _, s := range old.Sessions {
		if s.User != "" {
			om[s.ID] = s
		}
	}
	for _, s := range new.Sessions {
		if s.User != "" {
			nm[s.ID] = s
		}
	}
	for id, s := range nm {
		prev, exists := om[id]
		event := ""
		if !exists {
			event = "logon"
		} else if prev.State != s.State {
			event = strings.ToLower(s.State)
		}
		if event != "" {
			a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: displaySessionUser(s), SID: s.SID, Event: event, State: s.State, ClientName: s.ClientName}, a.cfg.HistoryLimit)
		}
	}
	for id, s := range om {
		if _, exists := nm[id]; !exists {
			a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: displaySessionUser(s), SID: s.SID, Event: "logoff", State: s.State, ClientName: s.ClientName}, a.cfg.HistoryLimit)
		}
	}
	for id, telemetry := range new.Telemetry {
		oldTelemetry := old.Telemetry[id]
		if telemetry.ObservedLogonMS > 0 && oldTelemetry.ObservedLogonMS == 0 {
			details := fmt.Sprintf("logon=%dms restore=%dms", telemetry.ObservedLogonMS, telemetry.RestoreDurationMS)
			a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: telemetry.User, SID: telemetry.SID, Event: "logon_ready", State: "Active", Details: details}, a.cfg.HistoryLimit)
		}
	}
}

func (a *App) effectivePolicyLocked(rec model.AgentRecord) *model.Policy {
	if rec.DesiredPolicy != nil {
		return rec.DesiredPolicy
	}

	// Explicit per-agent farm assignments have precedence and keep their
	// configured order. This makes policy precedence predictable when an agent
	// intentionally belongs to multiple farms.
	for _, fid := range rec.FarmIDs {
		if f, ok := a.store.data.Farms[fid]; ok && f.Enabled && f.Policy != nil {
			return f.Policy
		}
	}

	// Farms may also select agents centrally through AgentIDs or RequiredTags.
	// Evaluate these deterministically so a server receives the same policy
	// after every master restart.
	ids := make([]string, 0, len(a.store.data.Farms))
	for id := range a.store.data.Farms {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	for _, fid := range ids {
		f := a.store.data.Farms[fid]
		if !f.Enabled || f.Policy == nil {
			continue
		}
		if contains(f.AgentIDs, rec.ID) || (len(f.RequiredTags) > 0 && tagsMatch(rec.Tags, f.RequiredTags)) {
			return f.Policy
		}
	}
	return a.store.data.GlobalPolicy
}

func (a *App) brokerResolve(w http.ResponseWriter, r *http.Request) {
	if !a.cfg.Broker.Enabled {
		httpx.Error(w, 404, "broker disabled")
		return
	}
	token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
	if !constantEqual(token, a.cfg.Broker.APIKey) {
		httpx.Error(w, 401, "invalid broker credential")
		return
	}
	var req model.BrokerRequest
	if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	if strings.TrimSpace(req.Username) == "" {
		httpx.Error(w, 400, "username is required")
		return
	}
	resp, err := a.resolveBroker(req)
	if err != nil {
		httpx.Error(w, 503, err.Error())
		return
	}
	httpx.JSON(w, 200, resp)
}

func (a *App) brokerTokens(w http.ResponseWriter, r *http.Request) {
	if !a.cfg.Broker.Enabled {
		httpx.Error(w, 404, "broker disabled")
		return
	}
	token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
	if !constantEqual(token, a.cfg.Broker.APIKey) {
		httpx.Error(w, 401, "invalid broker credential")
		return
	}
	if err := r.ParseForm(); err != nil {
		httpx.Error(w, 400, "invalid form")
		return
	}
	req := model.BrokerRequest{
		Username:       r.FormValue("username"),
		ConnectionID:   r.FormValue("connection_id"),
		ConnectionName: r.FormValue("connection_name"),
		ResourceID:     r.FormValue("resource_id"),
		FarmID:         r.FormValue("farm_id"),
	}
	if strings.TrimSpace(req.Username) == "" {
		httpx.Error(w, 400, "username is required")
		return
	}
	// The Guacamole extension decorates every connection. Only connections
	// explicitly mapped as SessionGuard Resources should therefore invoke the
	// broker. Returning an empty token set leaves unrelated/static Guacamole
	// connections untouched and prevents a broker outage from breaking them.
	if !a.hasMappedResource(req) {
		w.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
		w.WriteHeader(http.StatusOK)
		return
	}
	resp, err := a.resolveBroker(req)
	if err != nil {
		httpx.Error(w, 503, err.Error())
		return
	}
	values := url.Values{}
	for k, v := range resp.Tokens {
		values.Set(k, v)
	}
	values.Set("SESSIONGUARD_REASON", resp.Reason)
	values.Set("SESSIONGUARD_FARM_ID", resp.FarmID)
	w.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
	_, _ = fmt.Fprint(w, values.Encode())
}

func (a *App) hasMappedResource(req model.BrokerRequest) bool {
	a.store.mu.RLock()
	defer a.store.mu.RUnlock()
	_, ok := a.findResourceLocked(req)
	return ok
}

func (a *App) findResourceLocked(req model.BrokerRequest) (*model.Resource, bool) {
	// Prefer stable explicit identifiers over names. Guacamole sends both
	// connection ID and name; deterministic precedence prevents a bad mapping
	// from being selected according to Go map iteration order.
	if req.ResourceID != "" {
		if res, ok := a.store.data.Resources[req.ResourceID]; ok && res.Enabled {
			cp := res
			return &cp, true
		}
	}
	if req.ConnectionID != "" {
		for _, res := range a.store.data.Resources {
			if res.Enabled && res.GuacamoleConnectionID != "" && res.GuacamoleConnectionID == req.ConnectionID {
				cp := res
				return &cp, true
			}
		}
	}
	if req.ConnectionName != "" {
		for _, res := range a.store.data.Resources {
			if res.Enabled && res.GuacamoleConnectionName != "" && strings.EqualFold(res.GuacamoleConnectionName, req.ConnectionName) {
				cp := res
				return &cp, true
			}
		}
	}
	return nil, false
}

func (a *App) resolveBroker(req model.BrokerRequest) (model.BrokerResponse, error) {
	now := time.Now().UTC()
	userKey := normalizeUser(req.Username)
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	resource, _ := a.findResourceLocked(req)
	farmID := req.FarmID
	if resource != nil {
		farmID = resource.FarmID
	}
	if farmID == "" {
		farmID = a.cfg.Broker.DefaultFarmID
	}
	if farmID != "" {
		if f, ok := a.store.data.Farms[farmID]; !ok || !f.Enabled {
			return model.BrokerResponse{}, fmt.Errorf("farm %q is not available", farmID)
		}
	}
	leaseKey := a.brokerLeaseKey(userKey, farmID, resourceID(resource))

	// Existing RDS session wins for the requested farm, including on a draining host.
	// Maintenance hosts are never selected. This provides Citrix-like reconnect affinity.
	if a.cfg.Broker.ReconnectExisting {
		for id, rec := range a.store.data.Agents {
			if rec.MaintenanceMode == "maintenance" || !agentOnline(rec, now, a.cfg.OfflineAfterSeconds) || !a.agentInFarmLocked(id, rec, farmID) || !agentResourceReady(rec, resource) {
				continue
			}
			for _, sess := range rec.Snapshot.Sessions {
				if brokerSessionState(sess.State) && sessionMatchesUser(sess, userKey) {
					lease := a.putLeaseLocked(leaseKey, userKey, id, farmID, resourceID(resource), "existing-session", now)
					if err := a.store.saveLocked(); err != nil {
						return model.BrokerResponse{}, err
					}
					return brokerResponse(rec, farmID, resource, true, "existing-session", lease), nil
				}
			}
		}
	}
	if lease, ok := a.store.data.Leases[leaseKey]; ok && now.Before(lease.ExpiresAt) {
		if rec, found := a.store.data.Agents[lease.AgentID]; found && rec.MaintenanceMode != "maintenance" && agentOnline(rec, now, a.cfg.OfflineAfterSeconds) && a.agentInFarmLocked(lease.AgentID, rec, farmID) && agentResourceReady(rec, resource) {
			lease.ExpiresAt = now.Add(time.Duration(a.cfg.Broker.LeaseSeconds) * time.Second)
			a.store.data.Leases[leaseKey] = lease
			if err := a.store.saveLocked(); err != nil {
				return model.BrokerResponse{}, err
			}
			return brokerResponse(rec, farmID, resource, true, "existing-lease", lease), nil
		}
	}
	candidates := a.farmCandidatesLocked(farmID, resource, now)
	if len(candidates) == 0 {
		return model.BrokerResponse{}, fmt.Errorf("no healthy online server is available for farm %q", farmID)
	}
	sort.Slice(candidates, func(i, j int) bool { return brokerScore(candidates[i]) > brokerScore(candidates[j]) })
	chosen := candidates[0]
	lease := a.putLeaseLocked(leaseKey, userKey, chosen.ID, farmID, resourceID(resource), "load-balance", now)
	if err := a.store.saveLocked(); err != nil {
		return model.BrokerResponse{}, err
	}
	return brokerResponse(chosen, farmID, resource, false, "load-balance", lease), nil
}

func (a *App) brokerLeaseKey(user, farm, resource string) string {
	if a.cfg.Broker.SingleSession {
		return user
	}
	return user + "|" + farm + "|" + resource
}

func brokerSessionState(state string) bool {
	switch strings.ToLower(strings.TrimSpace(state)) {
	case "active", "connected", "disconnected":
		return true
	default:
		return false
	}
}

func (a *App) agentInFarmLocked(id string, rec model.AgentRecord, farmID string) bool {
	if farmID == "" {
		return true
	}
	f, ok := a.store.data.Farms[farmID]
	if !ok || !f.Enabled {
		return false
	}
	if contains(f.AgentIDs, id) || contains(rec.FarmIDs, farmID) {
		return true
	}
	return len(f.RequiredTags) > 0 && tagsMatch(rec.Tags, f.RequiredTags)
}

func (a *App) farmCandidatesLocked(farmID string, resource *model.Resource, now time.Time) []model.AgentRecord {
	if farmID != "" {
		if f, ok := a.store.data.Farms[farmID]; !ok || !f.Enabled {
			return nil
		}
	}
	out := []model.AgentRecord{}
	for id, rec := range a.store.data.Agents {
		if rec.MaintenanceMode != "online" || !agentOnline(rec, now, a.cfg.OfflineAfterSeconds) || rec.Snapshot.Health.Score < a.cfg.Broker.MinHealthScore {
			continue
		}
		if !a.agentInFarmLocked(id, rec, farmID) {
			continue
		}
		if !agentResourceReady(rec, resource) {
			continue
		}
		out = append(out, rec)
	}
	return out
}

func (a *App) putLeaseLocked(key, user, agent, farm, res, reason string, now time.Time) model.UserLease {
	l := model.UserLease{UserKey: user, AgentID: agent, FarmID: farm, ResourceID: res, CreatedAt: now, ExpiresAt: now.Add(time.Duration(a.cfg.Broker.LeaseSeconds) * time.Second), Reason: reason}
	a.store.data.Leases[key] = l
	return l
}

func brokerResponse(rec model.AgentRecord, farm string, res *model.Resource, reconnect bool, reason string, lease model.UserLease) model.BrokerResponse {
	tokens := map[string]string{"SESSIONGUARD_HOST": rec.Snapshot.Server.Hostname, "SESSIONGUARD_AGENT_ID": rec.ID}
	rid := ""
	if res != nil {
		rid = res.ID
		tokens["SESSIONGUARD_RESOURCE_ID"] = res.ID
		tokens["SESSIONGUARD_REMOTE_APP"] = res.RemoteApp
		tokens["SESSIONGUARD_REMOTE_APP_DIR"] = res.RemoteAppDir
		remoteArgs := res.RemoteAppArgs
		if res.Kind == "remoteapp" && res.ManageRemoteApp {
			switch res.RemoteAppCommandLine {
			case 0:
				remoteArgs = ""
			case 2:
				remoteArgs = res.RemoteAppRequiredArgs
			}
		}
		tokens["SESSIONGUARD_REMOTE_APP_ARGS"] = remoteArgs
	}
	return model.BrokerResponse{AgentID: rec.ID, Hostname: rec.Snapshot.Server.Hostname, FarmID: farm, ResourceID: rid, Reconnect: reconnect, Reason: reason, HealthScore: rec.Snapshot.Health.Score, Tokens: tokens, LeaseExpires: lease.ExpiresAt}
}

func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
	recs := a.store.all()
	sort.Slice(recs, func(i, j int) bool { return strings.ToLower(recs[i].Name) < strings.ToLower(recs[j].Name) })
	now := time.Now().UTC()
	type row struct {
		model.AgentRecord
		Online        bool    `json:"online"`
		Active        int     `json:"active_sessions"`
		Disconnected  int     `json:"disconnected_sessions"`
		Total         int     `json:"total_sessions"`
		MemoryPercent float64 `json:"memory_percent"`
		BrokerScore   float64 `json:"broker_score"`
	}
	out := make([]row, 0, len(recs))
	for _, rec := range recs {
		active, disc := sessionCounts(rec.Snapshot.Sessions)
		mem := 0.0
		if rec.Snapshot.Server.MemoryTotal > 0 {
			mem = float64(rec.Snapshot.Server.MemoryTotal-rec.Snapshot.Server.MemoryAvailable) * 100 / float64(rec.Snapshot.Server.MemoryTotal)
		}
		out = append(out, row{AgentRecord: rec, Online: agentOnline(rec, now, a.cfg.OfflineAfterSeconds), Active: active, Disconnected: disc, Total: active + disc, MemoryPercent: mem, BrokerScore: brokerScore(rec)})
	}
	a.store.mu.RLock()
	farms := len(a.store.data.Farms)
	resources := len(a.store.data.Resources)
	alerts := 0
	accessSessions := 0
	for _, x := range a.store.data.Alerts {
		if x.Active {
			alerts++
		}
	}
	for _, sess := range a.store.data.AuthSessions {
		if sess.ExpiresAt.IsZero() || now.Before(sess.ExpiresAt) {
			accessSessions++
		}
	}
	a.store.mu.RUnlock()
	httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now, "farms": farms, "resources": resources, "active_alerts": alerts, "access_sessions": accessSessions, "store": a.store.kind()})
}
func (a *App) agentDetail(w http.ResponseWriter, r *http.Request) {
	rec, ok := a.store.get(r.PathValue("id"))
	if !ok {
		httpx.Error(w, 404, "agent not found")
		return
	}
	httpx.JSON(w, 200, rec)
}

func (a *App) policy(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	id := r.PathValue("id")
	var p model.Policy
	if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	config.NormalizePolicy(&p)
	if err := config.ValidatePolicy(p); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	p.Revision = randomToken(12)
	p.UpdatedAt = time.Now().UTC()
	actor := requestActor(r)
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	rec, ok := a.store.data.Agents[id]
	if !ok {
		httpx.Error(w, 404, "agent not found")
		return
	}
	rec.DesiredPolicy = &p
	a.store.data.Agents[id] = rec
	a.recordPolicyVersionLocked("agent:"+id, p, actor)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_update", Target: rec.Name, Result: "queued", Details: p.Revision})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, p)
}
func (a *App) policyAll(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	var p model.Policy
	if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	config.NormalizePolicy(&p)
	if err := config.ValidatePolicy(p); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	p.Revision = randomToken(12)
	p.UpdatedAt = time.Now().UTC()
	actor := requestActor(r)
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	a.store.data.GlobalPolicy = &p
	a.recordPolicyVersionLocked("global", p, actor)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "global_policy_update", Target: "all agents", Result: "queued", Details: p.Revision})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, p)
}
func (a *App) farmPolicy(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	var p model.Policy
	if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	config.NormalizePolicy(&p)
	if err := config.ValidatePolicy(p); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	p.Revision = randomToken(12)
	p.UpdatedAt = time.Now().UTC()
	id, actor := r.PathValue("id"), requestActor(r)
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	f, ok := a.store.data.Farms[id]
	if !ok {
		httpx.Error(w, 404, "farm not found")
		return
	}
	f.Policy = &p
	a.store.data.Farms[id] = f
	a.recordPolicyVersionLocked("farm:"+id, p, actor)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "farm_policy_update", Target: f.Name, Result: "queued", Details: p.Revision})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, p)
}

func (a *App) globalPolicyRollback(w http.ResponseWriter, r *http.Request) {
	a.rollbackPolicyTarget(w, r, "global", "")
}
func (a *App) farmPolicyRollback(w http.ResponseWriter, r *http.Request) {
	a.rollbackPolicyTarget(w, r, "farm:"+r.PathValue("id"), r.PathValue("id"))
}
func (a *App) rollbackPolicyTarget(w http.ResponseWriter, r *http.Request, target, farmID string) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	rev, actor := r.PathValue("revision"), requestActor(r)
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	var found *model.Policy
	for i := len(a.store.data.PolicyHistory) - 1; i >= 0; i-- {
		v := a.store.data.PolicyHistory[i]
		if v.Target == target && v.Revision == rev {
			p := v.Policy
			found = &p
			break
		}
	}
	if found == nil {
		httpx.Error(w, 404, "revision not found")
		return
	}
	found.Revision = randomToken(12)
	found.UpdatedAt = time.Now().UTC()
	name := "global"
	if target == "global" {
		a.store.data.GlobalPolicy = found
	} else {
		f, ok := a.store.data.Farms[farmID]
		if !ok {
			httpx.Error(w, 404, "farm not found")
			return
		}
		f.Policy = found
		a.store.data.Farms[farmID] = f
		name = f.Name
	}
	a.recordPolicyVersionLocked(target, *found, actor)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_rollback", Target: name, Result: "queued", Details: rev + " -> " + found.Revision})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, found)
}

func (a *App) recordPolicyVersionLocked(target string, p model.Policy, actor string) {
	a.store.data.PolicyHistory = append(a.store.data.PolicyHistory, model.PolicyVersion{ID: randomToken(10), Target: target, Revision: p.Revision, CreatedAt: time.Now().UTC(), Actor: actor, Policy: p})
	if len(a.store.data.PolicyHistory) > 500 {
		a.store.data.PolicyHistory = append([]model.PolicyVersion(nil), a.store.data.PolicyHistory[len(a.store.data.PolicyHistory)-500:]...)
	}
}
func (a *App) policyRollback(w http.ResponseWriter, r *http.Request) {
	id, rev := r.PathValue("id"), r.PathValue("revision")
	actor := requestActor(r)
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	rec, ok := a.store.data.Agents[id]
	if !ok {
		httpx.Error(w, 404, "agent not found")
		return
	}
	var found *model.Policy
	for i := len(a.store.data.PolicyHistory) - 1; i >= 0; i-- {
		v := a.store.data.PolicyHistory[i]
		if v.Target == "agent:"+id && v.Revision == rev {
			p := v.Policy
			found = &p
			break
		}
	}
	if found == nil {
		httpx.Error(w, 404, "revision not found")
		return
	}
	found.Revision = randomToken(12)
	found.UpdatedAt = time.Now().UTC()
	rec.DesiredPolicy = found
	a.store.data.Agents[id] = rec
	a.recordPolicyVersionLocked("agent:"+id, *found, actor)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_rollback", Target: rec.Name, Result: "queued", Details: rev + " -> " + found.Revision})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, found)
}
func (a *App) policyHistory(w http.ResponseWriter, r *http.Request) {
	a.store.mu.RLock()
	defer a.store.mu.RUnlock()
	httpx.JSON(w, 200, map[string]any{"history": append([]model.PolicyVersion(nil), a.store.data.PolicyHistory...)})
}

func (a *App) agentControl(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	var req struct {
		Mode               string            `json:"mode"`
		RestartWhenDrained bool              `json:"restart_when_drained"`
		Tags               map[string]string `json:"tags"`
		FarmIDs            []string          `json:"farm_ids"`
	}
	if err := httpx.DecodeJSON(r, &req, 128<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	mode := strings.ToLower(strings.TrimSpace(req.Mode))
	if mode != "" && mode != "online" && mode != "drain" && mode != "maintenance" {
		httpx.Error(w, 400, "mode must be online, drain or maintenance")
		return
	}
	id := r.PathValue("id")
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	rec, ok := a.store.data.Agents[id]
	if !ok {
		httpx.Error(w, 404, "agent not found")
		return
	}
	if mode != "" {
		rec.MaintenanceMode = mode
	}
	rec.RestartWhenDrained = req.RestartWhenDrained
	if req.Tags != nil {
		rec.Tags = req.Tags
	}
	if req.FarmIDs != nil {
		rec.FarmIDs = req.FarmIDs
	}
	a.store.data.Agents[id] = rec
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "agent_control", Target: rec.Name, Result: "success", Details: fmt.Sprintf("mode=%s restart_when_drained=%v", rec.MaintenanceMode, rec.RestartWhenDrained)})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	rec.TokenHash = ""
	httpx.JSON(w, 200, rec)
}

func (a *App) sessionAction(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	id := r.PathValue("id")
	session64, err := strconv.ParseUint(r.PathValue("session"), 10, 32)
	if err != nil {
		httpx.Error(w, 400, "invalid session id")
		return
	}
	var req model.SessionActionRequest
	if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	action := strings.ToLower(strings.TrimSpace(req.Action))
	if action != "logoff" && action != "disconnect" && action != "message" {
		httpx.Error(w, 400, "invalid action")
		return
	}
	if action == "logoff" && !a.hasPermission(r, "session_logoff") {
		httpx.Error(w, 403, "role may not log off sessions")
		return
	}
	if action == "message" && strings.TrimSpace(req.Message) == "" {
		httpx.Error(w, 400, "message is required")
		return
	}
	cmd := model.SessionCommand{ID: randomToken(12), Action: action, SessionID: uint32(session64), Title: req.Title, Message: req.Message, RequestedBy: requestActor(r), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(2 * time.Minute)}
	a.queueCommand(w, id, cmd)
}
func (a *App) sessionBulkAction(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	var req struct {
		Action  string `json:"action"`
		Scope   string `json:"scope"`
		Title   string `json:"title"`
		Message string `json:"message"`
	}
	if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	action := strings.ToLower(strings.TrimSpace(req.Action))
	scope := strings.ToLower(strings.TrimSpace(req.Scope))
	if scope == "" {
		scope = "all"
	}
	if action != "logoff" && action != "disconnect" && action != "message" {
		httpx.Error(w, 400, "invalid action")
		return
	}
	if scope != "all" && scope != "disconnected" && scope != "active" {
		httpx.Error(w, 400, "scope must be all, active or disconnected")
		return
	}
	if action == "logoff" && !a.hasPermission(r, "session_logoff") {
		httpx.Error(w, 403, "role may not log off sessions")
		return
	}
	if action == "message" && strings.TrimSpace(req.Message) == "" {
		httpx.Error(w, 400, "message is required")
		return
	}
	id := r.PathValue("id")
	now := time.Now().UTC()
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	rec, ok := a.store.data.Agents[id]
	if !ok {
		httpx.Error(w, 404, "agent not found")
		return
	}
	policy := rec.Snapshot.Policy
	if p := a.effectivePolicyLocked(rec); p != nil {
		policy = *p
	}
	if !policy.Sessions.ControlEnabled {
		httpx.Error(w, 409, "session control is disabled by policy")
		return
	}
	added := 0
	for _, sess := range rec.Snapshot.Sessions {
		if sess.User == "" {
			continue
		}
		state := strings.ToLower(sess.State)
		if scope == "disconnected" && state != "disconnected" {
			continue
		}
		if scope == "active" && state != "active" && state != "connected" {
			continue
		}
		if len(rec.PendingCommands) >= 100 {
			break
		}
		rec.PendingCommands = append(rec.PendingCommands, model.SessionCommand{ID: randomToken(12), Action: action, SessionID: sess.ID, Title: req.Title, Message: req.Message, RequestedBy: requestActor(r), CreatedAt: now, ExpiresAt: now.Add(2 * time.Minute)})
		added++
	}
	a.store.data.Agents[id] = rec
	a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: requestActor(r), Action: "bulk:" + action, Target: rec.Name, Result: "queued", Details: fmt.Sprintf("scope=%s sessions=%d", scope, added)})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 202, map[string]any{"queued": added})
}

func (a *App) processKill(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	pid64, err := strconv.ParseUint(r.PathValue("pid"), 10, 32)
	if err != nil {
		httpx.Error(w, 400, "invalid pid")
		return
	}
	agentID := r.PathValue("id")
	rec, ok := a.store.get(agentID)
	if !ok {
		httpx.Error(w, 404, "agent not found")
		return
	}
	pid := uint32(pid64)
	var sessionID uint32
	for _, process := range rec.Snapshot.Processes {
		if process.PID == pid {
			sessionID = process.SessionID
			break
		}
	}
	if sessionID == 0 {
		httpx.Error(w, 404, "process is no longer present in a user session")
		return
	}
	cmd := model.SessionCommand{ID: randomToken(12), Action: "kill_process", SessionID: sessionID, PID: pid, RequestedBy: requestActor(r), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(2 * time.Minute)}
	a.queueCommand(w, agentID, cmd)
}
func (a *App) queueCommand(w http.ResponseWriter, id string, cmd model.SessionCommand) {
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	rec, ok := a.store.data.Agents[id]
	if !ok {
		httpx.Error(w, 404, "agent not found")
		return
	}
	policy := rec.Snapshot.Policy
	if p := a.effectivePolicyLocked(rec); p != nil {
		policy = *p
	}
	if !policy.Sessions.ControlEnabled {
		httpx.Error(w, 409, "session control is disabled by policy")
		return
	}
	if len(rec.PendingCommands) >= 100 {
		httpx.Error(w, 429, "too many pending commands")
		return
	}
	rec.PendingCommands = append(rec.PendingCommands, cmd)
	a.store.data.Agents[id] = rec
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: cmd.RequestedBy, Action: "command:" + cmd.Action, Target: commandTarget(rec, cmd), Result: "queued"})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 202, cmd)
}

func (a *App) farms(w http.ResponseWriter, r *http.Request) {
	a.store.mu.RLock()
	defer a.store.mu.RUnlock()
	out := make([]model.Farm, 0, len(a.store.data.Farms))
	for _, f := range a.store.data.Farms {
		out = append(out, f)
	}
	sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
	httpx.JSON(w, 200, map[string]any{"farms": out})
}
func (a *App) farmCreate(w http.ResponseWriter, r *http.Request) {
	var f model.Farm
	if err := httpx.DecodeJSON(r, &f, 256<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	if f.ID == "" {
		f.ID = randomToken(8)
	}
	a.saveFarm(w, r, f, false)
}
func (a *App) farmUpdate(w http.ResponseWriter, r *http.Request) {
	var f model.Farm
	if err := httpx.DecodeJSON(r, &f, 256<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	f.ID = r.PathValue("id")
	a.saveFarm(w, r, f, true)
}
func (a *App) saveFarm(w http.ResponseWriter, r *http.Request, f model.Farm, mustExist bool) {
	if strings.TrimSpace(f.Name) == "" {
		httpx.Error(w, 400, "farm name is required")
		return
	}
	if f.RequiredTags == nil {
		f.RequiredTags = map[string]string{}
	}
	if f.Policy != nil {
		config.NormalizePolicy(f.Policy)
		if err := config.ValidatePolicy(*f.Policy); err != nil {
			httpx.Error(w, 400, err.Error())
			return
		}
		if f.Policy.Revision == "" {
			f.Policy.Revision = randomToken(12)
			f.Policy.UpdatedAt = time.Now().UTC()
		}
	}
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	_, exists := a.store.data.Farms[f.ID]
	if mustExist && !exists {
		httpx.Error(w, 404, "farm not found")
		return
	}
	a.store.data.Farms[f.ID] = f
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "farm_save", Target: f.Name, Result: "success"})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, f)
}
func (a *App) farmDelete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	f, ok := a.store.data.Farms[id]
	if !ok {
		httpx.Error(w, 404, "farm not found")
		return
	}
	delete(a.store.data.Farms, id)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "farm_delete", Target: f.Name, Result: "success"})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	w.WriteHeader(204)
}
func (a *App) resources(w http.ResponseWriter, r *http.Request) {
	a.store.mu.RLock()
	defer a.store.mu.RUnlock()
	out := make([]model.Resource, 0, len(a.store.data.Resources))
	for _, x := range a.store.data.Resources {
		out = append(out, x)
	}
	sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
	httpx.JSON(w, 200, map[string]any{"resources": out})
}
func (a *App) resourceCreate(w http.ResponseWriter, r *http.Request) {
	var x model.Resource
	if err := httpx.DecodeJSON(r, &x, 128<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	if x.ID == "" {
		x.ID = randomToken(8)
	}
	a.saveResource(w, r, x, false)
}
func (a *App) resourceUpdate(w http.ResponseWriter, r *http.Request) {
	var x model.Resource
	if err := httpx.DecodeJSON(r, &x, 128<<10); err != nil {
		httpx.Error(w, 400, err.Error())
		return
	}
	x.ID = r.PathValue("id")
	a.saveResource(w, r, x, true)
}
func (a *App) saveResource(w http.ResponseWriter, r *http.Request, x model.Resource, mustExist bool) {
	if strings.TrimSpace(x.Name) == "" || strings.TrimSpace(x.FarmID) == "" {
		httpx.Error(w, 400, "resource name and farm_id are required")
		return
	}
	if x.Kind != "desktop" && x.Kind != "remoteapp" {
		httpx.Error(w, 400, "kind must be desktop or remoteapp")
		return
	}
	if x.Kind == "remoteapp" && strings.TrimSpace(x.RemoteApp) == "" {
		httpx.Error(w, 400, "remote_app is required for remoteapp resources")
		return
	}
	if x.Kind == "desktop" {
		x.RemoteApp = ""
		x.RemoteAppDir = ""
		x.RemoteAppArgs = ""
		x.ManageRemoteApp = false
		x.RemoteAppPath = ""
		x.RemoteAppIconPath = ""
		x.RemoteAppIconIndex = 0
		x.RemoteAppCommandLine = 0
		x.RemoteAppRequiredArgs = ""
		x.RemoteAppShowInPortal = false
	}
	if x.Kind == "remoteapp" {
		alias := remoteAppAlias(x.RemoteApp)
		if !validRemoteAppAlias(alias) {
			httpx.Error(w, 400, "remote_app alias must contain only letters, numbers, dot, dash or underscore")
			return
		}
		x.RemoteApp = "||" + alias
		if x.ManageRemoteApp && strings.TrimSpace(x.RemoteAppPath) == "" {
			httpx.Error(w, 400, "remote_app_path is required when agent RemoteApp management is enabled")
			return
		}
		if x.RemoteAppCommandLine > 2 {
			httpx.Error(w, 400, "remote_app_command_line_setting must be 0, 1 or 2")
			return
		}
	}
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	if _, ok := a.store.data.Farms[x.FarmID]; !ok {
		httpx.Error(w, 400, "farm does not exist")
		return
	}
	_, exists := a.store.data.Resources[x.ID]
	if mustExist && !exists {
		httpx.Error(w, 404, "resource not found")
		return
	}
	if x.Enabled {
		for id, existing := range a.store.data.Resources {
			if id == x.ID || !existing.Enabled {
				continue
			}
			if x.GuacamoleConnectionID != "" && existing.GuacamoleConnectionID == x.GuacamoleConnectionID {
				httpx.Error(w, 409, "guacamole_connection_id is already mapped by another enabled resource")
				return
			}
			if x.GuacamoleConnectionName != "" && strings.EqualFold(existing.GuacamoleConnectionName, x.GuacamoleConnectionName) {
				httpx.Error(w, 409, "guacamole_connection_name is already mapped by another enabled resource")
				return
			}
			if x.Kind == "remoteapp" && x.ManageRemoteApp && existing.Kind == "remoteapp" && existing.ManageRemoteApp && strings.EqualFold(remoteAppAlias(existing.RemoteApp), remoteAppAlias(x.RemoteApp)) {
				httpx.Error(w, 409, "managed remote_app alias is already used by another enabled resource")
				return
			}
		}
	}
	a.store.data.Resources[x.ID] = x
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "resource_save", Target: x.Name, Result: "success"})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	httpx.JSON(w, 200, x)
}
func (a *App) resourceDelete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	a.store.mu.Lock()
	defer a.store.mu.Unlock()
	x, ok := a.store.data.Resources[id]
	if !ok {
		httpx.Error(w, 404, "resource not found")
		return
	}
	delete(a.store.data.Resources, id)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "resource_delete", Target: x.Name, Result: "success"})
	if err := a.store.saveLocked(); err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	w.WriteHeader(204)
}

func (a *App) history(w http.ResponseWriter, r *http.Request) {
	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
	items := a.store.history(limit)
	user := normalizeUser(r.URL.Query().Get("user"))
	agent := strings.TrimSpace(r.URL.Query().Get("agent"))
	if user != "" || agent != "" {
		filtered := make([]model.SessionHistoryEvent, 0, len(items))
		for _, x := range items {
			if user != "" && normalizeUser(x.User) != user {
				continue
			}
			if agent != "" && x.AgentID != agent {
				continue
			}
			filtered = append(filtered, x)
		}
		items = filtered
	}
	httpx.JSON(w, 200, map[string]any{"history": items})
}
func (a *App) audit(w http.ResponseWriter, r *http.Request) {
	httpx.JSON(w, 200, map[string]any{"audit": a.store.audit(500)})
}
func (a *App) alerts(w http.ResponseWriter, r *http.Request) {
	a.store.mu.RLock()
	defer a.store.mu.RUnlock()
	out := make([]model.Alert, 0, len(a.store.data.Alerts))
	for _, x := range a.store.data.Alerts {
		out = append(out, x)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Active != out[j].Active {
			return out[i].Active
		}
		return out[i].LastSeenAt.After(out[j].LastSeenAt)
	})
	httpx.JSON(w, 200, map[string]any{"alerts": out})
}

func (a *App) accessSessions(w http.ResponseWriter, r *http.Request) {
	type publicSession struct {
		ID        string    `json:"id"`
		Subject   string    `json:"subject"`
		SID       string    `json:"sid,omitempty"`
		Username  string    `json:"username"`
		Email     string    `json:"email,omitempty"`
		Name      string    `json:"name,omitempty"`
		Groups    []string  `json:"groups,omitempty"`
		CreatedAt time.Time `json:"created_at"`
		ExpiresAt time.Time `json:"expires_at"`
	}
	now := time.Now().UTC()
	a.store.mu.RLock()
	out := make([]publicSession, 0, len(a.store.data.AuthSessions))
	for _, sess := range a.store.data.AuthSessions {
		if !sess.ExpiresAt.IsZero() && !now.Before(sess.ExpiresAt) {
			continue
		}
		out = append(out, publicSession{ID: sess.ID, Subject: sess.Subject, SID: sess.SID, Username: sess.Username, Email: sess.Email, Name: sess.Name, Groups: append([]string(nil), sess.Groups...), CreatedAt: sess.CreatedAt, ExpiresAt: sess.ExpiresAt})
	}
	a.store.mu.RUnlock()
	sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
	httpx.JSON(w, 200, map[string]any{"sessions": out})
}

func (a *App) accessSessionRevoke(w http.ResponseWriter, r *http.Request) {
	if !httpx.SameOrigin(r) {
		httpx.Error(w, 403, "cross-origin request rejected")
		return
	}
	id := strings.TrimSpace(r.PathValue("id"))
	if id == "" {
		httpx.Error(w, 400, "session id is required")
		return
	}
	a.store.mu.Lock()
	var username string
	var hash string
	for h, sess := range a.store.data.AuthSessions {
		if sess.ID == id {
			hash, username = h, sess.Username
			break
		}
	}
	if hash == "" {
		a.store.mu.Unlock()
		httpx.Error(w, 404, "access session not found")
		return
	}
	delete(a.store.data.AuthSessions, hash)
	a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "access_session_revoke", Target: username, Result: "success", Details: id})
	err := a.store.saveLocked()
	a.store.mu.Unlock()
	if err != nil {
		httpx.Error(w, 500, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (a *App) leases(w http.ResponseWriter, r *http.Request) {
	now := time.Now().UTC()
	a.store.mu.RLock()
	out := make([]model.UserLease, 0, len(a.store.data.Leases))
	for _, l := range a.store.data.Leases {
		if now.Before(l.ExpiresAt) {
			out = append(out, l)
		}
	}
	a.store.mu.RUnlock()
	sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
	httpx.JSON(w, 200, map[string]any{"leases": out})
}

func (a *App) monitor(ctx context.Context) {
	t := time.NewTicker(15 * time.Second)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case now := <-t.C:
			a.store.mu.Lock()
			leasesChanged := false
			for key, lease := range a.store.data.Leases {
				if now.After(lease.ExpiresAt) {
					delete(a.store.data.Leases, key)
					leasesChanged = true
				}
			}
			if !a.cfg.Alerts.Enabled {
				if leasesChanged {
					if err := a.store.saveLocked(); err != nil {
						log.Printf("master monitor persistence: %v", err)
					}
				}
				a.store.mu.Unlock()
				continue
			}
			changed := []model.Alert{}
			for id, rec := range a.store.data.Agents {
				if now.Sub(rec.LastSeen) > time.Duration(a.cfg.Alerts.OfflineSeconds)*time.Second {
					changed = append(changed, a.setAlertLocked("offline:"+id, rec, "critical", "agent_offline", fmt.Sprintf("Agent seit %s nicht erreichbar", now.Sub(rec.LastSeen).Round(time.Second)), true, now)...)
				} else {
					changed = append(changed, a.setAlertLocked("offline:"+id, rec, "critical", "agent_offline", "", false, now)...)
				}
			}
			if err := a.store.saveLocked(); err != nil {
				log.Printf("master monitor persistence: %v", err)
			}
			a.store.mu.Unlock()
			for _, al := range changed {
				a.notifyAlert(al)
			}
		}
	}
}
func (a *App) evaluateAgentAlertsLocked(rec model.AgentRecord, now time.Time) []model.Alert {
	if !a.cfg.Alerts.Enabled {
		return nil
	}
	changed := []model.Alert{}
	s := rec.Snapshot.Server
	mem := 0.0
	if s.MemoryTotal > 0 {
		mem = float64(s.MemoryTotal-s.MemoryAvailable) * 100 / float64(s.MemoryTotal)
	}
	checks := []struct {
		key, sev, typ, msg string
		active             bool
	}{{"cpu:" + rec.ID, "warning", "cpu_high", fmt.Sprintf("CPU %.1f%%", s.CPUPercent), s.CPUPercent >= float64(a.cfg.Alerts.CPUPercent)}, {"memory:" + rec.ID, "warning", "memory_high", fmt.Sprintf("RAM %.1f%%", mem), mem >= float64(a.cfg.Alerts.MemoryPercent)}, {"disk:" + rec.ID, "critical", "disk_low", fmt.Sprintf("Systemdisk %d GiB frei", s.DiskFree>>30), s.DiskTotal > 0 && s.DiskFree < uint64(a.cfg.Alerts.DiskFreeGB)<<30}, {"health:" + rec.ID, "critical", "health_low", fmt.Sprintf("Health Score %d", rec.Snapshot.Health.Score), rec.Snapshot.Health.Score < a.cfg.Alerts.HealthScore}}
	_, disc := sessionCounts(rec.Snapshot.Sessions)
	checks = append(checks, struct {
		key, sev, typ, msg string
		active             bool
	}{"disc:" + rec.ID, "warning", "disconnected_sessions", fmt.Sprintf("%d getrennte Sitzungen", disc), disc >= a.cfg.Alerts.DisconnectedSessions})
	maxLogonMS := int64(0)
	for _, telemetry := range rec.Snapshot.Telemetry {
		if telemetry.ObservedLogonMS > maxLogonMS {
			maxLogonMS = telemetry.ObservedLogonMS
		}
	}
	checks = append(checks, struct {
		key, sev, typ, msg string
		active             bool
	}{"logon:" + rec.ID, "warning", "logon_slow", fmt.Sprintf("Letzte gemessene Logon-Pipeline %.1f s", float64(maxLogonMS)/1000), maxLogonMS >= int64(a.cfg.Alerts.LogonDurationSeconds)*1000})
	fail := 0
	for _, st := range rec.Snapshot.ProfileStatus {
		if st.LastBackupError != "" || st.LastRestoreError != "" {
			fail++
		}
	}
	checks = append(checks, struct {
		key, sev, typ, msg string
		active             bool
	}{"profiles:" + rec.ID, "critical", "profile_failures", fmt.Sprintf("%d Profile mit Fehlerstatus", fail), fail >= a.cfg.Alerts.ProfileFailures})
	for _, c := range checks {
		changed = append(changed, a.setAlertLocked(c.key, rec, c.sev, c.typ, c.msg, c.active, now)...)
	}
	return changed
}
func (a *App) setAlertLocked(key string, rec model.AgentRecord, severity, typ, msg string, active bool, now time.Time) []model.Alert {
	al, exists := a.store.data.Alerts[key]
	if !exists {
		al = model.Alert{ID: randomToken(8), Key: key, AgentID: rec.ID, Hostname: rec.Name, Severity: severity, Type: typ, FirstSeenAt: now}
	}
	if active {
		was := al.Active
		al.Active = true
		al.LastSeenAt = now
		al.Message = msg
		al.Severity = severity
		if !was {
			al.FirstSeenAt = now
		}
		a.store.data.Alerts[key] = al
		if !was || now.Sub(al.LastNotifiedAt) >= time.Duration(a.cfg.Alerts.NotificationMinInterval)*time.Second {
			al.LastNotifiedAt = now
			a.store.data.Alerts[key] = al
			return []model.Alert{al}
		}
		return nil
	}
	if exists && al.Active {
		al.Active = false
		al.ResolvedAt = now
		al.LastSeenAt = now
		al.LastNotifiedAt = now
		a.store.data.Alerts[key] = al
		return []model.Alert{al}
	}
	return nil
}
func (a *App) notifyAlert(al model.Alert) {
	if !a.cfg.Alerts.Enabled || strings.TrimSpace(a.cfg.Alerts.WebhookURL) == "" {
		return
	}
	body, _ := json.Marshal(al)
	req, err := http.NewRequest(http.MethodPost, a.cfg.Alerts.WebhookURL, bytes.NewReader(body))
	if err != nil {
		return
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := a.http.Do(req)
	if err != nil {
		log.Printf("alert webhook: %v", err)
		return
	}
	_ = resp.Body.Close()
}

func (a *App) metrics(w http.ResponseWriter, r *http.Request) {
	recs := a.store.all()
	now := time.Now().UTC()
	online, active, disconnected, profileJobs, cleanupJobs := 0, 0, 0, 0, 0
	healthTotal := 0
	for _, rec := range recs {
		if agentOnline(rec, now, a.cfg.OfflineAfterSeconds) {
			online++
		}
		aa, dd := sessionCounts(rec.Snapshot.Sessions)
		active += aa
		disconnected += dd
		profileJobs += len(rec.Snapshot.ProfileJobs)
		cleanupJobs += len(rec.Snapshot.PendingCleanup)
		healthTotal += rec.Snapshot.Health.Score
	}
	w.Header().Set("Content-Type", "text/plain; version=0.0.4")
	fmt.Fprintf(w, "sessionguard_master_up 1\nsessionguard_agents_total %d\nsessionguard_agents_online %d\nsessionguard_sessions_active %d\nsessionguard_sessions_disconnected %d\nsessionguard_profile_jobs %d\nsessionguard_cleanup_jobs %d\nsessionguard_health_score_sum %d\n", len(recs), online, active, disconnected, profileJobs, cleanupJobs, healthTotal)
}

func (a *App) me(w http.ResponseWriter, r *http.Request) {
	u, _ := auth.UserFrom(r)
	httpx.JSON(w, 200, map[string]any{"user": u, "roles": a.roles(r), "permissions": a.permissions(r)})
}

var rolePermissions = map[string][]string{"viewer": {"view"}, "helpdesk": {"view", "session"}, "operator": {"view", "session", "session_logoff", "process", "maintenance"}, "profile_admin": {"view", "session", "profile"}, "policy_admin": {"view", "policy"}, "auditor": {"view", "audit"}, "admin": {"view", "session", "session_logoff", "process", "maintenance", "profile", "policy", "audit", "manage"}}

func (a *App) roles(r *http.Request) []string {
	u, ok := auth.UserFrom(r)
	if !ok {
		return nil
	}
	if len(a.cfg.RBAC.Groups) == 0 {
		return []string{"admin"}
	}
	set := map[string]bool{}
	for _, g := range u.Groups {
		for configured, roles := range a.cfg.RBAC.Groups {
			if strings.EqualFold(g, configured) {
				for _, role := range roles {
					set[strings.ToLower(role)] = true
				}
			}
		}
	}
	if len(set) == 0 && a.cfg.RBAC.DefaultRole != "" {
		set[strings.ToLower(a.cfg.RBAC.DefaultRole)] = true
	}
	out := make([]string, 0, len(set))
	for role := range set {
		out = append(out, role)
	}
	sort.Strings(out)
	return out
}
func (a *App) permissions(r *http.Request) []string {
	set := map[string]bool{}
	for _, role := range a.roles(r) {
		for _, p := range rolePermissions[role] {
			set[p] = true
		}
	}
	out := make([]string, 0, len(set))
	for p := range set {
		out = append(out, p)
	}
	sort.Strings(out)
	return out
}
func (a *App) hasPermission(r *http.Request, p string) bool {
	for _, x := range a.permissions(r) {
		if x == p {
			return true
		}
	}
	return false
}
func (a *App) require(permission string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !a.hasPermission(r, permission) {
			httpx.Error(w, 403, "insufficient role permission: "+permission)
			return
		}
		next.ServeHTTP(w, r)
	})
}
func requestActor(r *http.Request) string {
	u, ok := auth.UserFrom(r)
	if !ok {
		return "unknown"
	}
	if strings.TrimSpace(u.Email) != "" {
		return u.Email
	}
	if strings.TrimSpace(u.Name) != "" {
		return u.Name
	}
	return u.Sub
}

func (a *App) masterPage(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store, max-age=0")
	_, _ = fmt.Fprint(w, masterHTML)
}
func randomToken(n int) string {
	b := make([]byte, n)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}
func hashToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func constantEqual(x, y string) bool {
	if len(x) != len(y) {
		return false
	}
	return subtle.ConstantTimeCompare([]byte(x), []byte(y)) == 1
}
func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("Referrer-Policy", "same-origin")
		w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'self' data:")
		next.ServeHTTP(w, r)
	})
}
func commandTarget(rec model.AgentRecord, cmd model.SessionCommand) string {
	if cmd.PID != 0 {
		return fmt.Sprintf("%s/process/%d", rec.Name, cmd.PID)
	}
	if cmd.SessionID != 0 {
		return fmt.Sprintf("%s/session/%d", rec.Name, cmd.SessionID)
	}
	return rec.Name
}
func userSessionCount(ss []model.Session) int {
	n := 0
	for _, s := range ss {
		if s.User != "" {
			n++
		}
	}
	return n
}
func hasPendingAction(cmds []model.SessionCommand, action string) bool {
	for _, c := range cmds {
		if c.Action == action {
			return true
		}
	}
	return false
}
func agentOnline(rec model.AgentRecord, now time.Time, seconds int) bool {
	return !rec.LastSeen.IsZero() && now.Sub(rec.LastSeen) < time.Duration(seconds)*time.Second
}
func sessionCounts(ss []model.Session) (int, int) {
	a, d := 0, 0
	for _, s := range ss {
		if s.User == "" {
			continue
		}
		if s.State == "Active" {
			a++
		}
		if s.State == "Disconnected" {
			d++
		}
	}
	return a, d
}
func brokerScore(rec model.AgentRecord) float64 {
	active, disc := sessionCounts(rec.Snapshot.Sessions)
	mem := 0.0
	if rec.Snapshot.Server.MemoryTotal > 0 {
		mem = float64(rec.Snapshot.Server.MemoryTotal-rec.Snapshot.Server.MemoryAvailable) * 100 / float64(rec.Snapshot.Server.MemoryTotal)
	}
	return float64(rec.Snapshot.Health.Score)*10 - float64(active)*20 - float64(disc)*5 - rec.Snapshot.Server.CPUPercent*2 - mem
}
func normalizeUser(u string) string {
	u = strings.ToLower(strings.TrimSpace(u))
	u = strings.ReplaceAll(u, "/", `\`)
	return u
}
func sessionMatchesUser(s model.Session, key string) bool {
	if s.User == "" {
		return false
	}
	key = normalizeUser(key)
	short := normalizeUser(s.User)
	qualified := normalizeUser(displaySessionUser(s))

	// A caller that supplied an explicit DOMAIN\user identity is expressing a
	// domain boundary. Never drop that qualifier merely because the short user
	// name happens to match another domain.
	if strings.Contains(key, `\`) {
		return qualified == key
	}
	if key == short || key == qualified {
		return true
	}
	if s.Domain != "" && normalizeUser(s.User+"@"+s.Domain) == key {
		return true
	}
	// PocketID preferred_username is often a UPN while WTS exposes a NetBIOS
	// domain. In that case the UPN suffix cannot be reliably derived from WTS;
	// permit local-part matching for UPNs, but retain strict matching above for
	// explicit DOMAIN\user values.
	if i := strings.Index(key, "@"); i > 0 && key[:i] == short {
		return true
	}
	return false
}
func displaySessionUser(s model.Session) string {
	if s.Domain != "" {
		return s.Domain + `\` + s.User
	}
	return s.User
}
func contains(xs []string, v string) bool {
	for _, x := range xs {
		if x == v {
			return true
		}
	}
	return false
}
func tagsMatch(have, need map[string]string) bool {
	for k, v := range need {
		if !strings.EqualFold(have[k], v) {
			return false
		}
	}
	return true
}
func remoteAppAlias(remoteApp string) string {
	v := strings.TrimSpace(remoteApp)
	v = strings.TrimPrefix(v, "||")
	return strings.TrimSpace(v)
}

func validRemoteAppAlias(alias string) bool {
	if alias == "" || len(alias) > 128 {
		return false
	}
	for _, r := range alias {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' {
			continue
		}
		return false
	}
	return true
}

func (a *App) desiredRemoteAppsLocked(agentID string, rec model.AgentRecord) []model.RemoteAppSpec {
	out := make([]model.RemoteAppSpec, 0)
	for _, res := range a.store.data.Resources {
		if !res.Enabled || res.Kind != "remoteapp" || !res.ManageRemoteApp || strings.TrimSpace(res.RemoteAppPath) == "" {
			continue
		}
		if !a.agentInFarmLocked(agentID, rec, res.FarmID) {
			continue
		}
		alias := remoteAppAlias(res.RemoteApp)
		if !validRemoteAppAlias(alias) {
			continue
		}
		out = append(out, model.RemoteAppSpec{
			ResourceID: res.ID, Alias: alias, DisplayName: res.Name, Path: res.RemoteAppPath,
			IconPath: res.RemoteAppIconPath, IconIndex: res.RemoteAppIconIndex,
			CommandLineSetting: res.RemoteAppCommandLine, RequiredCommandLine: res.RemoteAppRequiredArgs,
			ShowInPortal: res.RemoteAppShowInPortal,
		})
	}
	sort.Slice(out, func(i, j int) bool {
		if strings.EqualFold(out[i].Alias, out[j].Alias) {
			return out[i].ResourceID < out[j].ResourceID
		}
		return strings.ToLower(out[i].Alias) < strings.ToLower(out[j].Alias)
	})
	return out
}

func agentResourceReady(rec model.AgentRecord, res *model.Resource) bool {
	if res == nil || res.Kind != "remoteapp" || !res.ManageRemoteApp {
		return true
	}
	alias := remoteAppAlias(res.RemoteApp)
	for _, st := range rec.Snapshot.RemoteApps {
		if st.ResourceID == res.ID || (st.ResourceID == "" && strings.EqualFold(st.Alias, alias)) {
			return st.Published && st.PathExists && st.InSync && strings.TrimSpace(st.Error) == ""
		}
	}
	return false
}

func resourceID(r *model.Resource) string {
	if r == nil {
		return ""
	}
	return r.ID
}
