// Package api implements the REST API handlers for uopi.
package api

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"os"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/uopi/uopi/internal/access"
	"github.com/uopi/uopi/internal/audit"
	"github.com/uopi/uopi/internal/broker"
	"github.com/uopi/uopi/internal/confmgr"
	"github.com/uopi/uopi/internal/controllogic"
	"github.com/uopi/uopi/internal/datasource"
	"github.com/uopi/uopi/internal/datasource/synthetic"
	"github.com/uopi/uopi/internal/metrics"
	"github.com/uopi/uopi/internal/panelacl"
	"github.com/uopi/uopi/internal/storage"
)

// Handler holds the dependencies shared across all API endpoints.
type Handler struct {
	broker           *broker.Broker
	synthetic        *synthetic.Synthetic // nil if not enabled
	store            *storage.Store
	cfg              *confmgr.Store
	policy           *access.Policy
	acl              *panelacl.Store
	ctrlLogic        *controllogic.Store
	ctrlEngine       *controllogic.Engine
	audit            audit.Recorder // never nil; audit.Nop when disabled
	channelFinderURL string         // empty if not configured
	archiverURL      string         // empty if not configured
	uiDefaultZoom    float64        // base UI scale sent to clients; 0 means 1.0
	log              *slog.Logger
}

// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler {
	if rec == nil {
		rec = audit.Nop()
	}
	return &Handler{
		broker:           b,
		synthetic:        synth,
		store:            store,
		cfg:              cfg,
		policy:           policy,
		acl:              acl,
		ctrlLogic:        ctrlLogic,
		ctrlEngine:       ctrlEngine,
		audit:            rec,
		channelFinderURL: channelFinderURL,
		archiverURL:      archiverURL,
		uiDefaultZoom:    uiDefaultZoom,
		log:              log,
	}
}

// Register mounts all API routes onto mux under the given prefix.
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
	mux.HandleFunc("GET "+prefix+"/me", h.getMe)
	mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
	mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
	mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
	mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
	mux.HandleFunc("GET "+prefix+"/channel-finder", h.channelFinder)
	mux.HandleFunc("GET "+prefix+"/archiver/search", h.archiverSearch)
	mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces)
	mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface)
	mux.HandleFunc("POST "+prefix+"/interfaces/reorder", h.reorderInterfaces)
	mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface)
	mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
	mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
	mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
	mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions", h.listVersions)
	mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions/{version}", h.getVersion)
	mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/versions/{version}/tag", h.setVersionTag)
	mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/promote", h.promoteVersion)
	mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/fork", h.forkVersion)
	// Per-panel ownership / sharing (Phase 2)
	mux.HandleFunc("GET "+prefix+"/interfaces/{id}/acl", h.getPanelACL)
	mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/acl", h.putPanelACL)
	// Panel folders / hierarchy (Phase 3)
	mux.HandleFunc("GET "+prefix+"/folders", h.listFolders)
	mux.HandleFunc("POST "+prefix+"/folders", h.createFolder)
	mux.HandleFunc("PUT "+prefix+"/folders/{id}", h.updateFolder)
	mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
	// User groups defined in config (read-only; for the sharing UI)
	mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
	// Admin pane: runtime access management + server statistics. Every route is
	// gated by CanAdmin (the admins allowlist).
	mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess)
	mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser)
	mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup)
	mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup)
	mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup)
	mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats)
	// Signal group tree
	mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
	mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
	// Synthetic signal CRUD
	mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
	mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
	mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
	mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
	mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
	// Single-shot debug trace of an unsaved synthetic graph (live editor overlay).
	mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic)
	// Synthetic signal git-style versioning (list / view / promote / fork).
	mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions)
	mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
	mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/promote", h.promoteSyntheticVersion)
	mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/fork", h.forkSyntheticVersion)
	// Server-side control logic CRUD (mutations are write-gated by the access
	// middleware; each mutation reloads the running engine).
	mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
	mux.HandleFunc("POST "+prefix+"/controllogic", h.createControlLogic)
	mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
	mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
	mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
	// Control-logic git-style versioning (list / view / promote / fork).
	mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions", h.listControlLogicVersions)
	mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions/{version}", h.getControlLogicVersion)
	mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/promote", h.promoteControlLogicVersion)
	mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/fork", h.forkControlLogicVersion)
	// Configuration manager — config sets (schemas) and instances (values),
	// both versioned git-style. Mutations are write-gated by the access
	// middleware; "apply" writes each value to its target signal.
	mux.HandleFunc("GET "+prefix+"/config/sets", h.listConfigSets)
	mux.HandleFunc("POST "+prefix+"/config/sets", h.createConfigSet)
	mux.HandleFunc("GET "+prefix+"/config/sets/{id}", h.getConfigSet)
	mux.HandleFunc("PUT "+prefix+"/config/sets/{id}", h.updateConfigSet)
	mux.HandleFunc("DELETE "+prefix+"/config/sets/{id}", h.deleteConfigSet)
	mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions", h.listConfigSetVersions)
	mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions/{version}", h.getConfigSetVersion)
	mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/promote", h.promoteConfigSet)
	mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/fork", h.forkConfigSet)
	mux.HandleFunc("POST "+prefix+"/config/sets/{id}/snapshot", h.snapshotConfigSet)
	mux.HandleFunc("GET "+prefix+"/config/sets/diff", h.diffConfigSets)
	mux.HandleFunc("GET "+prefix+"/config/instances", h.listConfigInstances)
	mux.HandleFunc("POST "+prefix+"/config/instances", h.createConfigInstance)
	mux.HandleFunc("GET "+prefix+"/config/instances/{id}", h.getConfigInstance)
	mux.HandleFunc("PUT "+prefix+"/config/instances/{id}", h.updateConfigInstance)
	mux.HandleFunc("DELETE "+prefix+"/config/instances/{id}", h.deleteConfigInstance)
	mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions", h.listConfigInstanceVersions)
	mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions/{version}", h.getConfigInstanceVersion)
	mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/promote", h.promoteConfigInstance)
	mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/fork", h.forkConfigInstance)
	mux.HandleFunc("POST "+prefix+"/config/instances/{id}/apply", h.applyConfigInstance)
	mux.HandleFunc("POST "+prefix+"/config/instances/{id}/validate", h.validateConfigInstance)
	mux.HandleFunc("GET "+prefix+"/config/instances/{id}/livediff", h.diffConfigInstanceLive)
	mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances)
	// Config rules — CUE validation/transformation logic bound to a set, run
	// when instances of that set are saved. Versioned git-style; "check"
	// evaluates an unsaved source for the live editor.
	mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
	mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
	mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
	mux.HandleFunc("POST "+prefix+"/config/rules/preview", h.previewConfigRule)
	mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
	mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
	mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
	mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions", h.listConfigRuleVersions)
	mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions/{version}", h.getConfigRuleVersion)
	mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/promote", h.promoteConfigRule)
	mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/fork", h.forkConfigRule)
}

