package web

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"3588AdminBackend/internal/models"
	"3588AdminBackend/internal/service"
	"3588AdminBackend/internal/storage"
	"github.com/go-chi/chi/v5"
)

type UI struct {
	discovery *service.DiscoveryService
	registry  *service.RegistryService
	agent     *service.AgentClient
	tasks     *service.TaskService
	templates *service.TemplateService
	preview   *service.ConfigPreviewService
	stateRepo *storage.DeviceConfigStateRepo
	auditRepo *storage.AuditLogsRepo
	dbPath    string
	resourcesRepo *storage.ResourcesRepo

	tpl *template.Template
}

const (
	deviceAssignmentPreviewDevicePrefix = "demo-edge-"
	deviceAssignmentPreviewDeviceCount  = 8
)

type PageData struct {
	Title       string
	ContentHTML template.HTML
	Message     string
	Error       string

	DeviceCount  int
	OnlineCount  int
	OfflineCount int
	FoundCount   int

	Devices                  []*models.Device
	DeviceRows               []DeviceOverviewRow
	AttentionDevices         []*models.Device
	Found                    []*models.Device
	Device                   *models.Device
	ConfigStatus             *ConfigStatusView
	ConfigStatusText         string
	ConfigStatusErr          string
	ConfigSources            service.ConfigPreviewSources
	ConfigPreview            *service.ConfigPreviewResult
	ResultTitle              string
	SelectedTemplate         string
	SelectedProfile          string
	SelectedRecognitionUnit  string
	SelectedAssignmentDevice string
	SelectedOverlays         []string
	SelectedConfigID         string
	SelectedVersion          string
	Tasks                    []models.Task
	Task                     *models.Task
	TaskDeviceRows           []TaskDeviceRow
	StandardModels           []storage.StandardModelRecord
	ModelStatusBoard         *service.ModelStatusBoard
	StandardResources        []storage.StandardResourceRecord
	ResourceStatusBoard      *service.ResourceStatusBoard
	Templates                []service.Template
	Template                 *service.Template
	AssetTab                 string
	AssetTemplates           []service.ConfigTemplateAsset
	AssetTemplate            *service.ConfigTemplateAsset
	AssetTemplateEditing     bool
	AssetProfiles            []service.ConfigProfileAsset
	AssetProfile             *service.ConfigProfileAsset
	AssetProfileEditor       *service.ConfigProfileEditor
	AssetProfileEditing      bool
	AssetProfileFormAction   string
	ActiveInstanceIndex      int
	RecognitionUnits         []service.RecognitionUnitAsset
	RecognitionUnit          *service.RecognitionUnitAsset
	RecognitionUnitEditing   bool
	DeviceAssignments        []service.DeviceAssignmentAsset
	DeviceAssignment         *service.DeviceAssignmentAsset
	DeviceAssignmentEditing  bool
	DeviceAssignmentBoard    *service.DeviceAssignmentBoard
	DeviceAssignmentBoardJSON template.JS
	MaxUnitsPerDevice        int
	AssetTemplateMap         map[string]service.ConfigTemplateAsset
	AssetVideoSources        []service.ConfigVideoSourceAsset
	AssetVideoSource         *service.ConfigVideoSourceAsset
	AssetVideoSourceEditing  bool
	AssetIntegrations        []service.ConfigIntegrationServiceAsset
	AssetIntegration         *service.ConfigIntegrationServiceAsset
	AssetIntegrationEditing  bool
	AssetOverlays            []service.ConfigOverlayAsset
	AssetOverlay             *service.ConfigOverlayAsset
	AssetOverlayEditing      bool
	AssetInstanceCount       int
	SelectedDeviceIDs        []string
	SelectedDevices          []*models.Device
	SelectedQuery            string
	SelectedDevicesURL       string
	BatchConfigURL           string
	ReloadSummary            string
	RollbackSummary          string
	TemplateDraftName        string
	TemplateDraftDescription string
	TemplateCloneSource      string
	TemplateCreateMode       string
	OverlayDraftJSON         string
	AuditEntries             []storage.AuditLogRecord
	PersistedConfig          *storage.DeviceConfigStateRecord
	DBPath                   string

	RawJSON          string
	RawText          string
	SchemaJSON       string
	StateJSON        string
	FaceGalleryJSON  string
	TaskID           string
	DeviceIDs        string
	RunningTaskCount int
	FailedTaskCount  int
	SuccessTaskCount int
}

type DeviceOverviewRow struct {
	Device          *models.Device
	ConfigStatus    *ConfigStatusView
	ConfigStatusErr string
}

type TaskDeviceRow struct {
	Device   *models.Device
	Status   models.TaskStatus
	Progress float64
	Error    string
}

type ConfigStatusView struct {
	OK                 bool                      `json:"ok"`
	ConfigPath         string                    `json:"config_path"`
	Exists             bool                      `json:"exists"`
	Sha256             string                    `json:"sha256"`
	Size               int64                     `json:"size"`
	Metadata           ConfigStatusMetadata      `json:"metadata"`
	Candidate          *ConfigStatusLastGoodFile `json:"candidate"`
	MediaServer        ConfigStatusMediaServer   `json:"media_server"`
	PreviousConfig     *ConfigStatusLastGoodFile `json:"previous_config"`
	PreviousConfigPath string                    `json:"previous_config_path"`
}

type ConfigStatusMetadata struct {
	ConfigID            string   `json:"config_id"`
	ConfigVersion       string   `json:"config_version"`
	BusinessName        string   `json:"business_name"`
	Template            string   `json:"template"`
	Profile             string   `json:"profile"`
	Overlays            []string `json:"overlays"`
	RenderedAt          string   `json:"rendered_at"`
	RenderedBy          string   `json:"rendered_by"`
	InstanceName        string   `json:"instance_name"`
	InstanceDisplayName string   `json:"instance_display_name"`
}

type ConfigStatusMediaServer struct {
	Running bool   `json:"running"`
	PID     int    `json:"pid"`
	Version string `json:"version"`
}

type ConfigStatusLastGoodFile struct {
	Path     string               `json:"path"`
	Exists   bool                 `json:"exists"`
	Sha256   string               `json:"sha256"`
	Metadata ConfigStatusMetadata `json:"metadata"`
}

func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService, preview ...*service.ConfigPreviewService) (*UI, error) {
	tpl, err := template.New("layout").Funcs(template.FuncMap{
		"json": func(v any) string {
			b, _ := json.MarshalIndent(v, "", "  ")
			return string(b)
		},
		"rawHTML": func(v string) template.HTML {
			return template.HTML(v)
		},
		"hasString": func(items []string, want string) bool {
			for _, item := range items {
				if item == want {
					return true
				}
			}
			return false
		},
		"shortHash": func(v string) string {
			v = strings.TrimSpace(v)
			if len(v) > 8 {
				return v[:8]
			}
			return v
		},
		"modelTypeLabel": func(v string) string {
			switch strings.TrimSpace(v) {
			case "face_detection":
				return "人脸检测"
			case "face_recognition":
				return "人脸识别"
			case "object_detection":
				return "通用检测"
			case "ppe_detection":
				return "PPE检测"
			case "shoe_detection":
				return "工鞋检测"
			case "other":
				return "其他"
			default:
				return "-"
			}
		},
		"resourceTypeLabel": func(v string) string {
			switch strings.TrimSpace(v) {
			case "face_gallery":
				return "人脸库"
			case "dataset":
				return "数据集"
			case "calibration":
				return "标定文件"
			default:
				return v
			}
		},
		"displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string {
			if dev == nil {
				return "-"
			}
			return dev.DisplayName()
		},
		"displayDeviceTechnicalName": func(dev *models.Device) string {
			if dev == nil {
				return ""
			}
			if v := strings.TrimSpace(dev.TechnicalName()); v != "" {
				return v
			}
			return ""
		},
		"taskGroupLabel": func(v any) string {
			switch fmt.Sprint(v) {
			case "config_apply":
				return "批量配置"
			case "media_start", "media_restart", "media_stop":
				return "批量服务"
			case "reload", "rollback":
				return "设备操作"
		case "resource_sync_one", "resource_sync_all":
			return "资源同步"
			default:
				return "其他任务"
			}
		},
		"taskActionLabel": func(v any) string {
			switch fmt.Sprint(v) {
			case "config_apply":
				return "下发设备分配"
			case "model_sync_one":
				return "更新单个模型"
			case "model_sync_all":
				return "更新全部模型"
		case "resource_sync_one":
			return "同步单个资源"
		case "resource_sync_all":
			return "同步全部资源"
			case "reload":
				return "重载识别服务"
			case "rollback":
				return "回滚识别配置"
			case "media_start":
				return "启动视频分析服务"
			case "media_restart":
				return "重启视频分析服务"
			case "media_stop":
				return "停止视频分析服务"
			default:
				return fmt.Sprint(v)
			}
		},
		"taskGroupClass": func(v any) string {
			switch fmt.Sprint(v) {
			case "config_apply":
				return "pill run"
			case "model_sync_one", "model_sync_all":
				return "pill warn"
		case "resource_sync_one", "resource_sync_all":
			return "pill warn"
			case "media_start", "media_restart", "media_stop":
				return "pill ok"
			case "reload", "rollback":
				return "pill warn"
			default:
				return "pill"
			}
		},
		"taskStatusLabel": func(v any) string {
			switch fmt.Sprint(v) {
			case "success":
				return "成功"
			case "failed":
				return "失败"
			case "running":
				return "执行中"
			default:
				return "待执行"
			}
		},
		"taskStatusClass": func(v any) string {
			switch fmt.Sprint(v) {
			case "success":
				return "pill ok"
			case "failed":
				return "pill bad"
			case "running":
				return "pill run"
			default:
				return "pill"
			}
		},
		"auditField": func(details string, key string) string {
			var m map[string]any
			if err := json.Unmarshal([]byte(details), &m); err != nil {
				return ""
			}
			if v, ok := m[key].(string); ok {
				return strings.TrimSpace(v)
			}
			return ""
		},
		"auditActionLabel": func(v string) string {
			switch strings.TrimSpace(v) {
			case "config_apply":
				return "下发设备分配"
			case "reload":
				return "重载运行配置"
			case "rollback":
				return "回滚运行配置"
			case "media_start":
				return "启动服务"
			case "media_restart":
				return "重启服务"
			case "media_stop":
				return "停止服务"
			default:
				return strings.TrimSpace(v)
			}
		},
		"auditStatusLabel": func(v string) string {
			switch strings.TrimSpace(v) {
			case "success":
				return "成功"
			case "failed":
				return "失败"
			case "running":
				return "执行中"
			case "pending":
				return "待执行"
			default:
				return strings.TrimSpace(v)
			}
		},
		"ago": func(ms int64) string {
			if ms <= 0 {
				return "-"
			}
			d := time.Since(time.UnixMilli(ms))
			if d < 0 {
				d = 0
			}
			s := int64(d.Seconds())
			switch {
			case s < 60:
				return fmt.Sprintf("%d秒前", s)
			case s < 3600:
				return fmt.Sprintf("%d分钟前", s/60)
			case s < 86400:
				return fmt.Sprintf("%d小时前", s/3600)
			default:
				return fmt.Sprintf("%d天前", s/86400)
			}
		},
		"icon": func(name string) template.HTML {
			return template.HTML(tablerIconSVG(name))
		},
		"slotTypeLabel": func(v string) string {
			switch strings.TrimSpace(v) {
			case "video_source":
				return "视频源"
			case "object_storage":
				return "对象存储"
			case "token_service":
				return "认证服务"
			case "alarm_service":
				return "告警服务"
			case "stream_publish":
				return "视频输出"
			default:
				return strings.TrimSpace(v)
			}
		},
		"inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string {
			if len(bindings) == 0 {
				return ""
			}
			return strings.TrimSpace(bindings[slot].VideoSourceRef)
		},
		"serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string {
			if len(bindings) == 0 {
				return ""
			}
			return strings.TrimSpace(bindings[slot].ServiceRef)
		},
		"outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string {
			if len(bindings) == 0 {
				return ""
			}
			item, ok := bindings[slot]
			if !ok {
				return ""
			}
			switch strings.TrimSpace(field) {
			case "publish_hls_path":
				return strings.TrimSpace(item.PublishHLSPath)
			case "publish_rtsp_port":
				return strings.TrimSpace(item.PublishRTSPPort)
			case "publish_rtsp_path":
				return strings.TrimSpace(item.PublishRTSPPath)
			case "channel_no":
				return strings.TrimSpace(item.ChannelNo)
			default:
				return ""
			}
		},
	}).ParseFS(uiFS, "ui/templates/*.html")
	if err != nil {
		return nil, err
	}

	previewSvc := service.NewConfigPreviewService(nil)
	if len(preview) > 0 && preview[0] != nil {
		previewSvc = preview[0]
	}
	return &UI{
		discovery: discovery,
		registry:  registry,
		agent:     agent,
		tasks:     tasks,
		templates: templates,
		preview:   previewSvc,
		tpl:       tpl,
	}, nil
}

func (u *UI) SetStateRepo(repo *storage.DeviceConfigStateRepo) {
	if u == nil {
		return
	}
	u.stateRepo = repo
}

func (u *UI) SetAuditRepo(repo *storage.AuditLogsRepo) {
	if u == nil {
		return
	}
	u.auditRepo = repo
}

func (u *UI) SetDBPath(path string) {
	if u == nil {
		return
	}
	u.dbPath = strings.TrimSpace(path)
}

func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) {
	if u == nil {
		return
	}
	u.resourcesRepo = repo
}

func tablerIconSVG(name string) string {
	icons := map[string]string{
		"devices":   `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="4" width="18" height="12" rx="1"/><path d="M7 20h10"/><path d="M9 16v4"/><path d="M15 16v4"/></svg>`,
		"assets":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l4 -14"/><path d="M16 5l4 14"/><path d="M12 5v14"/><path d="M6 15h12"/></svg>`,
		"audit":     `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 8l0 4l2 2"/><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5"/></svg>`,
		"system":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c.996 .608 2.296 .07 2.572 -1.065z"/><circle cx="12" cy="12" r="3"/></svg>`,
		"theme":     `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3a9 9 0 1 0 9 9a4.5 4.5 0 0 1 -9 -9"/></svg>`,
		"bell":      `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3l2 3h-16l2 -3v-3a7 7 0 0 1 4 -6"/><path d="M9 17v1a3 3 0 0 0 6 0v-1"/></svg>`,
		"online":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5"/></svg>`,
		"detail":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 12h.01"/><path d="M12 12h.01"/><path d="M9 12h.01"/><path d="M5 12a7 7 0 1 0 14 0a7 7 0 0 0 -14 0"/></svg>`,
		"control":   `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 4v16l13 -8z"/></svg>`,
		"device":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M11 4h2"/><path d="M12 17v.01"/></svg>`,
		"status":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4"/></svg>`,
		"config":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6l16 0"/><path d="M4 12l16 0"/><path d="M4 18l16 0"/><path d="M8 6l0 .01"/><path d="M8 12l0 .01"/><path d="M8 18l0 .01"/></svg>`,
		"overview":  `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4h6v8h-6z"/><path d="M14 4h6v5h-6z"/><path d="M14 13h6v7h-6z"/><path d="M4 16h6v4h-6z"/></svg>`,
		"tech":      `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 8l-4 4l4 4"/><path d="M17 8l4 4l-4 4"/><path d="M14 4l-4 16"/></svg>`,
		"preview":   `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5c-7.633 0 -9 7 -9 7s1.367 7 9 7s9 -7 9 -7s-1.367 -7 -9 -7"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/></svg>`,
		"apply":     `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 12l3 3l7 -7"/><path d="M21 12c0 4.97 -4.03 9 -9 9s-9 -4.03 -9 -9s4.03 -9 9 -9"/></svg>`,
		"service":   `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13h5"/><path d="M12 16v5"/><path d="M16 4l0 5"/><path d="M20 8h-5"/><path d="M4 9h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/><path d="M9 4h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 15h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M9 13h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 4h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/></svg>`,
		"task":      `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8"/><path d="M20 12v7a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1h9"/></svg>`,
		"result":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
		"logs":      `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
		"meta":      `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M7 6h10"/><path d="M5 8v9"/><path d="M7 19h10"/><path d="M17 8v9"/></svg>`,
		"template":  `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/></svg>`,
		"edit":      `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 6l3 3"/><path d="M7 18l3.5 -.5l8 -8a2.121 2.121 0 0 0 -3 -3l-8 8l-.5 3.5"/></svg>`,
		"profile":   `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M12 3c2.755 0 5.455 .638 7.407 1.758a2 2 0 0 1 1.002 1.737v11.01a2 2 0 0 1 -1.002 1.737c-1.952 1.12 -4.652 1.758 -7.407 1.758s-5.455 -.638 -7.407 -1.758a2 2 0 0 1 -1.002 -1.737v-11.01a2 2 0 0 1 1.002 -1.737c1.952 -1.12 4.652 -1.758 7.407 -1.758z"/></svg>`,
		"overlay":   `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 3.34l10 5.66v10l-10 -5.66z"/><path d="M17 9l4 -2.26l-10 -5.74l-4 2.26z"/><path d="M7 13l-4 -2.26v-6.74l4 -2.26"/></svg>`,
		"release":   `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7h10v10h-10z"/><path d="M3 7l3 0"/><path d="M18 7l3 0"/><path d="M7 3l0 3"/><path d="M7 18l0 3"/><path d="M17 18l0 3"/><path d="M17 3l0 3"/></svg>`,
		"discovery": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M12 3v4"/><path d="M12 17v4"/><circle cx="12" cy="12" r="3"/><path d="M5.636 5.636l2.828 2.828"/><path d="M15.536 15.536l2.828 2.828"/><path d="M5.636 18.364l2.828 -2.828"/><path d="M15.536 8.464l2.828 -2.828"/></svg>`,
		"shield":    `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3l8 4v5c0 5 -3.5 9.5 -8 11c-4.5 -1.5 -8 -6 -8 -11v-5l8 -4"/></svg>`,
		"heartbeat": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l2 -3l4 6l2 -3h6"/></svg>`,
	}
	if svg, ok := icons[name]; ok {
		return svg
	}
	return ""
}

func (u *UI) Routes() (chi.Router, error) {
	r := chi.NewRouter()

	assets, err := fs.Sub(uiFS, "ui/assets")
	if err != nil {
		return nil, err
	}
	assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets)))
	r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		p := req.URL.Path
		w.Header().Set("Cache-Control", "no-store, max-age=0")
		w.Header().Set("Pragma", "no-cache")
		switch {
		case strings.HasSuffix(p, ".css"):
			w.Header().Set("Content-Type", "text/css; charset=utf-8")
		case strings.HasSuffix(p, ".js"):
			w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
		}
		assetHandler.ServeHTTP(w, req)
	}))

	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/ui/dashboard", http.StatusFound)
	})

	r.Get("/dashboard", u.pageDashboard)
	r.Get("/devices", u.pageDevices)
	r.Get("/devices/{id}/control", u.pageDeviceControl)
	r.Get("/plans", u.redirectPlansToSceneTemplates)
	r.Get("/plans/{name}", u.redirectPlanToSceneTemplate)
	r.Get("/scene-templates", u.pagePlans)
	r.Get("/scene-templates/{name}", u.pagePlan)
	r.Post("/scene-templates/create", u.actionPlanCreate)
	r.Post("/scene-templates/{name}", u.actionPlanSave)
	r.Post("/scene-templates/{name}/delete", u.actionPlanDelete)
	r.Get("/scene-templates/{name}/export", u.pagePlanExport)
	r.Get("/recognition-units", u.pageRecognitionUnits)
	r.Post("/recognition-units", u.actionRecognitionUnitSave)
	r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete)
	r.Get("/device-assignments", u.pageDeviceAssignments)
	r.Post("/device-assignments", u.actionDeviceAssignmentSave)
	r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete)
	r.Get("/assets", u.pageAssets)
	r.Get("/assets/video-sources", u.pageAssetVideoSources)
	r.Post("/assets/video-sources", u.actionAssetVideoSourceSave)
	r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete)
	r.Get("/assets/templates", u.pageAssetTemplates)
	r.Post("/assets/templates/create", u.actionAssetTemplateCreate)
	r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone)
	r.Get("/assets/templates/{name}", u.pageAssetTemplate)
	r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename)
	r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete)
	r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph)
	r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave)
	r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport)
	r.Get("/assets/profiles", u.redirectAssetProfilesToPlans)
	r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan)
	r.Post("/assets/profiles/{name}", u.actionPlanSave)
	r.Get("/assets/profiles/{name}/export", u.pagePlanExport)
	r.Get("/assets/integrations", u.pageAssetIntegrations)
	r.Post("/assets/integrations", u.actionAssetIntegrationSave)
	r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete)
	r.Get("/assets/overlays", u.pageAssetOverlays)
	r.Post("/assets/overlays", u.actionAssetOverlaySave)
	r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete)
	r.Get("/assets/overlays/{name}", u.pageAssetOverlay)
	r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport)
	r.Get("/audit", u.pageAudit)
	r.Get("/system", u.pageSystem)
	r.Get("/system/db-backup", u.pageSystemDBBackup)
	r.Get("/resources", u.pageResources)
	r.Post("/system/db-restore", u.actionSystemDBRestore)
	r.Get("/api/graph-node-types", u.apiGraphNodeTypes)
	r.Get("/device-config", u.pageDeviceConfig)
	r.Get("/device-config/{id}", u.pageDeviceConfigDetail)
	r.Get("/devices-add", u.pageDeviceAdd)
	r.Post("/devices-add", u.actionDeviceAdd)
	r.Post("/devices/batch-action", u.actionDevicesBatchAction)
	r.Get("/devices/batch-config", u.pageDeviceBatchConfig)
	r.Post("/devices/batch-config", u.actionDeviceBatchConfig)
	r.Post("/discovery/search", u.actionDiscoverySearch)
	r.Get("/devices/{id}", u.pageDevice)
	r.Post("/devices/{id}/alias", u.actionDeviceAliasSave)
	r.Post("/devices/{id}/action", u.actionDeviceAction)
	r.Get("/devices/{id}/logs", u.pageDeviceLogs)
	r.Get("/devices/{id}/graphs", u.pageDeviceGraphs)
	r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply)
	r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply)
	r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI)
	r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly)
	r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview)
	r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview)
	r.Post("/devices/{id}/config-candidate", u.actionDeviceConfigCandidate)
	r.Post("/devices/{id}/config-candidate/apply", u.actionDeviceConfigCandidateApply)
	r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan)
	r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply)
	r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload)
	r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload)
	r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload)
	r.Post("/devices/{id}/media-server/configs/upload", u.actionDeviceMediaServerConfigUpload)
	r.Post("/devices/{id}/media-server/configs/upload-batch", u.actionDeviceMediaServerConfigUploadBatch)

	r.Get("/tasks", u.pageTasks)
	r.Post("/tasks", u.actionCreateTask)
	r.Get("/tasks/{id}", u.pageTask)

	r.Get("/templates", u.pageTemplates)
	r.Get("/templates/{name}", u.pageTemplate)
	r.Get("/models", u.pageModels)
	r.Post("/models/sync", u.actionModelSync)
	r.Post("/resources/sync", u.actionResourceSync)
	r.Get("/diagnostics", u.pageDiagnostics)
	r.Get("/recognition", u.pageRecognition)
	r.Get("/logs", u.pageLogs)

	r.Get("/api", u.pageAPIConsole)

	return r, nil
}

func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) {
	var buf bytes.Buffer
	if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	data.ContentHTML = template.HTML(buf.String())

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store, max-age=0")
	w.Header().Set("Pragma", "no-cache")
	if err := u.tpl.ExecuteTemplate(w, "layout", data); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

func (u *UI) findDevice(id string) (*models.Device, bool) {
	u.ensureDevicesLoaded()
	devices := u.registry.GetDevices()
	for _, d := range devices {
		if d.DeviceID == id {
			return d, true
		}
	}
	if u.discovery != nil {
		_, _ = u.discovery.SearchDefault()
		devices = u.registry.GetDevices()
		for _, d := range devices {
			if d.DeviceID == id {
				return d, true
			}
		}
	}
	return nil, false
}

func (u *UI) ensureDevicesLoaded() {
	if u.registry == nil || u.discovery == nil {
		return
	}
	devices := u.registry.GetDevices()
	if len(devices) > 0 {
		// Check if any device is online; if not, try discovery to refresh
		hasOnline := false
		for _, d := range devices {
			if d.Online {
				hasOnline = true
				break
			}
		}
		if hasOnline {
			return
		}
	}
	_, _ = u.discovery.SearchDefault()
	if len(u.registry.GetDevices()) == 0 {
		_, _ = u.discovery.SearchDefault()
	}
}

func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
	data := u.deviceOverviewPageData(r, nil, "")
	if u.tasks != nil {
		for _, task := range u.tasks.ListTasks() {
			switch task.Status {
			case models.TaskRunning:
				data.RunningTaskCount++
			case models.TaskFailed:
				data.FailedTaskCount++
			case models.TaskSuccess:
				data.SuccessTaskCount++
			}
		}
	}
	data.Title = "总览"
	data.Tasks = nil
	if u.tasks != nil {
		data.Tasks = u.tasks.ListTasks()
	}
	data.AttentionDevices = nil
	for _, dev := range data.Devices {
		if dev != nil && !dev.Online {
			data.AttentionDevices = append(data.AttentionDevices, dev)
		}
	}
	u.render(w, r, "dashboard", data)
}

func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
	u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, ""))
}