// ── /me ─────────────────────────────────────────────────────────────────────

// getMe reports the caller's resolved identity, global access level, and group
// memberships. The frontend uses this to tailor the UI (hide edit affordances
// for read-only users, block no-access users). Always reachable, even for
// no-access users, so the UI can render an appropriate blocked state.
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
	user := access.UserFrom(r.Context())
	groups := h.policy.GroupsOf(user)
	if groups == nil {
		groups = []string{}
	}
	defaultZoom := h.uiDefaultZoom
	if defaultZoom <= 0 {
		defaultZoom = 1.0
	}
	jsonOK(w, map[string]any{
		"user":         user,
		"level":        h.policy.Level(user).String(),
		"groups":       groups,
		"canEditLogic": h.policy.CanEditLogic(user),
		"canViewAudit": h.policy.CanViewAudit(user),
		"canAdmin":     h.policy.CanAdmin(user),
		"defaultZoom":  defaultZoom,
	})
}

// ── /audit ────────────────────────────────────────────────────────────────────

// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
	if !h.policy.CanViewAudit(caller(r)) {
		jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
		return
	}
	q := r.URL.Query()
	f := audit.Filter{
		Actor:  q.Get("user"),
		Action: q.Get("action"),
		DS:     q.Get("ds"),
		Signal: q.Get("signal"),
	}
	if v := q.Get("start"); v != "" {
		t, err := time.Parse(time.RFC3339, v)
		if err != nil {
			jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
			return
		}
		f.Start = t
	}
	if v := q.Get("end"); v != "" {
		t, err := time.Parse(time.RFC3339, v)
		if err != nil {
			jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
			return
		}
		f.End = t
	}
	if v := q.Get("limit"); v != "" {
		if n, err := strconv.Atoi(v); err == nil {
			f.Limit = n
		}
	}
	events, err := h.audit.Query(f)
	if err != nil {
		h.log.Error("audit query", "err", err)
		jsonError(w, http.StatusInternalServerError, err.Error())
		return
	}
	jsonOK(w, events)
}

// ── access helpers ────────────────────────────────────────────────────────────

// caller returns the resolved end-user identity for the request (set by the
// access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }

// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		if i := strings.IndexByte(xff, ','); i >= 0 {
			return strings.TrimSpace(xff[:i])
		}
		return strings.TrimSpace(xff)
	}
	if xr := r.Header.Get("X-Real-IP"); xr != "" {
		return strings.TrimSpace(xr)
	}
	if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
		return host
	}
	return r.RemoteAddr
}

// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
	h.audit.Record(audit.Event{
		Actor:     caller(r),
		ActorType: audit.ActorUser,
		Action:    action,
		Detail:    detail,
		IP:        clientIP(r),
		Outcome:   audit.OutcomeOK,
	})
}

// capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users
// get nothing.
func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
	switch h.policy.Level(user) {
	case access.LevelNone:
		return panelacl.PermNone
	case access.LevelRead:
		if p > panelacl.PermRead {
			return panelacl.PermRead
		}
	}
	return p
}

// panelPerm returns the caller's effective permission on a panel. A request with
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
// so it is governed purely by the global level: full write on an unconfigured
// (open) policy, read-only once roles are configured.
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
	user := caller(r)
	if user == "" {
		return h.capByGlobal("", panelacl.PermWrite)
	}
	return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
}

// folderPerm returns the caller's effective permission on a folder. As with
// panelPerm, an unidentified caller is governed by the global level (full write
// on an unconfigured/open policy, read-only once roles are configured).
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
	user := caller(r)
	if user == "" {
		return h.capByGlobal("", panelacl.PermWrite)
	}
	return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
}

// ── /datasources ──────────────────────────────────────────────────────────────

type dsInfo struct {
	Name   string `json:"name"`
	Status string `json:"status"`
}

func (h *Handler) listDataSources(w http.ResponseWriter, r *http.Request) {
	sources := h.broker.DataSources()
	out := make([]dsInfo, len(sources))
	for i, ds := range sources {
		out[i] = dsInfo{Name: ds.Name(), Status: "connected"}
	}
	jsonOK(w, out)
}

// ── /signals ──────────────────────────────────────────────────────────────────

type signalInfo struct {
	Name        string            `json:"name"`
	Type        string            `json:"type"`
	Unit        string            `json:"unit,omitempty"`
	Description string            `json:"description,omitempty"`
	DisplayLow  float64           `json:"displayLow"`
	DisplayHigh float64           `json:"displayHigh"`
	Writable    bool              `json:"writable"`
	EnumStrings []string          `json:"enumStrings,omitempty"`
	Properties  map[string]string `json:"properties,omitempty"`
	Tags        []string          `json:"tags,omitempty"`
}