func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) {
	u.render(w, r, "device_add", PageData{Title: "新增设备"})
}

func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) {
	u.ensureDevicesLoaded()
	u.render(w, r, "device_config", PageData{
		Title:   "设备配置入口",
		Devices: u.registry.GetDevices(),
	})
}

func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
}

func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	deviceID := strings.TrimSpace(r.FormValue("device_id"))
	deviceName := strings.TrimSpace(r.FormValue("device_name"))
	ip := strings.TrimSpace(r.FormValue("ip"))
	agentPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("agent_port")))
	mediaPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("media_port")))

	if deviceID == "" || ip == "" {
		u.render(w, r, "device_add", PageData{Title: "新增设备", Error: "节点标识和 IP 不能为空"})
		return
	}
	if agentPort == 0 {
		agentPort = 9100
	}
	if mediaPort == 0 {
		mediaPort = 9000
	}

	dev := &models.Device{
		DeviceID:   deviceID,
		DeviceName: deviceName,
		IP:         ip,
		AgentPort:  agentPort,
		MediaPort:  mediaPort,
		Online:     true,
		LastSeenMs: time.Now().UnixMilli(),
	}
	u.registry.UpdateDevice(dev)
	http.Redirect(w, r, "/ui/devices", http.StatusFound)
}

func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	timeoutMs, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_ms")))
	if timeoutMs <= 0 {
		timeoutMs = 1200
	}

	found, err := u.discovery.Search(timeoutMs)
	devices := u.registry.GetDevices()
	online := 0
	for _, d := range devices {
		if d.Online {
			online++
		}
	}
	data := PageData{Title: "设备", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online}
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "devices", data)
}

func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	action := strings.TrimSpace(r.FormValue("action"))
	deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
	if len(deviceIDs) == 0 {
		u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备"))
		return
	}

	typeStr := ""
	switch action {
	case "media_start", "media_restart", "media_stop", "reload", "rollback":
		typeStr = action
	default:
		u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action))
		return
	}

	if u.tasks == nil {
		http.Error(w, "task service not initialized", http.StatusInternalServerError)
		return
	}

	var payload any
	if typeStr == "media_start" || typeStr == "media_restart" {
		cfgName := strings.TrimSpace(r.FormValue("config"))
		if cfgName != "" {
			payload = map[string]any{"config": cfgName}
		}
	}

	task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
	if err != nil {
		u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error()))
		return
	}

	http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}

func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
	data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"]))
	u.render(w, r, "device_batch_config", data)
}

func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
	data := u.deviceBatchConfigPageData(r, selectedIDs)

	if len(selectedIDs) == 0 {
		data.Error = "请先选择需要下发分配的设备"
		u.render(w, r, "device_batch_config", data)
		return
	}
	if u.tasks == nil {
		data.Error = "task service not initialized"
		u.render(w, r, "device_batch_config", data)
		return
	}
	configs := make(map[string]any, len(selectedIDs))
	for _, deviceID := range selectedIDs {
		preview, err := u.preview.RenderDeviceAssignment(deviceID)
		if err != nil {
			data.Error = err.Error()
			u.render(w, r, "device_batch_config", data)
			return
		}
		if data.ConfigPreview == nil {
			data.ConfigPreview = preview
		}
		var configDoc any
		if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
			data.Error = "生成配置 JSON 无效: " + err.Error()
			u.render(w, r, "device_batch_config", data)
			return
		}
		configs[deviceID] = configDoc
	}

	task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs})
	if err != nil {
		data.Error = err.Error()
		u.render(w, r, "device_batch_config", data)
		return
	}

	http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}

func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	u.render(w, r, "device", u.deviceDetailPageData(dev))
}

func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
	data := u.deviceControlPageData(dev)
	data.Title = "设备详情"
	if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil {
		if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil {
			data.PersistedConfig = state
		}
	}
	if u.preview != nil {
		if profiles, err := u.preview.ListProfileAssets(); err == nil {
			data.AssetProfiles = profiles
			selectedProfile := ""
			if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" {
				selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile)
			} else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" {
				selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName)
			}
			if selectedProfile == "" && len(profiles) > 0 {
				selectedProfile = profiles[0].Name
			}
			data.SelectedProfile = selectedProfile
			for i := range profiles {
				if strings.TrimSpace(profiles[i].Name) == selectedProfile {
					data.AssetProfile = &profiles[i]
					data.SelectedTemplate = profileAssetTemplate(&profiles[i])
					break
				}
			}
			if data.AssetProfile == nil && len(profiles) > 0 {
				data.AssetProfile = &profiles[0]
				data.SelectedProfile = profiles[0].Name
				data.SelectedTemplate = profileAssetTemplate(&profiles[0])
			}
		} else if data.Error == "" {
			data.Error = err.Error()
		}
		if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil {
			data.DeviceAssignment = assignment
			data.SelectedAssignmentDevice = assignment.DeviceID
			data.SelectedProfile = assignment.ProfileName
		}
	}
	return data
}

func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
}

func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	_ = r.ParseForm()
	action := strings.TrimSpace(r.FormValue("action"))

	method := "POST"
	path := ""
	switch action {
	case "reload":
		path = "/v1/media-server/reload"
	case "rollback":
		path = "/v1/media-server/rollback"
	case "media_start":
		path = "/v1/media-server/start"
	case "media_restart":
		path = "/v1/media-server/restart"
	case "media_stop":
		path = "/v1/media-server/stop"
	case "media_status":
		method = "GET"
		path = "/v1/media-server/status"
	case "info":
		method = "GET"
		path = "/v1/info"
	default:
		http.Error(w, "unknown action", http.StatusBadRequest)
		return
	}

	body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil)
	msg := fmt.Sprintf("%s %s -> %d", method, path, code)
	returnTo := strings.TrimSpace(r.FormValue("return_to"))
	if returnTo == "control" || returnTo == "config" {
		data := u.deviceDetailPageData(dev)
		data.Message = msg
		data.RawText = string(body)
		data.ResultTitle = "执行结果摘要"
		if err != nil {
			data.Error = err.Error()
		}
		u.render(w, r, "device", data)
		return
	}
	data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)}
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "device", data)
}

func (u *UI) pageDeviceLogs(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	limit := strings.TrimSpace(r.URL.Query().Get("limit"))
	path := "/v1/logs/recent"
	if limit != "" {
		path += "?limit=" + urlQueryEscape(limit)
	}

	body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, path, nil)
	data := PageData{Title: "诊断日志", Device: dev, Message: fmt.Sprintf("GET %s -> %d", path, code), RawText: string(body)}
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "device_logs", data)
}

func (u *UI) pageDeviceGraphs(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil)
	data := PageData{Title: "运行指标", Device: dev, Message: fmt.Sprintf("GET /v1/graphs -> %d", code), RawText: string(body)}
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "device_graphs", data)
}

func (u *UI) actionDeviceConfigApply(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	_ = r.ParseForm()
	raw := strings.TrimSpace(r.FormValue("json"))
	if raw == "" {
		raw = `{"config":{}}`
	}

	body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw))
	data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw}
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "device", data)
}

func (u *UI) actionDeviceAliasSave(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	_ = r.ParseForm()
	alias := strings.TrimSpace(r.FormValue("device_alias"))
	data := u.deviceDetailPageData(dev)
	if err := u.registry.SetDeviceAlias(id, alias); err != nil {
		data.Error = err.Error()
		u.render(w, r, "device", data)
		return
	}
	dev.DeviceAlias = alias
	data.Device = dev
	data.Message = "设备别名已保存"
	u.render(w, r, "device", data)
}

func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}

	if err := r.ParseMultipartForm(100 << 20); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	name := strings.TrimSpace(r.FormValue("name"))
	file, hdr, err := r.FormFile("file")
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	defer file.Close()
	if name == "" {
		http.Error(w, "name is required", http.StatusBadRequest)
		return
	}
	path := fmt.Sprintf("/v1/models/%s", name)
	resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size)
	out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
	if derr != nil {
		out.Error = derr.Error()
	}
	u.render(w, r, "device", out)
}

func (u *UI) actionDeviceMediaServerConfigUpload(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}

	if err := r.ParseMultipartForm(50 << 20); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	name, err := normalizeConfigName(r.FormValue("name"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	file, hdr, err := r.FormFile("file")
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	defer file.Close()

	path := "/v1/media-server/configs/" + url.PathEscape(name)
	resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size)
	data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
	if derr != nil {
		data.Error = derr.Error()
	}
	u.render(w, r, "device", data)
}

func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}

	if err := r.ParseMultipartForm(200 << 20); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 {
		http.Error(w, "files is required", http.StatusBadRequest)
		return
	}
	files := r.MultipartForm.File["files"]
	if len(files) == 0 {
		http.Error(w, "files is required", http.StatusBadRequest)
		return
	}

	var sb strings.Builder
	errCount := 0
	for _, hdr := range files {
		name, nerr := normalizeConfigName(filepath.Base(hdr.Filename))
		if nerr != nil {
			errCount++
			sb.WriteString(fmt.Sprintf("%s -> invalid name: %v\n", hdr.Filename, nerr))
			continue
		}
		file, err := hdr.Open()
		if err != nil {
			errCount++
			sb.WriteString(fmt.Sprintf("%s -> open failed: %v\n", name, err))
			continue
		}
		path := "/v1/media-server/configs/" + url.PathEscape(name)
		resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size)
		_ = file.Close()
		if derr != nil {
			errCount++
			sb.WriteString(fmt.Sprintf("%s -> %d error: %v\n", name, code, derr))
			continue
		}
		if len(resp) > 0 {
			sb.WriteString(fmt.Sprintf("%s -> %d %s\n", name, code, strings.TrimSpace(string(resp))))
		} else {
			sb.WriteString(fmt.Sprintf("%s -> %d\n", name, code))
		}
	}

	data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()}
	if errCount > 0 {
		data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files))
	}
	u.render(w, r, "device", data)
}

func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
	u.ensureDevicesLoaded()
	devices := u.registry.GetDevices()
	selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"]))
	data := PageData{
		Title:             "任务中心",
		Tasks:             u.tasks.ListTasks(),
		Devices:           devices,
		SelectedDeviceIDs: selectedIDs,
		SelectedDevices:   selectedDevicesFromIDs(devices, selectedIDs),
		DeviceIDs:         strings.Join(selectedIDs, ","),
	}
	u.render(w, r, "tasks", data)
}

func (u *UI) taskPageData(task *models.Task) PageData {
	data := PageData{Title: "任务中心", Task: task}
	if task == nil {
		return data
	}

	devices := make(map[string]*models.Device)
	if u.registry != nil {
		for _, dev := range u.registry.GetDevices() {
			if dev == nil {
				continue
			}
			devices[dev.DeviceID] = dev
		}
	}

	rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs))
	for _, did := range task.DeviceIDs {
		row := TaskDeviceRow{}
		if dev := devices[did]; dev != nil {
			row.Device = dev
		} else {
			row.Device = &models.Device{DeviceID: did}
		}
		if ds := task.Devices[did]; ds != nil {
			row.Status = ds.Status
			row.Progress = ds.Progress
			row.Error = ds.Error
		}
		rows = append(rows, row)
	}
	data.TaskDeviceRows = rows
	return data
}

func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	typeStr := strings.TrimSpace(r.FormValue("type"))
	if typeStr == "" {
		typeStr = "config_apply"
	}
	ids := strings.TrimSpace(r.FormValue("device_ids"))
	var deviceIDs []string
	if ids != "" {
		for _, p := range strings.Split(ids, ",") {
			p = strings.TrimSpace(p)
			if p != "" {
				deviceIDs = append(deviceIDs, p)
			}
		}
	} else {
		deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
		ids = strings.Join(deviceIDs, ",")
	}
	raw := strings.TrimSpace(r.FormValue("payload_json"))
	if raw == "" {
		raw = `{"config":{}}`
	}
	var payload any
	if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
		return
	}

	task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
	if err != nil {
		u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
		return
	}
	http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}

func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	items := u.tasks.ListTasks()
	var task *models.Task
	for i := range items {
		if items[i].ID == id {
			t := items[i]
			task = &t
			break
		}
	}
	if task == nil {
		http.NotFound(w, r)
		return
	}
	data := u.taskPageData(task)
	data.TaskID = id
	u.render(w, r, "task", data)
}

func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/ui/assets/templates", http.StatusFound)
}

func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}

func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
	u.ensureDevicesLoaded()
	data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()}
	for _, dev := range data.Devices {
		if dev == nil {
			continue
		}
		if dev.Online {
			data.OnlineCount++
		}
	}
	board := service.ModelStatusBoard{}
	if strings.TrimSpace(u.dbPath) != "" {
		if store, err := storage.OpenSQLite(u.dbPath); err == nil {
			modelsRepo := storage.NewModelsRepo(store.DB())
			if items, err := modelsRepo.List(); err == nil {
				data.StandardModels = items
				installed := map[string][]service.InstalledModelStatus{}
				for _, device := range data.Devices {
					if device == nil || !device.Online {
						continue
					}
					items, err := service.FetchInstalledModelStatuses(u.agent, device)
					if err == nil {
						installed[device.DeviceID] = items
					}
				}
				board = service.BuildModelStatusBoard(items, data.Devices, installed)
			}
			_ = store.Close()
		}
	}
	data.ModelStatusBoard = &board
	u.render(w, r, "models", data)
}