func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
	dsName := r.URL.Query().Get("ds")
	if dsName == "" {
		jsonError(w, http.StatusBadRequest, "query parameter 'ds' is required")
		return
	}

	// Synthetic signals carry per-caller visibility rules, so build their list
	// from the definitions (filtered) rather than the generic metadata path.
	if dsName == "synthetic" && h.synthetic != nil {
		user := caller(r)
		panel := r.URL.Query().Get("panel")
		userGroups := h.policy.GroupsOf(user)
		metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
			return synVisible(d, user, panel, userGroups)
		})
		out := make([]signalInfo, len(metas))
		for i, m := range metas {
			out[i] = metaToSignalInfo(m)
		}
		jsonOK(w, out)
		return
	}

	ds, ok := h.broker.Source(dsName)
	if !ok {
		jsonError(w, http.StatusNotFound, "unknown data source: "+dsName)
		return
	}

	metas, err := ds.ListSignals(r.Context())
	if err != nil {
		h.log.Error("list signals", "ds", dsName, "err", err)
		jsonError(w, http.StatusInternalServerError, err.Error())
		return
	}

	out := make([]signalInfo, len(metas))
	for i, m := range metas {
		out[i] = metaToSignalInfo(m)
	}
	jsonOK(w, out)
}

// synVisible reports whether a synthetic signal should be listed for the given
// caller (a member of userGroups) while editing the given panel. An empty
// Visibility is treated as "global" so legacy definitions remain visible
// everywhere.
func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool {
	switch d.Visibility {
	case "user":
		return user != "" && d.Owner == user
	case "group":
		return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
	case "panel":
		return panel != "" && d.Panel == panel
	default: // "global" or legacy empty
		return true
	}
}

// ── /signals/search ───────────────────────────────────────────────────────────

func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
	q := strings.ToLower(r.URL.Query().Get("q"))
	dsName := r.URL.Query().Get("ds")

	var sources []datasource.DataSource
	if dsName != "" {
		ds, ok := h.broker.Source(dsName)
		if !ok {
			jsonError(w, http.StatusNotFound, "unknown data source: "+dsName)
			return
		}
		sources = []datasource.DataSource{ds}
	} else {
		sources = h.broker.DataSources()
	}

	user := caller(r)
	panel := r.URL.Query().Get("panel")
	userGroups := h.policy.GroupsOf(user)

	var out []signalInfo
	for _, ds := range sources {
		var metas []datasource.Metadata
		if ds.Name() == "synthetic" && h.synthetic != nil {
			metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
				return synVisible(d, user, panel, userGroups)
			})
		} else {
			var err error
			metas, err = ds.ListSignals(r.Context())
			if err != nil {
				continue
			}
		}
		for _, m := range metas {
			if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) {
				si := metaToSignalInfo(m)
				out = append(out, si)
			}
		}
	}
	if out == nil {
		out = []signalInfo{}
	}
	jsonOK(w, out)
}

// ── /channel-finder ───────────────────────────────────────────────────────────