func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	action := strings.TrimSpace(r.FormValue("action"))
	if action == "" {
		action = "model_sync_all"
	}
	if action != "model_sync_one" && action != "model_sync_all" {
		http.Error(w, "invalid action", http.StatusBadRequest)
		return
	}
	deviceIDs := make([]string, 0)
	for _, id := range r.Form["device_id"] {
		id = strings.TrimSpace(id)
		if id != "" {
			deviceIDs = append(deviceIDs, id)
		}
	}
	if len(deviceIDs) == 0 {
		http.Error(w, "missing device_id", http.StatusBadRequest)
		return
	}
	payload := map[string]any{}
	if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" {
		payload["model_name"] = modelName
	}
	task, err := u.tasks.CreateTask(action, deviceIDs, payload)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}

func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
	u.ensureDevicesLoaded()
	data := PageData{Title: "日志审计", Devices: u.registry.GetDevices()}
	if u.auditRepo != nil {
		items, err := u.auditRepo.List()
		if err != nil {
			data.Error = err.Error()
		} else {
			data.AuditEntries = items
		}
	}
	if len(data.AuditEntries) == 0 && u.tasks != nil {
		data.Tasks = u.tasks.ListTasks()
	}
	u.render(w, r, "diagnostics", data)
}

func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
	u.ensureDevicesLoaded()
	data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()}
	for _, dev := range data.Devices {
		if dev == nil {
			continue
		}
		if dev.Online {
			data.OnlineCount++
		}
	}
	board := service.ResourceStatusBoard{}
	if strings.TrimSpace(u.dbPath) != "" {
		if store, err := storage.OpenSQLite(u.dbPath); err == nil {
			resourcesRepo := storage.NewResourcesRepo(store.DB())
			if items, err := resourcesRepo.List(); err == nil {
				data.StandardResources = items
				board = service.FetchAndBuildResourceBoard(u.agent, data.Devices, items)
			}
			_ = store.Close()
		}
	}
	data.ResourceStatusBoard = &board
	u.render(w, r, "resources", data)
}

func (u *UI) actionResourceSync(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	action := strings.TrimSpace(r.FormValue("action"))
	if action == "" {
		action = "resource_sync_all"
	}
	if action != "resource_sync_one" && action != "resource_sync_all" {
		http.Error(w, "invalid action", http.StatusBadRequest)
		return
	}
	deviceIDs := make([]string, 0)
	for _, id := range r.Form["device_id"] {
		id = strings.TrimSpace(id)
		if id != "" {
			deviceIDs = append(deviceIDs, id)
		}
	}
	if len(deviceIDs) == 0 {
		http.Error(w, "missing device_id", http.StatusBadRequest)
		return
	}
	payload := map[string]any{}
	if resourceName := strings.TrimSpace(r.FormValue("resource_name")); resourceName != "" {
		payload["resource_name"] = resourceName
	}
	task, err := u.tasks.CreateTask(action, deviceIDs, payload)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}

func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/ui/assets", http.StatusFound)
}

func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/ui/diagnostics", http.StatusFound)
}

func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) {
	u.render(w, r, "api", PageData{Title: "高级调试"})
}

func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("overview")
	u.render(w, r, "assets", data)
}

func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("templates")
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
	if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
		if item, err := u.preview.GetTemplateAsset(name); err == nil {
			data.AssetTemplate = item
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	} else if len(data.AssetTemplates) > 0 {
		if item, err := u.preview.GetTemplateAsset(data.AssetTemplates[0].Name); err == nil {
			data.AssetTemplate = item
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	}
	data.AssetTemplateEditing = editMode && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly
	u.render(w, r, "asset_templates", data)
}

func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	name, err := normalizeConfigName(r.FormValue("name"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	name = strings.TrimSuffix(name, ".json")
	description := strings.TrimSpace(r.FormValue("description"))
	cloneSource := strings.TrimSpace(r.FormValue("clone_source"))

	var doc map[string]any
	if cloneSource != "" {
		item, err := u.preview.GetTemplateAsset(cloneSource)
		if err != nil || item == nil {
			http.Error(w, "clone source template not found", http.StatusNotFound)
			return
		}
		body, err := json.Marshal(item.Raw)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		if err := json.Unmarshal(body, &doc); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	} else {
		doc = map[string]any{
			"name":        name,
			"description": description,
			"params":      map[string]any{},
			"template": map[string]any{
				"nodes": []any{},
				"edges": []any{},
			},
		}
	}

	doc["name"] = name
	doc["description"] = description
	body, err := json.MarshalIndent(doc, "", "  ")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := u.preview.SaveTemplateAsset(name, description, string(body)+"\n"); err != nil {
		status := http.StatusInternalServerError
		if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") {
			status = http.StatusBadRequest
		}
		http.Error(w, err.Error(), status)
		return
	}
	http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound)
}

func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	sourceName := chi.URLParam(r, "name")
	item, err := u.preview.GetTemplateAsset(sourceName)
	if err != nil || item == nil {
		http.NotFound(w, r)
		return
	}
	if !item.ReadOnly {
		http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound)
		return
	}
	targetName, err := u.nextTemplateCloneName(item.Name)
	if err != nil {
		http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
		return
	}
	body, err := json.Marshal(item.Raw)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	doc := map[string]any{}
	if err := json.Unmarshal(body, &doc); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	doc["name"] = targetName
	doc["description"] = item.Description
	pretty, err := json.MarshalIndent(doc, "", "  ")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := u.preview.SaveTemplateAsset(targetName, item.Description, string(pretty)+"\n"); err != nil {
		http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&edit=1&msg="+urlQueryEscape("标准模板已复制，请继续编辑"), http.StatusFound)
}

func (u *UI) nextTemplateCloneName(sourceName string) (string, error) {
	base := strings.TrimSpace(sourceName)
	if base == "" {
		return "", fmt.Errorf("template name is required")
	}
	if strings.HasPrefix(base, "std_") {
		base = strings.TrimPrefix(base, "std_")
	}
	candidate := base + "_copy"
	if item, err := u.preview.GetTemplateAsset(candidate); err == nil && item != nil {
		for i := 2; i < 1000; i++ {
			name := fmt.Sprintf("%s_copy_%d", base, i)
			item, err := u.preview.GetTemplateAsset(name)
			if err != nil || item == nil {
				return name, nil
			}
		}
		return "", fmt.Errorf("无法生成可用的模板副本名称")
	}
	return candidate, nil
}

func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	data := u.assetPageData("templates")
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	item, err := u.preview.GetTemplateAsset(name)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	data.AssetTemplate = item
	u.render(w, r, "asset_templates", data)
}

func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	data := u.assetPageData("templates")
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	if u.preview == nil {
		data.Error = "配置中心服务未初始化"
		u.render(w, r, "asset_templates", data)
		return
	}
	item, err := u.preview.GetTemplateAsset(name)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	data.Title = "模板可视化编辑"
	if item.ReadOnly {
		data.Title = "标准模板可视化预览"
	}
	data.AssetTemplate = item
	raw, err := compactJSON(item.Raw)
	if err != nil {
		data.Error = err.Error()
		u.render(w, r, "asset_templates", data)
		return
	}
	data.RawJSON = raw
	u.render(w, r, "asset_template_graph", data)
}

func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	raw := strings.TrimSpace(r.FormValue("json"))
	if raw == "" {
		http.Error(w, "template json is required", http.StatusBadRequest)
		return
	}
	var doc map[string]any
	if err := json.Unmarshal([]byte(raw), &doc); err != nil {
		http.Error(w, "invalid template json: "+err.Error(), http.StatusBadRequest)
		return
	}
	if err := validateTemplateGraphDocument(doc); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	targetName := name
	if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" {
		normalized, err := normalizeConfigName(rawName)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		targetName = strings.TrimSuffix(normalized, ".json")
	}
	description := strings.TrimSpace(r.FormValue("description"))
	if description == "" {
		description, _ = doc["description"].(string)
	}
	doc["name"] = targetName
	doc["description"] = description
	body, err := json.MarshalIndent(doc, "", "  ")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if targetName != name {
		err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n")
	} else {
		err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n")
	}
	if err != nil {
		status := http.StatusInternalServerError
		if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") {
			status = http.StatusBadRequest
		}
		http.Error(w, err.Error(), status)
		return
	}
	message := "模板已保存"
	if targetName != name {
		message = "模板已保存，名称已更新"
	}
	http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound)
}

func (u *UI) actionAssetTemplateRename(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	item, err := u.preview.GetTemplateAsset(name)
	if err != nil || item == nil {
		http.NotFound(w, r)
		return
	}
	targetName := name
	if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" {
		normalized, err := normalizeConfigName(rawName)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		targetName = strings.TrimSuffix(normalized, ".json")
	}
	description := strings.TrimSpace(r.FormValue("description"))
	if description == "" {
		description = item.Description
	}
	body, err := json.Marshal(item.Raw)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	doc := map[string]any{}
	if err := json.Unmarshal(body, &doc); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	doc["name"] = targetName
	doc["description"] = description
	body, err = json.MarshalIndent(doc, "", "  ")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if targetName != name {
		err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n")
	} else {
		err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n")
	}
	if err != nil {
		http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存，名称已更新"), http.StatusFound)
}

func (u *UI) actionAssetTemplateDelete(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	if err := u.preview.DeleteTemplateAsset(name); err != nil {
		http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound)
}

func (u *UI) apiGraphNodeTypes(w http.ResponseWriter, r *http.Request) {
	if body, ok := u.loadAgentGraphNodeTypes(); ok {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_, _ = w.Write(body)
		return
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(map[string]any{"items": graphNodeTypesCatalog()})
}

func (u *UI) loadAgentGraphNodeTypes() ([]byte, bool) {
	if u.agent == nil || u.registry == nil {
		return nil, false
	}
	u.ensureDevicesLoaded()
	for _, dev := range u.registry.GetDevices() {
		if dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 {
			continue
		}
		body, code, err := u.agent.Do(http.MethodGet, dev.IP, dev.AgentPort, "/v1/graph-node-types", nil)
		if err != nil || code < 200 || code >= 300 {
			continue
		}
		var payload struct {
			Items []graphNodeTypeInfo `json:"items"`
		}
		if err := json.Unmarshal(body, &payload); err != nil || len(payload.Items) == 0 {
			continue
		}
		return body, true
	}
	return nil, false
}

func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) {
	u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name"))
}

func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("")
	data.Title = "场景"
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
	editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
	selected := strings.TrimSpace(r.URL.Query().Get("name"))
	if selected == "" && !newMode && len(data.AssetProfiles) > 0 {
		selected = data.AssetProfiles[0].Name
	}
	if newMode {
		editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()}
		if len(data.AssetTemplates) > 0 {
			editor.PrimaryTemplateName = data.AssetTemplates[0].Name
		}
		data.AssetProfileEditor = editor
		data.AssetProfileEditing = true
		data.AssetProfileFormAction = "/ui/scene-templates/create"
		data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0)
		u.render(w, r, "scene_templates", data)
		return
	}
	if selected != "" {
		editor, err := u.preview.GetProfileEditor(selected)
		if err == nil {
			data.AssetProfileEditor = editor
			data.SelectedProfile = editor.Name
			data.AssetProfileEditing = editMode
			data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(editor.Name)
			if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
				data.SelectedTemplate = editor.Instances[0].Template
			}
			data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	} else if len(data.AssetProfiles) == 0 {
		editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()}
		if len(data.AssetTemplates) > 0 {
			editor.PrimaryTemplateName = data.AssetTemplates[0].Name
		}
		data.AssetProfileEditor = editor
		data.AssetProfileEditing = true
		data.AssetProfileFormAction = "/ui/scene-templates/create"
		data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0)
	}
	u.render(w, r, "scene_templates", data)
}

func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) {
	u.pagePlans(w, r)
}

func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	data, err := u.profileEditorPageData(name)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	data.Title = "场景"
	data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
	data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
	data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
	u.render(w, r, "scene_templates", data)
}

func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) {
	u.pagePlan(w, r)
}

func (u *UI) pagePlanExport(w http.ResponseWriter, r *http.Request) {
	u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name"))
}

func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) {
	u.pagePlanExport(w, r)
}

func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) {
	u.actionPlanSaveWithName(w, r, chi.URLParam(r, "name"))
}

func (u *UI) actionPlanCreate(w http.ResponseWriter, r *http.Request) {
	u.actionPlanSaveWithName(w, r, "")
}

func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name string) {
	var (
		editor service.ConfigProfileEditor
		data   PageData
		err    error
	)
	if strings.TrimSpace(name) == "" {
		data = u.assetPageData("")
		data.Title = "场景"
		_ = r.ParseForm()
		editor = service.ConfigProfileEditor{
			Name:                strings.TrimSpace(r.FormValue("profile_name")),
			PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")),
			BusinessName:        strings.TrimSpace(r.FormValue("business_name")),
			Description:         strings.TrimSpace(r.FormValue("description")),
			OverlayName:         strings.TrimSpace(r.FormValue("overlay_name")),
			SiteName:            strings.TrimSpace(r.FormValue("site_name")),
			Queue:               service.DefaultConfigProfileQueue(),
			Instances:           parseProfileInstanceForm(r.Form),
		}
		data.AssetProfileEditor = &editor
		data.AssetProfileEditing = true
		data.AssetProfileFormAction = "/ui/scene-templates/create"
		data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
	} else {
		editor, data, err = u.profileEditorActionData(r, name)
		if err != nil {
			http.NotFound(w, r)
			return
		}
		data.AssetProfileEditing = true
		data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
	}
	if err := u.preview.SaveProfileEditor(editor); err != nil {
		data.Error = err.Error()
		data.Title = "场景"
		data.AssetProfileEditing = true
		u.render(w, r, "scene_templates", data)
		return
	}
	msg := "场景已保存"
	if strings.TrimSpace(name) != "" && editor.Name != name {
		msg = "场景已保存，名称已更新"
	}
	http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound)
}

func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
	name := strings.TrimSpace(chi.URLParam(r, "name"))
	if name == "" {
		http.NotFound(w, r)
		return
	}
	if u.stateRepo != nil {
		items, err := u.stateRepo.ListByProfileName(name)
		if err != nil {
			http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
			return
		}
		if len(items) > 0 {
			deviceIDs := make([]string, 0, len(items))
			for _, item := range items {
				deviceIDs = append(deviceIDs, item.DeviceID)
			}
			msg := fmt.Sprintf("场景 %q 正被以下设备使用，无法删除：%s", name, strings.Join(deviceIDs, "、"))
			http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound)
			return
		}
	}
	if err := u.preview.DeleteProfileAsset(name); err != nil {
		http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound)
}

func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
	u.actionPlanSave(w, r)
}

func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) {
	target := "/ui/scene-templates"
	if r.URL.RawQuery != "" {
		target += "?" + r.URL.RawQuery
	}
	http.Redirect(w, r, target, http.StatusFound)
}

func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/ui/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}

func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) {
	target := "/ui/scene-templates"
	if r.URL.RawQuery != "" {
		target += "?" + r.URL.RawQuery
	}
	http.Redirect(w, r, target, http.StatusFound)
}

func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request) {
	target := "/ui/scene-templates/" + url.PathEscape(chi.URLParam(r, "name"))
	if r.URL.RawQuery != "" {
		target += "?" + r.URL.RawQuery
	}
	http.Redirect(w, r, target, http.StatusFound)
}

func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("")
	data.Title = "视频通道"
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
	editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
	selected := strings.TrimSpace(r.URL.Query().Get("ref"))
	if selected == "" && !newMode && len(data.RecognitionUnits) > 0 {
		selected = data.RecognitionUnits[0].Ref
	}
	data.SelectedRecognitionUnit = selected
	if newMode {
		defaultTemplate := ""
		if len(data.AssetProfiles) > 0 {
			defaultTemplate = data.AssetProfiles[0].Name
		}
		data.RecognitionUnit = &service.RecognitionUnitAsset{
			SceneTemplateName: defaultTemplate,
			OutputChannel:     "cam1",
			RTSPPort:          "8555",
		}
		data.RecognitionUnitEditing = true
	} else if selected != "" {
		if item, err := u.preview.GetRecognitionUnit(selected); err == nil {
			data.RecognitionUnit = item
			data.RecognitionUnitEditing = editMode
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	}
	u.render(w, r, "recognition_units", data)
}

func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	asset := service.RecognitionUnitAsset{
		SceneTemplateName: strings.TrimSpace(r.FormValue("scene_template_name")),
		Name:              strings.TrimSpace(r.FormValue("name")),
		DisplayName:       strings.TrimSpace(r.FormValue("display_name")),
		SiteName:          strings.TrimSpace(r.FormValue("site_name")),
		VideoSourceRef:    strings.TrimSpace(r.FormValue("video_source_ref")),
		OutputChannel:     strings.TrimSpace(r.FormValue("output_channel")),
		RTSPPort:          strings.TrimSpace(r.FormValue("rtsp_port")),
	}
	originalRef := strings.TrimSpace(r.FormValue("original_ref"))
	if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil {
		data := u.assetPageData("")
		data.Title = "视频通道"
		data.Error = err.Error()
		data.RecognitionUnit = &asset
		data.RecognitionUnitEditing = true
		data.SelectedRecognitionUnit = originalRef
		u.render(w, r, "recognition_units", data)
		return
	}
	ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name)
	http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound)
}

func serviceRecognitionUnitRef(profileName string, unitName string) string {
	return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName)
}

func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request) {
	ref := strings.TrimSpace(r.FormValue("ref"))
	if err := u.preview.DeleteRecognitionUnit(ref); err != nil {
		http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound)
}

func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("")
	data.Title = "通道部署"
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	u.ensureDevicesLoaded()
	data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices())
	maxUnits := service.DefaultMaxUnitsPerDevice()
	if value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("max_units_per_device"))); err == nil && value > 0 {
		maxUnits = value
	}
	data.MaxUnitsPerDevice = maxUnits
	if u.preview != nil {
		if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil {
			data.DeviceAssignmentBoard = board
			if payload, marshalErr := json.Marshal(board); marshalErr == nil {
				data.DeviceAssignmentBoardJSON = template.JS(payload)
			}
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	}
	u.render(w, r, "device_assignments", data)
}

func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json")))
	if err != nil {
		data := u.assetPageData("")
		data.Title = "通道部署"
		u.ensureDevicesLoaded()
		data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices())
		data.Error = err.Error()
		maxUnits := service.DefaultMaxUnitsPerDevice()
		if value, convErr := strconv.Atoi(strings.TrimSpace(r.FormValue("max_units_per_device"))); convErr == nil && value > 0 {
			maxUnits = value
		}
		data.MaxUnitsPerDevice = maxUnits
		if u.preview != nil {
			if board, buildErr := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); buildErr == nil {
				data.DeviceAssignmentBoard = board
				if payload, marshalErr := json.Marshal(board); marshalErr == nil {
					data.DeviceAssignmentBoardJSON = template.JS(payload)
				}
			}
		}
		u.render(w, r, "device_assignments", data)
		return
	}
	if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil {
		http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound)
}

func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device {
	out := make([]*models.Device, 0, len(devices))
	seen := make(map[string]struct{}, len(devices))
	for _, dev := range devices {
		if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
			continue
		}
		deviceID := strings.TrimSpace(dev.DeviceID)
		if _, ok := seen[deviceID]; ok {
			continue
		}
		seen[deviceID] = struct{}{}
		out = append(out, dev)
	}
	for len(out) < deviceAssignmentPreviewDeviceCount {
		index := len(out) + 1
		deviceID := fmt.Sprintf("%s%02d", deviceAssignmentPreviewDevicePrefix, index)
		out = append(out, &models.Device{
			DeviceID:   deviceID,
			DeviceName: fmt.Sprintf("测试设备 %02d", index),
			Online:     true,
		})
	}
	return out
}

func filterPreviewDeviceAssignments(assignments map[string][]string) map[string][]string {
	if len(assignments) == 0 {
		return assignments
	}
	filtered := make(map[string][]string, len(assignments))
	for deviceID, refs := range assignments {
		if strings.HasPrefix(strings.TrimSpace(deviceID), deviceAssignmentPreviewDevicePrefix) {
			continue
		}
		filtered[deviceID] = refs
	}
	return filtered
}

func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request) {
	deviceID := strings.TrimSpace(chi.URLParam(r, "id"))
	if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil {
		http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound)
}

func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("video-sources")
	data.Title = "配置中心"
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
	editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
	selected := strings.TrimSpace(r.URL.Query().Get("name"))
	if selected != "" {
		if item, err := u.preview.GetVideoSource(selected); err == nil {
			data.AssetVideoSource = item
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	} else if !newMode && len(data.AssetVideoSources) > 0 {
		if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil {
			data.AssetVideoSource = item
		}
	}
	if data.AssetVideoSource == nil {
		data.AssetVideoSource = &service.ConfigVideoSourceAsset{
			SourceType:      "rtsp",
			SourceTypeLabel: "RTSP",
		}
		data.AssetVideoSourceEditing = true
	} else {
		data.AssetVideoSourceEditing = newMode || editMode
	}
	u.render(w, r, "assets", data)
}

func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	_ = r.ParseForm()
	asset := service.ConfigVideoSourceAsset{
		Name:        strings.TrimSpace(r.FormValue("name")),
		SourceType:  strings.TrimSpace(r.FormValue("source_type")),
		Area:        strings.TrimSpace(r.FormValue("area")),
		Description: strings.TrimSpace(r.FormValue("description")),
		Config: service.VideoSourceConfig{
			URL:         strings.TrimSpace(r.FormValue("url")),
			Resolution:  strings.TrimSpace(r.FormValue("resolution")),
			FrameSize:   strings.TrimSpace(r.FormValue("frame_size")),
			FPS:         strings.TrimSpace(r.FormValue("fps")),
			VideoFormat: strings.TrimSpace(r.FormValue("video_format")),
			FocalLength: strings.TrimSpace(r.FormValue("focal_length")),
			MountHeight: strings.TrimSpace(r.FormValue("mount_height")),
			MountAngle:  strings.TrimSpace(r.FormValue("mount_angle")),
		},
	}
	if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
		data := u.assetPageData("video-sources")
		data.Title = "配置中心"
		data.Error = err.Error()
		data.AssetVideoSource = &asset
		data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType)
		data.AssetVideoSourceEditing = true
		u.render(w, r, "assets", data)
		return
	}
	http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
}

func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	name := chi.URLParam(r, "name")
	if err := u.preview.DeleteVideoSource(name); err != nil {
		http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound)
}

func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("integrations")
	data.Title = "配置中心"
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
	editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
	selected := strings.TrimSpace(r.URL.Query().Get("name"))
	if selected != "" {
		if item, err := u.preview.GetIntegrationService(selected); err == nil {
			data.AssetIntegration = item
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	} else if !newMode && len(data.AssetIntegrations) > 0 {
		if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil {
			data.AssetIntegration = item
		}
	}
	if data.AssetIntegration == nil {
		data.AssetIntegration = &service.ConfigIntegrationServiceAsset{
			Type:          "object_storage",
			TypeLabel:     "对象存储",
			Enabled:       true,
			ObjectStorage: &service.ObjectStorageConfig{},
		}
		data.AssetIntegrationEditing = true
	} else {
		data.AssetIntegrationEditing = newMode || editMode
	}
	u.render(w, r, "assets", data)
}

func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	_ = r.ParseForm()
	enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on")
	asset := service.ConfigIntegrationServiceAsset{
		Name:        strings.TrimSpace(r.FormValue("name")),
		Type:        strings.TrimSpace(r.FormValue("type")),
		Description: strings.TrimSpace(r.FormValue("description")),
		Enabled:     enabled,
		ObjectStorage: &service.ObjectStorageConfig{
			Endpoint:  strings.TrimSpace(r.FormValue("endpoint")),
			Bucket:    strings.TrimSpace(r.FormValue("bucket")),
			AccessKey: strings.TrimSpace(r.FormValue("access_key")),
			SecretKey: strings.TrimSpace(r.FormValue("secret_key")),
		},
		TokenService: &service.TokenServiceConfig{
			GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")),
			Username:    strings.TrimSpace(r.FormValue("username")),
			Password:    strings.TrimSpace(r.FormValue("password")),
			TenantCode:  strings.TrimSpace(r.FormValue("tenant_code")),
		},
		AlarmService: &service.AlarmServiceConfig{
			PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")),
			Username:      strings.TrimSpace(r.FormValue("alarm_username")),
			Password:      strings.TrimSpace(r.FormValue("alarm_password")),
			TenantCode:    strings.TrimSpace(r.FormValue("alarm_tenant_code")),
		},
	}
	if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil {
		http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
}

func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	name := chi.URLParam(r, "name")
	if err := u.preview.DeleteIntegrationService(name); err != nil {
		http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound)
}