// channelFinder proxies a query to the EPICS Channel Finder service when one
// is configured, returning a flat list of PV names. Returns 501 otherwise.
func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query().Get("q")

	// Empty-query availability probe (used by the signal tree to decide whether
	// to surface Channel Finder UI). Always respond 200 so an unconfigured CFS
	// is not logged as a failed request in the browser console.
	if q == "" && (r.URL.RawQuery == "" || r.URL.RawQuery == "q=") {
		jsonOK(w, map[string]bool{"available": h.channelFinderURL != ""})
		return
	}

	if h.channelFinderURL == "" {
		jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)")
		return
	}

	cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels"
	if q != "" {
		cfURL += "?~name=*" + q + "*"
	} else {
		// Structured query (e.g. ~name=, ~tag=, property filters) passed through.
		cfURL += "?" + r.URL.RawQuery
	}

	resp, err := http.Get(cfURL) //nolint:noctx
	if err != nil {
		jsonError(w, http.StatusBadGateway, "Channel Finder request failed: "+err.Error())
		return
	}
	defer resp.Body.Close()

	// Channel Finder returns JSON array of channel objects; extract names.
	var channels []struct {
		Name string `json:"name"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil {
		jsonError(w, http.StatusBadGateway, "Channel Finder response parse error: "+err.Error())
		return
	}

	names := make([]string, 0, len(channels))
	for _, ch := range channels {
		if ch.Name != "" {
			names = append(names, ch.Name)
		}
	}
	jsonOK(w, names)
}

// ── /archiver/search ──────────────────────────────────────────────────────────

// archiverSearch proxies a search query to the EPICS Archive Appliance
// management API when configured. Returns 501 otherwise.
func (h *Handler) archiverSearch(w http.ResponseWriter, r *http.Request) {
	if h.archiverURL == "" {
		jsonError(w, http.StatusNotImplemented, "Archiver not configured (set archive_url in config)")
		return
	}

	pattern := r.URL.Query().Get("q")
	if pattern == "" {
		pattern = "*"
	}
	// AA expects glob patterns. If no glob characters, wrap in stars.
	if !strings.ContainsAny(pattern, "*?[]") {
		pattern = "*" + pattern + "*"
	}

	searchURL := strings.TrimRight(h.archiverURL, "/") + "/mgmt/bpl/getAllPVs?pv=" + url.QueryEscape(pattern)
	resp, err := http.Get(searchURL) //nolint:noctx
	if err != nil {
		jsonError(w, http.StatusBadGateway, "Archiver request failed: "+err.Error())
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		jsonError(w, http.StatusBadGateway, "Archiver returned status "+resp.Status)
		return
	}

	var pvs []string
	if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil {
		jsonError(w, http.StatusBadGateway, "Archiver response parse error: "+err.Error())
		return
	}

	jsonOK(w, pvs)
}

// ── /groups ───────────────────────────────────────────────────────────────────

// getGroups returns the workspace group tree as a JSON array.
func (h *Handler) getGroups(w http.ResponseWriter, r *http.Request) {
	data, err := h.store.ReadGroups()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_, _ = w.Write(data)
}

// putGroups replaces the workspace group tree. The body must be a JSON array.
func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB max
	if err != nil {
		http.Error(w, "read error", http.StatusBadRequest)
		return
	}
	// Validate it is a JSON array (basic sanity check).
	var raw []json.RawMessage
	if err := json.Unmarshal(body, &raw); err != nil {
		http.Error(w, "body must be a JSON array", http.StatusBadRequest)
		return
	}
	if err := h.store.WriteGroups(body); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ── /interfaces ───────────────────────────────────────────────────────────────

// interfaceListItem enriches the stored metadata with the panel's owner,
// containing folder, and the caller's effective permission, so the frontend can
// render sharing affordances and filter the visible set.
type interfaceListItem struct {
	storage.InterfaceMeta
	Owner  string   `json:"owner,omitempty"`
	Folder string   `json:"folder,omitempty"`
	Order  float64  `json:"order,omitempty"`
	Perm   string   `json:"perm"`
	Scope  string   `json:"scope,omitempty"`  // derived visibility bucket: private|group|global
	Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with
}

// panelScope derives a uniform visibility token (and, for group scope, the
// shared group names) from a panel's ACL record so the selector tree can bucket
// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel
// (nil record) or any panel exposed publicly is global; otherwise a panel shared
// with one or more user-groups is group-scoped; everything else is private.
func panelScope(acl *panelacl.PanelACL) (string, []string) {
	if acl == nil || acl.Public != "" {
		return access.ScopeGlobal, nil
	}
	var groups []string
	for _, g := range acl.Grants {
		if g.Kind == "group" {
			groups = append(groups, g.Name)
		}
	}
	if len(groups) > 0 {
		return access.ScopeGroup, groups
	}
	return access.ScopePrivate, nil
}

func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
	list, err := h.store.List()
	if err != nil {
		h.log.Error("list interfaces", "err", err)
		jsonError(w, http.StatusInternalServerError, err.Error())
		return
	}
	out := make([]interfaceListItem, 0, len(list))
	for _, m := range list {
		perm := h.panelPerm(r, m.ID)
		if perm < panelacl.PermRead {
			continue // hide panels the caller cannot see
		}
		item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
		acl := h.acl.GetPanel(m.ID)
		if acl != nil {
			item.Owner = acl.Owner
			item.Folder = acl.Folder
			item.Order = acl.Order
		}
		item.Scope, item.Groups = panelScope(acl)
		out = append(out, item)
	}
	h.log.Info("list interfaces", "count", len(out))
	jsonOK(w, out)
}

func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
		return
	}
	// Editing panel logic may be restricted to an allowlist. A brand-new panel
	// carrying a non-empty <logic> block requires that permission.
	if !h.policy.CanEditLogic(caller(r)) && extractLogicBlock(body) != "" {
		jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
		return
	}
	id, err := h.store.Create(body, r.URL.Query().Get("tag"))
	if err != nil {
		h.log.Error("create interface", "err", err)
		jsonError(w, http.StatusBadRequest, err.Error())
		return
	}
	// Record ownership so the new panel defaults to private (owner-only). Skip
	// for unidentified callers (trusted LAN): an owner-less record would make the
	// panel invisible to everyone.
	if user := caller(r); user != "" {
		if err := h.acl.CreatePanel(id, user); err != nil {
			h.log.Error("record panel ownership", "id", id, "err", err)
		}
	}
	h.recordMutation(r, "interface.create", id)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
}

// reorderInterfaces places a set of panels into a folder (or the root when folder
// is empty) in the given order, stamping each id with order=index. The caller must
// have write access to the destination folder (if any) and to each panel.
func (h *Handler) reorderInterfaces(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Folder string   `json:"folder"`
		IDs    []string `json:"ids"`
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "decode body: "+err.Error())
		return
	}
	if req.Folder != "" {
		if _, err := h.acl.GetFolder(req.Folder); err != nil {
			jsonError(w, http.StatusNotFound, "folder not found: "+req.Folder)
			return
		}
		if h.folderPerm(r, req.Folder) < panelacl.PermWrite {
			jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
			return
		}
	}
	for _, id := range req.IDs {
		if h.panelPerm(r, id) < panelacl.PermWrite {
			jsonError(w, http.StatusForbidden, "you do not have write access to panel: "+id)
			return
		}
	}
	for i, id := range req.IDs {
		if err := h.acl.PlacePanel(id, req.Folder, float64(i)); err != nil {
			h.log.Error("reorder interface", "id", id, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
			return
		}
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermRead {
		jsonError(w, http.StatusNotFound, "interface not found: "+id)
		return
	}
	data, err := h.store.Get(id)
	if err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "interface not found: "+id)
		} else {
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	w.Header().Set("Content-Type", "application/xml")
	_, _ = w.Write(data)
}

func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
		return
	}
	// Editing panel logic may be restricted to an allowlist. Permit unrestricted
	// edits to the rest of the panel as long as the <logic> block is unchanged.
	if !h.policy.CanEditLogic(caller(r)) {
		if cur, err := h.store.Get(id); err != nil || extractLogicBlock(body) != extractLogicBlock(cur) {
			jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
			return
		}
	}
	if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "interface not found: "+id)
		} else {
			h.log.Error("update interface", "id", id, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	h.recordMutation(r, "interface.update", id)
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermRead {
		jsonError(w, http.StatusNotFound, "interface not found: "+id)
		return
	}
	versions, err := h.store.Versions(id)
	if err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "interface not found: "+id)
		} else {
			h.log.Error("list versions", "id", id, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	jsonOK(w, versions)
}

func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermRead {
		jsonError(w, http.StatusNotFound, "interface not found: "+id)
		return
	}
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	data, err := h.store.GetVersion(id, version)
	if err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "version not found")
		} else {
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	w.Header().Set("Content-Type", "application/xml")
	_, _ = w.Write(data)
}

func (h *Handler) setVersionTag(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
		return
	}
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	if err := h.store.SetVersionTag(id, version, r.URL.Query().Get("tag")); err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "version not found")
		} else {
			h.log.Error("set version tag", "id", id, "version", version, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) promoteVersion(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
		return
	}
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	if err := h.store.Promote(id, version); err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "version not found")
		} else {
			h.log.Error("promote version", "id", id, "version", version, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) forkVersion(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermRead {
		jsonError(w, http.StatusNotFound, "interface not found: "+id)
		return
	}
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	newID, err := h.store.Fork(id, version)
	if err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "version not found")
		} else {
			h.log.Error("fork version", "id", id, "version", version, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	// The fork is a brand-new panel owned by the caller.
	if user := caller(r); user != "" {
		if err := h.acl.CreatePanel(newID, user); err != nil {
			h.log.Error("record fork ownership", "id", newID, "err", err)
		}
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}

func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
		return
	}
	if err := h.store.Delete(id); err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "interface not found: "+id)
		} else {
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	if err := h.acl.DeletePanel(id); err != nil {
		h.log.Error("delete panel ACL", "id", id, "err", err)
	}
	h.recordMutation(r, "interface.delete", id)
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.panelPerm(r, id) < panelacl.PermRead {
		jsonError(w, http.StatusNotFound, "interface not found: "+id)
		return
	}
	newID, err := h.store.Clone(id)
	if err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "interface not found: "+id)
		} else {
			h.log.Error("clone interface", "id", id, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	// The clone is a brand-new panel owned by the caller.
	if user := caller(r); user != "" {
		if err := h.acl.CreatePanel(newID, user); err != nil {
			h.log.Error("record clone ownership", "id", newID, "err", err)
		}
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}

// ── /interfaces/{id}/acl (Phase 2) ──────────────────────────────────────────

// panelACLRequest is the body accepted by putPanelACL: the panel's folder,
// public level, and explicit user/group grants. Owner is immutable here.
type panelACLRequest struct {
	Folder string           `json:"folder"`
	Public string           `json:"public"`
	Grants []panelacl.Grant `json:"grants"`
}

// panelACLResponse is the sharing record returned to the frontend, enriched
// with the caller's own effective permission.
type panelACLResponse struct {
	Owner  string           `json:"owner"`
	Folder string           `json:"folder"`
	Public string           `json:"public"`
	Grants []panelacl.Grant `json:"grants"`
	Perm   string           `json:"perm"`
}

// getPanelACL returns the sharing settings for a panel. Any user who can see
// the panel (read) may inspect them.
func (h *Handler) getPanelACL(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	perm := h.panelPerm(r, id)
	if perm < panelacl.PermRead {
		jsonError(w, http.StatusNotFound, "interface not found: "+id)
		return
	}
	resp := panelACLResponse{Grants: []panelacl.Grant{}, Perm: perm.String()}
	if acl := h.acl.GetPanel(id); acl != nil {
		resp.Owner = acl.Owner
		resp.Folder = acl.Folder
		resp.Public = acl.Public
		if acl.Grants != nil {
			resp.Grants = acl.Grants
		}
	}
	jsonOK(w, resp)
}

// putPanelACL replaces a panel's sharing settings. Only the panel owner may do
// this (an unmanaged legacy panel is adopted by the first writer to set an ACL).
func (h *Handler) putPanelACL(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	user := caller(r)
	// The panel must exist in storage.
	if _, err := h.store.Get(id); err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "interface not found: "+id)
		} else {
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	// Read-only / no-access users can never change sharing.
	if h.policy.Level(user) < access.LevelWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access")
		return
	}
	owner := user
	if acl := h.acl.GetPanel(id); acl != nil && acl.Owner != "" {
		if acl.Owner != user {
			jsonError(w, http.StatusForbidden, "only the owner can change sharing")
			return
		}
		owner = acl.Owner
	}
	var req panelACLRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	// A referenced folder must exist.
	if req.Folder != "" {
		if _, err := h.acl.GetFolder(req.Folder); err != nil {
			jsonError(w, http.StatusBadRequest, "unknown folder: "+req.Folder)
			return
		}
	}
	if err := h.acl.SetPanel(id, owner, req.Folder, req.Public, req.Grants); err != nil {
		h.log.Error("set panel ACL", "id", id, "err", err)
		jsonError(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ── /folders (Phase 3) ──────────────────────────────────────────────────────

// folderItem is a folder enriched with the caller's effective permission.
type folderItem struct {
	panelacl.Folder
	Perm string `json:"perm"`
}

// folderRequest is the body accepted by create/updateFolder.
type folderRequest struct {
	Name   string           `json:"name"`
	Parent string           `json:"parent"`
	Public string           `json:"public"`
	Grants []panelacl.Grant `json:"grants"`
}

// listFolders returns every folder the caller can at least read.
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request) {
	folders := h.acl.Folders()
	out := make([]folderItem, 0, len(folders))
	for id, f := range folders {
		perm := h.folderPerm(r, id)
		if perm < panelacl.PermRead {
			continue
		}
		if f.Grants == nil {
			f.Grants = []panelacl.Grant{}
		}
		out = append(out, folderItem{Folder: f, Perm: perm.String()})
	}
	jsonOK(w, out)
}

// createFolder creates a new folder owned by the caller. Creating a subfolder
// requires write access on the parent.
func (h *Handler) createFolder(w http.ResponseWriter, r *http.Request) {
	user := caller(r)
	if h.policy.Level(user) < access.LevelWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access")
		return
	}
	var req folderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Name) == "" {
		jsonError(w, http.StatusBadRequest, "folder name is required")
		return
	}
	if req.Parent != "" && h.folderPerm(r, req.Parent) < panelacl.PermWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access to the parent folder")
		return
	}
	f, err := h.acl.CreateFolder(genID("fld"), req.Name, req.Parent, user)
	if err != nil {
		if errors.Is(err, panelacl.ErrNotFound) {
			jsonError(w, http.StatusBadRequest, "unknown parent folder: "+req.Parent)
		} else {
			h.log.Error("create folder", "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(f)
}

// updateFolder replaces a folder's mutable fields. Requires write access on the
// folder (owners always have it).
func (h *Handler) updateFolder(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.folderPerm(r, id) < panelacl.PermWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
		return
	}
	var req folderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Name) == "" {
		jsonError(w, http.StatusBadRequest, "folder name is required")
		return
	}
	if err := h.acl.UpdateFolder(id, req.Name, req.Parent, req.Public, req.Grants); err != nil {
		switch {
		case errors.Is(err, panelacl.ErrNotFound):
			jsonError(w, http.StatusNotFound, "folder not found: "+id)
		default:
			jsonError(w, http.StatusBadRequest, err.Error())
		}
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// deleteFolder removes a folder, reparenting its contents. Requires write
// access on the folder.
func (h *Handler) deleteFolder(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if h.folderPerm(r, id) < panelacl.PermWrite {
		jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
		return
	}
	if err := h.acl.DeleteFolder(id); err != nil {
		if errors.Is(err, panelacl.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "folder not found: "+id)
		} else {
			h.log.Error("delete folder", "id", id, "err", err)
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ── /usergroups ─────────────────────────────────────────────────────────────

// listUserGroups returns the names of the user-groups defined in config, for
// populating the sharing UI's group picker.
func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
	names := h.policy.GroupNames()
	if names == nil {
		names = []string{}
	}
	jsonOK(w, names)
}

// ── /admin ──────────────────────────────────────────────────────────────────

// requireAdmin writes a 403 and returns false unless the caller may use the
// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins
// allowlist is configured, everyone, are permitted.
func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
	if h.policy.CanAdmin(caller(r)) {
		return true
	}
	jsonError(w, http.StatusForbidden, "you are not permitted to administer this server")
	return false
}

// getAdminAccess returns the full mutable access configuration (users, groups,
// allowlists) for the admin pane.
func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) {
	if !h.requireAdmin(w, r) {
		return
	}
	jsonOK(w, h.policy.Snapshot())
}

type adminUserReq struct {
	// Roles maps each group the user should belong to → their role token. The
	// user is removed from any group not listed. Unknown groups are created.
	Roles map[string]string `json:"roles"`
}

// putAdminUser replaces a user's full set of (group → role) memberships.
func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) {
	if !h.requireAdmin(w, r) {
		return
	}
	user := strings.TrimSpace(r.PathValue("user"))
	if user == "" {
		jsonError(w, http.StatusBadRequest, "empty user")
		return
	}
	var req adminUserReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	roles := make(map[string]access.Role, len(req.Roles))
	for g, token := range req.Roles {
		if g = strings.TrimSpace(g); g != "" {
			roles[g] = access.ParseRole(token)
		}
	}
	if err := h.policy.SetUserRoles(user, roles); err != nil {
		jsonError(w, http.StatusInternalServerError, err.Error())
		return
	}
	h.recordMutation(r, "admin.user", user)
	jsonOK(w, h.policy.Snapshot())
}

type adminGroupReq struct {
	Name    string            `json:"name"`   // for update: the (possibly new) group name
	Parent  string            `json:"parent"` // parent group for nesting ("" = root)
	Members map[string]string `json:"members"`
}

// createAdminGroup creates an empty group with an optional parent.
func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) {
	if !h.requireAdmin(w, r) {
		return
	}
	var req adminGroupReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Name) == "" {
		jsonError(w, http.StatusBadRequest, "empty group name")
		return
	}
	if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil {
		jsonError(w, http.StatusBadRequest, err.Error())
		return
	}
	h.recordMutation(r, "admin.group.create", req.Name)
	w.WriteHeader(http.StatusCreated)
	jsonOK(w, h.policy.Snapshot())
}

// updateAdminGroup renames a group (when the body name differs from the path)
// and replaces its parent and member roles.
func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) {
	if !h.requireAdmin(w, r) {
		return
	}
	old := strings.TrimSpace(r.PathValue("name"))
	var req adminGroupReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	name := strings.TrimSpace(req.Name)
	if name == "" {
		name = old
	}
	if name != old {
		if err := h.policy.RenameGroup(old, name); err != nil {
			if errors.Is(err, access.ErrNotFound) {
				jsonError(w, http.StatusNotFound, "group not found: "+old)
				return
			}
			jsonError(w, http.StatusBadRequest, err.Error())
			return
		}
	}
	members := make(map[string]access.Role, len(req.Members))
	for u, token := range req.Members {
		if u = strings.TrimSpace(u); u != "" {
			members[u] = access.ParseRole(token)
		}
	}
	if err := h.policy.SetGroup(name, req.Parent, members); err != nil {
		if errors.Is(err, access.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "group not found: "+name)
			return
		}
		jsonError(w, http.StatusBadRequest, err.Error())
		return
	}
	h.recordMutation(r, "admin.group.update", name)
	jsonOK(w, h.policy.Snapshot())
}

// deleteAdminGroup removes a group (members keep their other groups; children
// are reparented). The built-in public group cannot be deleted.
func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) {
	if !h.requireAdmin(w, r) {
		return
	}
	name := strings.TrimSpace(r.PathValue("name"))
	if err := h.policy.DeleteGroup(name); err != nil {
		if errors.Is(err, access.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "group not found: "+name)
			return
		}
		jsonError(w, http.StatusBadRequest, err.Error())
		return
	}
	h.recordMutation(r, "admin.group.delete", name)
	jsonOK(w, h.policy.Snapshot())
}

// getAdminStats reports live server statistics: the in-process metrics counters,
// observed signals and data sources from the broker, Go runtime stats, and the
// system load average on Linux.
func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) {
	if !h.requireAdmin(w, r) {
		return
	}
	m := metrics.Snapshot()
	var ms runtime.MemStats
	runtime.ReadMemStats(&ms)
	sources := h.broker.DataSources()
	dsNames := make([]string, len(sources))
	for i, ds := range sources {
		dsNames[i] = ds.Name()
	}
	jsonOK(w, map[string]any{
		"uptimeSeconds":   m.UptimeSeconds,
		"wsConnections":   m.WsConnections,
		"observedSignals": h.broker.ActiveSubscriptions(),
		"msgIn":           m.MsgIn,
		"msgOut":          m.MsgOut,
		"writeOps":        m.WriteOps,
		"historyReqs":     m.HistoryReqs,
		"goroutines":      runtime.NumGoroutine(),
		"memAllocBytes":   ms.Alloc,
		"memSysBytes":     ms.Sys,
		"heapInuseBytes":  ms.HeapInuse,
		"loadAvg":         readLoadAvg(),
		"dataSources":     dsNames,
	})
}

// readLoadAvg returns the 1/5/15-minute system load averages from
// /proc/loadavg, or nil on non-Linux systems or any read/parse error.
func readLoadAvg() []float64 {
	data, err := os.ReadFile("/proc/loadavg")
	if err != nil {
		return nil
	}
	fields := strings.Fields(string(data))
	if len(fields) < 3 {
		return nil
	}
	out := make([]float64, 3)
	for i := 0; i < 3; i++ {
		v, err := strconv.ParseFloat(fields[i], 64)
		if err != nil {
			return nil
		}
		out[i] = v
	}
	return out
}

// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
func genID(prefix string) string {
	var b [8]byte
	_, _ = rand.Read(b[:])
	return prefix + "-" + hex.EncodeToString(b[:])
}

// ── /synthetic ────────────────────────────────────────────────────────────────

func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) {
	if h.synthetic == nil {
		jsonOK(w, []any{})
		return
	}
	jsonOK(w, h.synthetic.GetDefs())
}

func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	var def synthetic.SignalDef
	if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	// Stamp ownership from the trusted identity; clients cannot spoof it.
	def.Owner = caller(r)
	if def.Visibility == "" {
		def.Visibility = "panel"
	}
	if err := h.synthetic.AddSignal(def); err != nil {
		if errors.Is(err, datasource.ErrNotFound) {
			jsonError(w, http.StatusConflict, err.Error())
		} else {
			jsonError(w, http.StatusBadRequest, err.Error())
		}
		return
	}
	w.WriteHeader(http.StatusCreated)
	jsonOK(w, def)
}

func (h *Handler) getSynthetic(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	name := r.PathValue("name")
	def, err := h.synthetic.GetSignal(name)
	if err != nil {
		if errors.Is(err, datasource.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
		} else {
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	jsonOK(w, def)
}

func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	name := r.PathValue("name")
	var def synthetic.SignalDef
	if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	// Ensure the name in the URL matches the body (or fill it in).
	def.Name = name
	// Preserve the original owner across edits; never trust a client-supplied one.
	if existing, err := h.synthetic.GetSignal(name); err == nil {
		def.Owner = existing.Owner
	}
	if err := h.synthetic.UpdateSignal(def); err != nil {
		if errors.Is(err, datasource.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
		} else {
			jsonError(w, http.StatusBadRequest, err.Error())
		}
		return
	}
	jsonOK(w, def)
}

func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	name := r.PathValue("name")
	if err := h.synthetic.RemoveSignal(name); err != nil {
		if errors.Is(err, datasource.ErrNotFound) {
			jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
		} else {
			jsonError(w, http.StatusInternalServerError, err.Error())
		}
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// traceSynthetic evaluates an unsaved synthetic graph once against the current
// live value of each source signal and returns every node's computed value. It
// persists nothing; it powers the editor's live/debug overlay.
func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	var def synthetic.SignalDef
	if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()
	read := func(ds, name string) (any, error) {
		v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name})
		if err != nil {
			return nil, err
		}
		return v.Data, nil
	}
	res, err := h.synthetic.Trace(def, read)
	if err != nil {
		jsonError(w, http.StatusBadRequest, err.Error())
		return
	}
	jsonOK(w, res)
}

func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	name := r.PathValue("name")
	versions, err := h.synthetic.Versions(name)
	if err != nil {
		jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
		return
	}
	jsonOK(w, versions)
}

func (h *Handler) getSyntheticVersion(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	name := r.PathValue("name")
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	def, err := h.synthetic.GetVersion(name, version)
	if err != nil {
		jsonError(w, http.StatusNotFound, "version not found")
		return
	}
	jsonOK(w, def)
}

func (h *Handler) promoteSyntheticVersion(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	name := r.PathValue("name")
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	def, err := h.synthetic.PromoteVersion(name, version)
	if err != nil {
		jsonError(w, http.StatusNotFound, "version not found")
		return
	}
	h.recordMutation(r, "synthetic.promote", fmt.Sprintf("%s v%d", name, version))
	jsonOK(w, def)
}

func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
	if h.synthetic == nil {
		jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
		return
	}
	name := r.PathValue("name")
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	def, err := h.synthetic.ForkVersion(name, version)
	if err != nil {
		jsonError(w, http.StatusNotFound, "version not found")
		return
	}
	h.recordMutation(r, "synthetic.fork", fmt.Sprintf("%s v%d -> %s", name, version, def.Name))
	w.WriteHeader(http.StatusCreated)
	jsonOK(w, map[string]string{"id": def.Name})
}

// ── Control logic ──────────────────────────────────────────────────────────────

func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonOK(w, []any{})
		return
	}
	user := caller(r)
	graphs := []controllogic.Graph{}
	for _, g := range h.ctrlLogic.List() {
		if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) {
			graphs = append(graphs, g)
		}
	}
	jsonOK(w, graphs)
}

func (h *Handler) getControlLogic(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	g, err := h.ctrlLogic.Get(r.PathValue("id"))
	if err != nil {
		jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
		return
	}
	jsonOK(w, g)
}

func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	if !h.policy.CanEditLogic(caller(r)) {
		jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
		return
	}
	var g controllogic.Graph
	if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	g.ID = genID("cl")
	g.Owner = caller(r) // stamp ownership from the trusted identity
	if err := h.ctrlLogic.Save(g); err != nil {
		jsonError(w, http.StatusInternalServerError, err.Error())
		return
	}
	h.ctrlEngine.Reload()
	h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
	w.WriteHeader(http.StatusCreated)
	jsonOK(w, g)
}

func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	if !h.policy.CanEditLogic(caller(r)) {
		jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
		return
	}
	id := r.PathValue("id")
	prev, err := h.ctrlLogic.Get(id)
	if err != nil {
		jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
		return
	}
	var g controllogic.Graph
	if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
		return
	}
	g.ID = id
	g.Owner = prev.Owner // owner is immutable across revisions
	if err := h.ctrlLogic.Save(g); err != nil {
		jsonError(w, http.StatusInternalServerError, err.Error())
		return
	}
	h.ctrlEngine.Reload()
	h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
	jsonOK(w, g)
}

func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	if !h.policy.CanEditLogic(caller(r)) {
		jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
		return
	}
	id := r.PathValue("id")
	if err := h.ctrlLogic.Delete(id); err != nil {
		jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
		return
	}
	h.ctrlEngine.Reload()
	h.recordMutation(r, "controllogic.delete", id)
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) listControlLogicVersions(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	versions, err := h.ctrlLogic.Versions(r.PathValue("id"))
	if err != nil {
		jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
		return
	}
	jsonOK(w, versions)
}

func (h *Handler) getControlLogicVersion(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	g, err := h.ctrlLogic.GetVersion(r.PathValue("id"), version)
	if err != nil {
		jsonError(w, http.StatusNotFound, "version not found")
		return
	}
	jsonOK(w, g)
}

func (h *Handler) promoteControlLogicVersion(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	if !h.policy.CanEditLogic(caller(r)) {
		jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
		return
	}
	id := r.PathValue("id")
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	g, err := h.ctrlLogic.Promote(id, version)
	if err != nil {
		jsonError(w, http.StatusNotFound, "version not found")
		return
	}
	h.ctrlEngine.Reload()
	h.recordMutation(r, "controllogic.promote", fmt.Sprintf("%s v%d", id, version))
	jsonOK(w, g)
}

func (h *Handler) forkControlLogicVersion(w http.ResponseWriter, r *http.Request) {
	if h.ctrlLogic == nil {
		jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
		return
	}
	if !h.policy.CanEditLogic(caller(r)) {
		jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
		return
	}
	id := r.PathValue("id")
	version, err := strconv.Atoi(r.PathValue("version"))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
		return
	}
	g, err := h.ctrlLogic.Fork(id, version)
	if err != nil {
		jsonError(w, http.StatusNotFound, "version not found")
		return
	}
	h.ctrlEngine.Reload()
	h.recordMutation(r, "controllogic.fork", fmt.Sprintf("%s v%d -> %s", id, version, g.ID))
	w.WriteHeader(http.StatusCreated)
	jsonOK(w, map[string]string{"id": g.ID})
}

// ── Helpers ───────────────────────────────────────────────────────────────────

// extractLogicBlock returns the verbatim <logic>…</logic> section of an
// interface XML document, or "" when absent. The frontend serializes the block
// only when it holds nodes/wires and uses a stable format, so comparing the
// extracted substrings reliably detects whether the panel logic changed.
func extractLogicBlock(xml []byte) string {
	s := string(xml)
	start := strings.Index(s, "<logic>")
	if start < 0 {
		return ""
	}
	end := strings.Index(s[start:], "</logic>")
	if end < 0 {
		return ""
	}
	return s[start : start+end+len("</logic>")]
}

func metaToSignalInfo(m datasource.Metadata) signalInfo {
	return signalInfo{
		Name:        m.Name,
		Type:        dataTypeName(m.Type),
		Unit:        m.Unit,
		Description: m.Description,
		DisplayLow:  m.DisplayLow,
		DisplayHigh: m.DisplayHigh,
		Writable:    m.Writable,
		EnumStrings: m.EnumStrings,
		Properties:  m.Properties,
		Tags:        m.Tags,
	}
}

func dataTypeName(t datasource.DataType) string {
	switch t {
	case datasource.TypeFloat64:
		return "float64"
	case datasource.TypeFloat64Array:
		return "float64[]"
	case datasource.TypeString:
		return "string"
	case datasource.TypeInt64:
		return "int64"
	case datasource.TypeBool:
		return "bool"
	case datasource.TypeEnum:
		return "enum"
	default:
		return "unknown"
	}
}

func jsonOK(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}

func jsonError(w http.ResponseWriter, code int, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