func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) {
	data := u.assetPageData("overlays")
	data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
	if data.Error == "" {
		data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
	}
	newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
	editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
	if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
		if item, err := u.preview.GetOverlayAsset(name); err == nil {
			data.AssetOverlay = item
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	} else if !newMode && len(data.AssetOverlays) > 0 {
		if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil {
			data.AssetOverlay = item
		} else if data.Error == "" {
			data.Error = err.Error()
		}
	}
	if newMode {
		data.AssetOverlay = &service.ConfigOverlayAsset{
			Name:        "",
			Description: "",
			Raw: map[string]any{
				"name":              "",
				"description":       "",
				"instance_overrides": map[string]any{"*": map[string]any{"override": map[string]any{}}},
			},
		}
		data.AssetOverlayEditing = true
	} else {
		data.AssetOverlayEditing = editMode && data.AssetOverlay != nil
	}
	if data.AssetOverlay != nil {
		rawJSON, err := compactJSON(data.AssetOverlay.Raw)
		if err == nil {
			data.OverlayDraftJSON = rawJSON
		}
	}
	u.render(w, r, "asset_overlays", data)
}

func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	data := u.assetPageData("overlays")
	item, err := u.preview.GetOverlayAsset(name)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	data.AssetOverlay = item
	u.render(w, r, "asset_overlays", data)
}

func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) {
	u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name"))
}

func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	_ = r.ParseForm()
	name := strings.TrimSpace(r.FormValue("name"))
	description := strings.TrimSpace(r.FormValue("description"))
	rawText := strings.TrimSpace(r.FormValue("json"))
	if rawText == "" {
		rawText = "{}"
	}
	raw := map[string]any{}
	if err := json.Unmarshal([]byte(rawText), &raw); err != nil {
		data := u.assetPageData("overlays")
		data.Title = "配置中心"
		data.Error = "调试参数 JSON 格式不正确：" + err.Error()
		data.AssetOverlayEditing = true
		data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
		data.OverlayDraftJSON = rawText
		u.render(w, r, "asset_overlays", data)
		return
	}
	asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
	if err := u.preview.SaveOverlayAsset(asset, raw); err != nil {
		data := u.assetPageData("overlays")
		data.Title = "配置中心"
		data.Error = err.Error()
		data.AssetOverlayEditing = true
		data.AssetOverlay = &asset
		data.OverlayDraftJSON = rawText
		u.render(w, r, "asset_overlays", data)
		return
	}
	http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound)
}

func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) {
	if u.preview == nil {
		http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
		return
	}
	name := chi.URLParam(r, "name")
	if err := u.preview.DeleteOverlayAsset(name); err != nil {
		http.Redirect(w, r, "/ui/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
		return
	}
	http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound)
}

func (u *UI) assetPageData(tab string) PageData {
	data := PageData{
		Title:    "配置中心",
		AssetTab: tab,
	}
	if u.preview == nil {
		data.Error = "配置中心服务未初始化"
		return data
	}
	sources, err := u.preview.ListSources()
	data.ConfigSources = sources
	if err != nil {
		data.Error = err.Error()
	}
	if items, listErr := u.preview.ListTemplateAssets(); listErr == nil {
		data.AssetTemplates = items
		data.AssetTemplateMap = make(map[string]service.ConfigTemplateAsset, len(items))
		for _, item := range items {
			data.AssetTemplateMap[item.Name] = item
		}
	} else if data.Error == "" {
		data.Error = listErr.Error()
	}
	if items, listErr := u.preview.ListProfileAssets(); listErr == nil {
		data.AssetProfiles = items
		for _, item := range items {
			data.AssetInstanceCount += len(item.Instances)
		}
	} else if data.Error == "" {
		data.Error = listErr.Error()
	}
	if items, listErr := u.preview.ListOverlayAssets(); listErr == nil {
		data.AssetOverlays = items
	} else if data.Error == "" {
		data.Error = listErr.Error()
	}
	if items, listErr := u.preview.ListVideoSources(); listErr == nil {
		data.AssetVideoSources = items
	} else if data.Error == "" {
		data.Error = listErr.Error()
	}
	if items, listErr := u.preview.ListIntegrationServices(); listErr == nil {
		data.AssetIntegrations = items
	} else if data.Error == "" {
		data.Error = listErr.Error()
	}
	if items, listErr := u.preview.ListRecognitionUnits(); listErr == nil {
		data.RecognitionUnits = items
	} else if data.Error == "" {
		data.Error = listErr.Error()
	}
	if items, listErr := u.preview.ListDeviceAssignments(); listErr == nil {
		data.DeviceAssignments = items
	} else if data.Error == "" {
		data.Error = listErr.Error()
	}
	return data
}

func (u *UI) profileEditorPageData(name string) (PageData, error) {
	data := u.assetPageData("profiles")
	if u.preview == nil {
		return data, fmt.Errorf("preview service not initialized")
	}
	editor, err := u.preview.GetProfileEditor(name)
	if err != nil {
		return data, err
	}
	data.AssetProfileEditor = editor
	data.SelectedProfile = editor.Name
	if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
		data.SelectedTemplate = editor.Instances[0].Template
	} else {
		data.SelectedTemplate = "std_workshop_face_recognition_shoe_alarm"
	}
	return data, nil
}

func clampActiveInstanceIndex(count int, preferred int) int {
	if count <= 0 {
		return 0
	}
	if preferred < 0 {
		return 0
	}
	if preferred >= count {
		return count - 1
	}
	return preferred
}

func activeInstanceIndexFromValues(values url.Values) int {
	raw := strings.TrimSpace(values.Get("active_instance"))
	if raw == "" {
		return 0
	}
	idx, err := strconv.Atoi(raw)
	if err != nil {
		return 0
	}
	return idx
}

func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) {
	data, err := u.profileEditorPageData(name)
	if err != nil {
		return service.ConfigProfileEditor{}, data, err
	}
	_ = r.ParseForm()
	editor := service.ConfigProfileEditor{
		Name:                strings.TrimSpace(r.FormValue("profile_name")),
		PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")),
		BusinessName:        strings.TrimSpace(r.FormValue("business_name")),
		Description:         strings.TrimSpace(r.FormValue("description")),
		OverlayName:         strings.TrimSpace(r.FormValue("overlay_name")),
		SiteName:            strings.TrimSpace(r.FormValue("site_name")),
		Queue:               service.DefaultConfigProfileQueue(),
		Instances:           parseProfileInstanceForm(r.Form),
	}
	if editor.Name == "" && data.AssetProfileEditor != nil {
		editor.Name = data.AssetProfileEditor.Name
	}
	if editor.PrimaryTemplateName == "" && data.AssetProfileEditor != nil {
		editor.PrimaryTemplateName = data.AssetProfileEditor.PrimaryTemplateName
	}
	if editor.DeviceCode == "" && data.AssetProfileEditor != nil {
		editor.DeviceCode = data.AssetProfileEditor.DeviceCode
	}
	if len(editor.Instances) == 0 && data.AssetProfileEditor != nil {
		editor.Instances = append([]service.ConfigProfileInstanceEditor(nil), data.AssetProfileEditor.Instances...)
	}
	data.AssetProfileEditor = &editor
	data.SelectedProfile = editor.Name
	data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
	return editor, data, nil
}

func (u *UI) exportAssetJSON(w http.ResponseWriter, r *http.Request, kind string, name string) {
	if u.preview == nil {
		http.Error(w, "preview service not initialized", http.StatusInternalServerError)
		return
	}
	body, filename, err := u.preview.ExportAssetJSON(kind, name)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
	_, _ = w.Write(body)
}

func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
	data := PageData{Title: "审计记录"}
	if u.auditRepo != nil {
		items, err := u.auditRepo.List()
		if err != nil {
			data.Error = err.Error()
		} else {
			data.AuditEntries = items
		}
	}
	if len(data.AuditEntries) == 0 && u.tasks != nil {
		data.Tasks = u.tasks.ListTasks()
	}
	u.render(w, r, "audit", data)
}

func (u *UI) pageSystem(w http.ResponseWriter, r *http.Request) {
	u.renderSystemPage(
		w,
		r,
		http.StatusOK,
		strings.TrimSpace(r.URL.Query().Get("msg")),
		strings.TrimSpace(r.URL.Query().Get("error")),
	)
}

func (u *UI) pageSystemDBBackup(w http.ResponseWriter, r *http.Request) {
	if strings.TrimSpace(u.dbPath) == "" {
		http.Error(w, "database path is not configured", http.StatusNotFound)
		return
	}
	body, err := os.ReadFile(u.dbPath)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	filename := "app-" + time.Now().Format("20060102-150405") + ".db"
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
	_, _ = w.Write(body)
}

func (u *UI) renderSystemPage(w http.ResponseWriter, r *http.Request, status int, message string, errText string) {
	w.WriteHeader(status)
	u.render(w, r, "system", PageData{
		Title:   "系统状态",
		Devices: u.registry.GetDevices(),
		DBPath:  u.dbPath,
		Message: message,
		Error:   errText,
	})
}

func (u *UI) actionSystemDBRestore(w http.ResponseWriter, r *http.Request) {
	if strings.TrimSpace(u.dbPath) == "" {
		http.Error(w, "database path is not configured", http.StatusNotFound)
		return
	}
	if err := r.ParseMultipartForm(50 << 20); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	file, _, err := r.FormFile("file")
	if err != nil {
		u.renderSystemPage(w, r, http.StatusBadRequest, "", "请先选择数据库备份文件")
		return
	}
	defer file.Close()
	body, err := io.ReadAll(file)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := os.WriteFile(u.dbPath, body, 0o644); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/ui/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound)
}

func urlQueryEscape(s string) string {
	r := strings.NewReplacer("%", "%25", " ", "%20", "+", "%2B", "&", "%26", "=", "%3D", "?", "%3F")
	return r.Replace(s)
}

func normalizeConfigName(name string) (string, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return "", fmt.Errorf("name is required")
	}
	if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
		return "", fmt.Errorf("name contains invalid path")
	}
	if !strings.HasSuffix(strings.ToLower(name), ".json") {
		name += ".json"
	}
	return name, nil
}

func serviceVideoSourceTypeLabel(v string) string {
	switch strings.TrimSpace(v) {
	case "rtsp":
		return "RTSP"
	case "rtmp":
		return "RTMP"
	case "file":
		return "文件"
	case "usb_camera":
		return "USB 摄像头"
	default:
		return strings.TrimSpace(v)
	}
}

func compactJSON(v any) (string, error) {
	body, err := json.Marshal(v)
	if err != nil {
		return "", err
	}
	return string(body), nil
}

func validateTemplateGraphDocument(doc map[string]any) error {
	knownTypes := knownGraphNodeTypes()
	templateMap, ok := doc["template"].(map[string]any)
	if !ok {
		return fmt.Errorf("template must be an object")
	}
	nodes, ok := templateMap["nodes"].([]any)
	if !ok {
		return fmt.Errorf("template.nodes must be an array")
	}
	edges, ok := templateMap["edges"].([]any)
	if !ok {
		return fmt.Errorf("template.edges must be an array")
	}
	seen := map[string]bool{}
	for _, item := range nodes {
		node, ok := item.(map[string]any)
		if !ok {
			return fmt.Errorf("template node must be an object")
		}
		id := strings.TrimSpace(fmt.Sprint(node["id"]))
		if id == "" {
			return fmt.Errorf("template node id is required")
		}
		if seen[id] {
			return fmt.Errorf("duplicate node id: %s", id)
		}
		seen[id] = true
		nodeType := strings.TrimSpace(fmt.Sprint(node["type"]))
		if nodeType == "" {
			return fmt.Errorf("template node type is required: %s", id)
		}
		if !knownTypes[nodeType] {
			return fmt.Errorf("unknown node type: %s", nodeType)
		}
	}
	for _, item := range edges {
		var from, to string
		if edge, ok := item.([]any); ok {
			if len(edge) < 2 {
				return fmt.Errorf("edge must have from and to")
			}
			from = strings.TrimSpace(fmt.Sprint(edge[0]))
			to = strings.TrimSpace(fmt.Sprint(edge[1]))
		} else if edge, ok := item.(map[string]any); ok {
			from = strings.TrimSpace(fmt.Sprint(edge["from"]))
			to = strings.TrimSpace(fmt.Sprint(edge["to"]))
		} else {
			return fmt.Errorf("edge must be an array or object")
		}
		if from == "" || to == "" {
			return fmt.Errorf("edge has empty endpoint")
		}
		if !seen[from] || !seen[to] {
			return fmt.Errorf("edge references unknown node: %s -> %s", from, to)
		}
	}
	return nil
}

func prettyJSON(raw []byte) string {
	var out bytes.Buffer
	if err := json.Indent(&out, raw, "", "  "); err != nil {
		return string(raw)
	}
	return out.String()
}

func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, error) {
	if u.agent == nil || dev == nil {
		return nil, "", nil
	}
	body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil)
	raw := fmt.Sprintf("GET /v1/config/status -> %d\n%s", code, prettyJSON(body))
	if err != nil {
		dev.Online = false
		return nil, raw, err
	}
	if code < 200 || code >= 300 {
		dev.Online = false
		return nil, raw, fmt.Errorf("GET /v1/config/status -> %d", code)
	}
	dev.Online = true
	dev.LastSeenMs = time.Now().UnixMilli()
	var status ConfigStatusView
	if err := json.Unmarshal(body, &status); err != nil {
		return nil, raw, err
	}
	if v := strings.TrimSpace(status.Metadata.InstanceName); v != "" {
		dev.InstanceName = v
	}
	return &status, raw, nil
}

func (u *UI) loadConfigUIData(dev *models.Device) PageData {
	schemaBody, schemaCode, schemaErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/schema", nil)
	stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/state", nil)
	faceBody, faceCode, faceErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/face-gallery", nil)

	data := PageData{
		Title:           "高级识别配置",
		Device:          dev,
		SchemaJSON:      fmt.Sprintf("GET /v1/config/ui/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)),
		StateJSON:       fmt.Sprintf("GET /v1/config/ui/state -> %d\n%s", stateCode, prettyJSON(stateBody)),
		FaceGalleryJSON: fmt.Sprintf("GET /v1/face-gallery -> %d\n%s", faceCode, prettyJSON(faceBody)),
		RawJSON:         strings.TrimSpace(prettyJSON(stateBody)),
	}
	if schemaErr != nil {
		data.Error = schemaErr.Error()
	} else if stateErr != nil {
		data.Error = stateErr.Error()
	} else if faceErr != nil {
		data.Error = faceErr.Error()
	}
	return data
}

func (u *UI) pageDeviceConfigUI(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	u.render(w, r, "config_ui", u.loadConfigUIData(dev))
}

func (u *UI) pageDeviceConfigFriendly(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	u.render(w, r, "config_friendly", PageData{Title: "识别方案配置", Device: dev})
}

func (u *UI) pageDeviceConfigPreview(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	data := u.configPreviewPageData(dev)
	u.render(w, r, "config_preview", data)
}

func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID)
	data := u.configPreviewPageData(dev)
	data.ConfigPreview = preview
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "config_preview", data)
}

func (u *UI) actionDeviceConfigCandidate(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	_ = r.ParseForm()
	raw := strings.TrimSpace(r.FormValue("json"))
	data := u.configPreviewPageData(dev)
	if raw == "" {
		data.Error = "候选配置 JSON 不能为空"
		u.render(w, r, "config_preview", data)
		return
	}
	if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
		data.Error = "候选配置 JSON 无效: " + err.Error()
		u.render(w, r, "config_preview", data)
		return
	}
	data.ConfigPreview = previewResultFromJSON(raw)
	populateSelectionsFromPreview(&data)
	body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config/candidate", []byte(raw))
	data.Message = fmt.Sprintf("PUT /v1/config/candidate -> %d", code)
	data.RawText = prettyJSON(body)
	data.ResultTitle = "候选配置结果"
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "config_preview", data)
}

func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	returnTo := strings.TrimSpace(r.FormValue("return_to"))
	var data PageData
	if returnTo == "control" || returnTo == "config" {
		data = u.deviceDetailPageData(dev)
	} else {
		data = u.configPreviewPageData(dev)
	}
	raw := strings.TrimSpace(r.FormValue("json"))
	if raw != "" {
		data.ConfigPreview = previewResultFromJSON(raw)
		populateSelectionsFromPreview(&data)
	}
	body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`))
	data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code)
	data.RawText = prettyJSON(body)
	data.ResultTitle = "应用候选配置结果"
	if err != nil {
		data.Error = err.Error()
	} else {
		status, _, statusErr := u.loadConfigStatus(dev)
		data.ConfigStatus = status
		if statusErr != nil {
			data.ConfigStatusErr = statusErr.Error()
		} else {
			data.ConfigStatusErr = ""
		}
	}
	if returnTo == "control" || returnTo == "config" {
		u.render(w, r, "device", data)
		return
	}
	u.render(w, r, "config_preview", data)
}

func (u *UI) configPreviewPageData(dev *models.Device) PageData {
	sources, err := u.preview.ListSources()
	data := PageData{
		Title:            "配置预览",
		Device:           dev,
		ConfigSources:    sources,
	}
	if err != nil {
		data.Error = err.Error()
	}
	if dev != nil {
		if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil {
			data.DeviceAssignment = assignment
			data.SelectedAssignmentDevice = assignment.DeviceID
			data.SelectedProfile = assignment.ProfileName
		}
	}
	status, _, statusErr := u.loadConfigStatus(dev)
	data.ConfigStatus = status
	if statusErr != nil {
		data.ConfigStatusErr = statusErr.Error()
	}
	return data
}

func (u *UI) deviceControlPageData(dev *models.Device) PageData {
	data := PageData{
		Title:  "设备控制",
		Device: dev,
	}
	status, raw, statusErr := u.loadConfigStatus(dev)
	data.ConfigStatus = status
	data.ConfigStatusText = raw
	if statusErr != nil {
		data.ConfigStatusErr = statusErr.Error()
	}
	return data
}

func (u *UI) deviceConfigWorkspacePageData(dev *models.Device) PageData {
	data := u.deviceControlPageData(dev)
	data.Title = "配置管理"
	return data
}

func (u *UI) listTemplatesSafe() ([]service.Template, error) {
	if u.templates == nil {
		return nil, nil
	}
	return u.templates.ListTemplates()
}

func cleanFormList(values []string) []string {
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value != "" {
			out = append(out, value)
		}
	}
	return out
}

func parseDeviceAssignmentBoardState(raw string) (map[string][]string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, fmt.Errorf("设备分配数据为空")
	}
	var payload struct {
		Devices map[string][]string `json:"devices"`
	}
	if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		return nil, fmt.Errorf("设备分配数据格式错误")
	}
	if payload.Devices == nil {
		return map[string][]string{}, nil
	}
	out := make(map[string][]string, len(payload.Devices))
	for deviceID, refs := range payload.Devices {
		deviceID = strings.TrimSpace(deviceID)
		if deviceID == "" {
			continue
		}
		out[deviceID] = cleanFormList(refs)
	}
	return out, nil
}

func parseAdvancedParams(raw string) map[string]any {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil
	}
	var out map[string]any
	if err := json.Unmarshal([]byte(raw), &out); err != nil {
		return map[string]any{}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func defaultProfileEditorDraft(templates []service.ConfigTemplateAsset) *service.ConfigProfileEditor {
	templateName := "std_workshop_face_recognition_shoe_alarm"
	if len(templates) > 0 && strings.TrimSpace(templates[0].Name) != "" {
		templateName = strings.TrimSpace(templates[0].Name)
	}
	inst := newProfileInstanceDraft(templateName, "cam1")
	return &service.ConfigProfileEditor{
		Name:         "",
		BusinessName: "",
		Description:  "",
		OverlayName:  "",
		Queue:        service.DefaultConfigProfileQueue(),
		Instances:    []service.ConfigProfileInstanceEditor{inst},
	}
}

func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	data := u.deviceDetailPageData(dev)
	if data.DeviceAssignment == nil {
		data.Error = "请先到通道部署中为该设备指定视频通道"
		u.render(w, r, "device", data)
		return
	}
	if u.tasks == nil {
		data.Error = "task service not initialized"
		u.render(w, r, "device", data)
		return
	}
	preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID)
	data.ConfigPreview = preview
	if err != nil {
		data.Error = err.Error()
		u.render(w, r, "device", data)
		return
	}
	var configDoc any
	if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
		data.Error = "生成配置 JSON 无效: " + err.Error()
		u.render(w, r, "device", data)
		return
	}
	task, err := u.tasks.CreateTask("config_apply", []string{dev.DeviceID}, map[string]any{"config": configDoc})
	if err != nil {
		data.Error = err.Error()
		u.render(w, r, "device", data)
		return
	}
	http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}

func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string {
	used := make(map[string]struct{}, len(instances))
	for _, inst := range instances {
		name := strings.TrimSpace(inst.Name)
		if name == "" {
			continue
		}
		used[name] = struct{}{}
	}
	for i := 1; ; i++ {
		candidate := fmt.Sprintf("cam%d", i)
		if _, ok := used[candidate]; !ok {
			return candidate
		}
	}
}

func newProfileInstanceDraft(templateName string, channelName string) service.ConfigProfileInstanceEditor {
	outputs := map[string]service.OutputBindingEditor{
		"stream_output_main": {
			PublishHLSPath:  "./web/hls/" + channelName + "/index.m3u8",
			PublishRTSPPort: "8555",
			PublishRTSPPath: "/live/" + channelName,
			ChannelNo:       channelName,
		},
	}
	return service.ConfigProfileInstanceEditor{
		Name:            channelName,
		Template:        templateName,
		PublishHLSPath:  outputs["stream_output_main"].PublishHLSPath,
		PublishRTSPPort: outputs["stream_output_main"].PublishRTSPPort,
		PublishRTSPPath: outputs["stream_output_main"].PublishRTSPPath,
		ChannelNo:       outputs["stream_output_main"].ChannelNo,
		OutputBindings:  outputs,
	}
}

func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEditor {
	indices := make([]int, 0)
	seen := map[int]struct{}{}
	for key := range form {
		if !strings.HasPrefix(key, "instances[") {
			continue
		}
		rest := strings.TrimPrefix(key, "instances[")
		end := strings.Index(rest, "]")
		if end <= 0 {
			continue
		}
		idx, err := strconv.Atoi(rest[:end])
		if err != nil {
			continue
		}
		if _, ok := seen[idx]; ok {
			continue
		}
		seen[idx] = struct{}{}
		indices = append(indices, idx)
	}
	sort.Ints(indices)
	out := make([]service.ConfigProfileInstanceEditor, 0, len(indices))
	for _, idx := range indices {
		prefix := fmt.Sprintf("instances[%d].", idx)
		inputBindings := parseInputBindingForm(form, prefix)
		serviceBindings := parseServiceBindingForm(form, prefix)
		outputBindings := parseOutputBindingForm(form, prefix)
		inst := service.ConfigProfileInstanceEditor{
			Name:            strings.TrimSpace(form.Get(prefix + "name")),
			Template:        strings.TrimSpace(form.Get(prefix + "template")),
			VideoSourceRef:  firstString(inputBindingValue(inputBindings, "video_input_main"), strings.TrimSpace(form.Get(prefix+"video_source_ref"))),
			DisplayName:     strings.TrimSpace(form.Get(prefix + "display_name")),
			PublishHLSPath:  firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_hls_path"), strings.TrimSpace(form.Get(prefix+"publish_hls_path"))),
			PublishRTSPPort: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_port"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_port"))),
			PublishRTSPPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_path"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_path"))),
			ChannelNo:       firstString(outputBindingFormValue(outputBindings, "stream_output_main", "channel_no"), strings.TrimSpace(form.Get(prefix+"channel_no"))),
			InputBindings:   inputBindings,
			ServiceBindings: serviceBindings,
			OutputBindings:  outputBindings,
			AdvancedParams:  parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))),
			Delete:          strings.TrimSpace(form.Get(prefix+"delete")) == "1",
		}
		if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete {
			out = append(out, inst)
		}
	}
	if strings.TrimSpace(form.Get("add_instance")) == "1" {
		templateName := "std_workshop_face_recognition_shoe_alarm"
		if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" {
			templateName = strings.TrimSpace(out[0].Template)
		}
		out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out)))
	}
	if len(out) > 0 {
		fallbackTemplate := strings.TrimSpace(out[0].Template)
		if fallbackTemplate == "" {
			fallbackTemplate = "std_workshop_face_recognition_shoe_alarm"
		}
		for i := range out {
			if strings.TrimSpace(out[i].Template) == "" {
				out[i].Template = fallbackTemplate
			}
		}
	}
	return out
}

func parseInputBindingForm(form url.Values, prefix string) map[string]service.InputBindingEditor {
	bindings := map[string]service.InputBindingEditor{}
	for key := range form {
		if !strings.HasPrefix(key, prefix+"input_bindings.") || !strings.HasSuffix(key, ".video_source_ref") {
			continue
		}
		slot := strings.TrimPrefix(key, prefix+"input_bindings.")
		slot = strings.TrimSuffix(slot, ".video_source_ref")
		slot = strings.TrimSpace(slot)
		if slot == "" {
			continue
		}
		value := strings.TrimSpace(form.Get(key))
		if value == "" {
			continue
		}
		bindings[slot] = service.InputBindingEditor{VideoSourceRef: value}
	}
	if len(bindings) == 0 {
		return nil
	}
	return bindings
}

func parseServiceBindingForm(form url.Values, prefix string) map[string]service.ServiceBindingEditor {
	bindings := map[string]service.ServiceBindingEditor{}
	for key := range form {
		if !strings.HasPrefix(key, prefix+"service_bindings.") || !strings.HasSuffix(key, ".service_ref") {
			continue
		}
		slot := strings.TrimPrefix(key, prefix+"service_bindings.")
		slot = strings.TrimSuffix(slot, ".service_ref")
		slot = strings.TrimSpace(slot)
		if slot == "" {
			continue
		}
		value := strings.TrimSpace(form.Get(key))
		if value == "" {
			continue
		}
		bindings[slot] = service.ServiceBindingEditor{ServiceRef: value}
	}
	if len(bindings) == 0 {
		return nil
	}
	return bindings
}

func parseOutputBindingForm(form url.Values, prefix string) map[string]service.OutputBindingEditor {
	bindings := map[string]service.OutputBindingEditor{}
	for key := range form {
		if !strings.HasPrefix(key, prefix+"output_bindings.") {
			continue
		}
		slotField := strings.TrimPrefix(key, prefix+"output_bindings.")
		dot := strings.LastIndex(slotField, ".")
		if dot <= 0 {
			continue
		}
		slot := strings.TrimSpace(slotField[:dot])
		field := strings.TrimSpace(slotField[dot+1:])
		if slot == "" || field == "" {
			continue
		}
		value := strings.TrimSpace(form.Get(key))
		entry := bindings[slot]
		switch field {
		case "publish_hls_path":
			entry.PublishHLSPath = value
		case "publish_rtsp_port":
			entry.PublishRTSPPort = value
		case "publish_rtsp_path":
			entry.PublishRTSPPath = value
		case "channel_no":
			entry.ChannelNo = value
		default:
			continue
		}
		if strings.TrimSpace(entry.PublishHLSPath) == "" && strings.TrimSpace(entry.PublishRTSPPort) == "" &&
			strings.TrimSpace(entry.PublishRTSPPath) == "" && strings.TrimSpace(entry.ChannelNo) == "" {
			continue
		}
		bindings[slot] = entry
	}
	if len(bindings) == 0 {
		return nil
	}
	return bindings
}

func inputBindingValue(bindings map[string]service.InputBindingEditor, slot string) string {
	if len(bindings) == 0 {
		return ""
	}
	return strings.TrimSpace(bindings[slot].VideoSourceRef)
}

func outputBindingFormValue(bindings map[string]service.OutputBindingEditor, slot string, field string) string {
	if len(bindings) == 0 {
		return ""
	}
	item, ok := bindings[slot]
	if !ok {
		return ""
	}
	switch field {
	case "publish_hls_path":
		return strings.TrimSpace(item.PublishHLSPath)
	case "publish_rtsp_port":
		return strings.TrimSpace(item.PublishRTSPPort)
	case "publish_rtsp_path":
		return strings.TrimSpace(item.PublishRTSPPath)
	case "channel_no":
		return strings.TrimSpace(item.ChannelNo)
	default:
		return ""
	}
}

func firstString(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}

func selectedIDsFromQuery(values []string) []string {
	values = cleanFormList(values)
	if len(values) == 0 {
		return nil
	}
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

func filterSelectedDeviceIDs(devices []*models.Device, candidates []string) []string {
	if len(candidates) == 0 || len(devices) == 0 {
		return nil
	}
	known := make(map[string]struct{}, len(devices))
	for _, dev := range devices {
		if dev == nil {
			continue
		}
		id := strings.TrimSpace(dev.DeviceID)
		if id != "" {
			known[id] = struct{}{}
		}
	}
	seen := make(map[string]struct{}, len(candidates))
	out := make([]string, 0, len(candidates))
	for _, id := range candidates {
		id = strings.TrimSpace(id)
		if id == "" {
			continue
		}
		if _, ok := known[id]; !ok {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func selectedQueryString(ids []string) string {
	if len(ids) == 0 {
		return ""
	}
	values := url.Values{}
	for _, id := range ids {
		values.Add("selected", id)
	}
	return values.Encode()
}

func selectedURL(path string, ids []string) string {
	query := selectedQueryString(ids)
	if query == "" {
		return path
	}
	return path + "?" + query
}

func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMsg string) PageData {
	u.ensureDevicesLoaded()
	devices := u.registry.GetDevices()
	rows := make([]DeviceOverviewRow, 0, len(devices))
	for _, dev := range devices {
		row := DeviceOverviewRow{Device: dev}
		status, _, err := u.loadConfigStatus(dev)
		row.ConfigStatus = status
		if err != nil {
			row.ConfigStatusErr = err.Error()
		}
		rows = append(rows, row)
	}
	online := 0
	attention := 0
	for _, d := range devices {
		if d.Online {
			online++
		} else {
			attention++
		}
	}
	failedTasks := 0
	if u.tasks != nil {
		for _, t := range u.tasks.ListTasks() {
			if t.Status == models.TaskFailed {
				failedTasks++
			}
		}
	}
	if selectedIDs == nil {
		selectedIDs = selectedIDsFromQuery(r.URL.Query()["selected"])
	}
	selectedIDs = filterSelectedDeviceIDs(devices, selectedIDs)
	data := PageData{
		Title:              "设备",
		Devices:            devices,
		DeviceRows:         rows,
		DeviceCount:        len(devices),
		OnlineCount:        online,
		OfflineCount:       len(devices) - online,
		RunningTaskCount:   0,
		FailedTaskCount:    failedTasks,
		FoundCount:         attention,
		SelectedDeviceIDs:  selectedIDs,
		SelectedQuery:      selectedQueryString(selectedIDs),
		SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs),
		BatchConfigURL:     selectedURL("/ui/devices/batch-config", selectedIDs),
		ReloadSummary:      batchActionSummary(rows, selectedIDs, "reload"),
		RollbackSummary:    batchActionSummary(rows, selectedIDs, "rollback"),
	}
	if errMsg != "" {
		data.Error = errMsg
	}
	return data
}

func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData {
	data := u.deviceOverviewPageData(r, selectedIDs, "")
	sources, err := u.preview.ListSources()
	data.Title = "下发设备分配"
	data.ConfigSources = sources
	data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs)
	assignments, assignErr := u.preview.ListDeviceAssignments()
	filteredAssignments := make([]service.DeviceAssignmentAsset, 0, len(assignments))
	selectedSet := make(map[string]struct{}, len(data.SelectedDeviceIDs))
	for _, id := range data.SelectedDeviceIDs {
		selectedSet[id] = struct{}{}
	}
	for _, item := range assignments {
		if _, ok := selectedSet[item.DeviceID]; ok {
			filteredAssignments = append(filteredAssignments, item)
		}
	}
	data.DeviceAssignments = filteredAssignments
	if err != nil {
		data.Error = err.Error()
	} else if assignErr != nil {
		data.Error = assignErr.Error()
	}
	return data
}

func selectedDevicesFromIDs(devices []*models.Device, ids []string) []*models.Device {
	if len(devices) == 0 || len(ids) == 0 {
		return nil
	}
	byID := make(map[string]*models.Device, len(devices))
	for _, dev := range devices {
		if dev == nil {
			continue
		}
		byID[strings.TrimSpace(dev.DeviceID)] = dev
	}
	selected := make([]*models.Device, 0, len(ids))
	for _, id := range ids {
		if dev := byID[strings.TrimSpace(id)]; dev != nil {
			selected = append(selected, dev)
		}
	}
	return selected
}

func previewResultFromJSON(raw string) *service.ConfigPreviewResult {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil
	}
	var doc map[string]any
	if err := json.Unmarshal([]byte(raw), &doc); err != nil {
		return nil
	}
	metadata, _ := doc["metadata"].(map[string]any)
	return &service.ConfigPreviewResult{
		JSON:     raw,
		Metadata: metadata,
		Size:     len([]byte(raw)),
	}
}

func populateSelectionsFromPreview(data *PageData) {
	if data == nil || data.ConfigPreview == nil {
		return
	}
	if metadata := data.ConfigPreview.Metadata; metadata != nil {
		if v, _ := metadata["template"].(string); strings.TrimSpace(v) != "" {
			data.SelectedTemplate = v
		}
		if v, _ := metadata["profile"].(string); strings.TrimSpace(v) != "" {
			data.SelectedProfile = v
		}
		if v, _ := metadata["config_id"].(string); strings.TrimSpace(v) != "" {
			data.SelectedConfigID = v
		}
		if v, _ := metadata["config_version"].(string); strings.TrimSpace(v) != "" {
			data.SelectedVersion = v
		}
		if items, ok := metadata["overlays"].([]any); ok {
			overlays := make([]string, 0, len(items))
			for _, item := range items {
				if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
					overlays = append(overlays, s)
				}
			}
			if len(overlays) > 0 {
				data.SelectedOverlays = overlays
			}
		}
	}
}

func profileAssetTemplate(asset *service.ConfigProfileAsset) string {
	if asset == nil {
		return ""
	}
	for _, item := range asset.Instances {
		if v := strings.TrimSpace(item.Template); v != "" {
			return v
		}
	}
	return ""
}

func profileAssetBusinessName(asset *service.ConfigProfileAsset) string {
	if asset == nil {
		return ""
	}
	if v := strings.TrimSpace(asset.BusinessName); v != "" {
		return v
	}
	return strings.TrimSpace(asset.Name)
}

func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action string) string {
	if len(selectedIDs) == 0 {
		return ""
	}
	rowByID := make(map[string]DeviceOverviewRow, len(rows))
	for _, row := range rows {
		if row.Device == nil {
			continue
		}
		rowByID[strings.TrimSpace(row.Device.DeviceID)] = row
	}
	lines := make([]string, 0, len(selectedIDs))
	for _, id := range selectedIDs {
		row, ok := rowByID[strings.TrimSpace(id)]
		if !ok || row.Device == nil {
			continue
		}
		label := row.Device.DisplayName()
		switch action {
		case "reload":
			summary := "未取到当前运行配置"
			if row.ConfigStatus != nil {
				meta := row.ConfigStatus.Metadata
				if name := strings.TrimSpace(meta.BusinessName); name != "" {
					summary = name
					if profile := strings.TrimSpace(meta.Profile); profile != "" {
						summary += " (" + profile + ")"
					}
				} else if profile := strings.TrimSpace(meta.Profile); profile != "" {
					summary = profile
				} else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
					summary = configID
				}
			}
			lines = append(lines, label+" -> "+summary)
		case "rollback":
			summary := "未取到可回滚运行配置"
			if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil {
				meta := row.ConfigStatus.PreviousConfig.Metadata
				if name := strings.TrimSpace(meta.BusinessName); name != "" {
					summary = name
					if profile := strings.TrimSpace(meta.Profile); profile != "" {
						summary += " (" + profile + ")"
					}
				} else if profile := strings.TrimSpace(meta.Profile); profile != "" {
					summary = profile
				} else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
					summary = configID
				}
			}
			lines = append(lines, label+" -> "+summary)
		}
	}
	return strings.Join(lines, "；")
}

func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	_ = r.ParseForm()
	raw := strings.TrimSpace(r.FormValue("json"))
	if raw == "" {
		raw = `{"instances":[]}`
	}
	if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
		data := u.loadConfigUIData(dev)
		data.Error = "json 无效: " + err.Error()
		data.RawJSON = raw
		u.render(w, r, "config_ui", data)
		return
	}

	body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/plan", []byte(raw))
	data := u.loadConfigUIData(dev)
	data.Message = fmt.Sprintf("POST /v1/config/ui/plan -> %d", code)
	data.RawText = prettyJSON(body)
	data.RawJSON = raw
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "config_ui", data)
}

func (u *UI) actionDeviceConfigUIApply(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	_ = r.ParseForm()
	raw := strings.TrimSpace(r.FormValue("json"))
	if raw == "" {
		raw = `{"instances":[]}`
	}
	if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
		data := u.loadConfigUIData(dev)
		data.Error = "json 无效: " + err.Error()
		data.RawJSON = raw
		u.render(w, r, "config_ui", data)
		return
	}

	body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/apply", []byte(raw))
	data := u.loadConfigUIData(dev)
	data.Message = fmt.Sprintf("POST /v1/config/ui/apply -> %d", code)
	data.RawText = prettyJSON(body)
	data.RawJSON = raw
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "config_ui", data)
}

func (u *UI) actionDeviceFaceGalleryUpload(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	if err := r.ParseMultipartForm(500 << 20); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	file, hdr, err := r.FormFile("file")
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	defer file.Close()

	resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/face-gallery", file, "application/octet-stream", hdr.Size)
	data := u.loadConfigUIData(dev)
	data.Message = fmt.Sprintf("PUT /v1/face-gallery -> %d", code)
	data.RawText = prettyJSON(resp)
	if derr != nil {
		data.Error = derr.Error()
	}
	u.render(w, r, "config_ui", data)
}

func (u *UI) actionDeviceFaceGalleryReload(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	dev, ok := u.findDevice(id)
	if !ok {
		http.NotFound(w, r)
		return
	}
	resp, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/face-gallery/reload", nil)
	data := u.loadConfigUIData(dev)
	data.Message = fmt.Sprintf("POST /v1/face-gallery/reload -> %d", code)
	data.RawText = prettyJSON(resp)
	if err != nil {
		data.Error = err.Error()
	}
	u.render(w, r, "config_ui", data)
}
