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
    }
    if data.AssetIntegration != nil && data.AssetIntegration.Type == "custom" {
        if configRaw, ok := data.AssetIntegration.Raw["config"]; ok {
            if s, err := compactJSON(configRaw); err == nil {
                data.IntegrationConfigDraft = s
            }
        }
        if data.IntegrationConfigDraft == "" {
            data.IntegrationConfigDraft = "{}"
        }
    }
    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")
    serviceType := strings.TrimSpace(r.FormValue("type"))
    asset := service.ConfigIntegrationServiceAsset{
        Name:        strings.TrimSpace(r.FormValue("name")),
        Type:        serviceType,
        Description: strings.TrimSpace(r.FormValue("description")),
        Enabled:     enabled,
        ObjectStorage: &service.ObjectStorageConfig{},
        AlarmService:  &service.AlarmServiceConfig{},
    }
    if serviceType == "custom" {
        configRaw := strings.TrimSpace(r.FormValue("config_json"))
        if configRaw == "" {
            configRaw = "{}"
        }
        var raw map[string]any
        if err := json.Unmarshal([]byte(configRaw), &raw); err != nil {
            http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape("自定义配置 JSON 格式不正确："+err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
            return
        }
        asset.Raw = raw
    } else {
        asset.ObjectStorage.Endpoint = strings.TrimSpace(r.FormValue("endpoint"))
        asset.ObjectStorage.Bucket = strings.TrimSpace(r.FormValue("bucket"))
        asset.ObjectStorage.AccessKey = strings.TrimSpace(r.FormValue("access_key"))
        asset.ObjectStorage.SecretKey = strings.TrimSpace(r.FormValue("secret_key"))
        asset.AlarmService.GetTokenURL = strings.TrimSpace(r.FormValue("get_token_url"))
        asset.AlarmService.PutMessageURL = strings.TrimSpace(r.FormValue("put_message_url"))
        asset.AlarmService.TenantCode = strings.TrimSpace(r.FormValue("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"
    cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source"))
    cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name"))
    if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" && !newMode {
        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 && cloneSource != "" {
        if item, err := u.preview.GetOverlayAsset(cloneSource); err == nil {
            rawJSON, err := json.Marshal(item.Raw)
            if err == nil {
                doc := map[string]any{}
                if err := json.Unmarshal(rawJSON, &doc); err == nil {
                    doc["name"] = cloneName
                    doc["description"] = item.Description
                    data.AssetOverlay = &service.ConfigOverlayAsset{
                        Name:        cloneName,
                        Description: item.Description,
                        Raw:         doc,
                    }
                }
            }
        }
        if data.AssetOverlay == nil {
            newMode = false
            data.Error = "无法加载源调试参数"
        }
    }
    if newMode {
        data.AssetOverlayEditing = data.AssetOverlay != nil
    } else {
        data.AssetOverlayEditing = editMode && data.AssetOverlay != nil && !data.AssetOverlay.ReadOnly
    }
    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) actionAssetOverlayClone(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.GetOverlayAsset(sourceName)
    if err != nil || item == nil {
        http.NotFound(w, r)
        return
    }
    if !item.ReadOnly {
        http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound)
        return
    }
    targetName, err := u.nextOverlayCloneName(item.Name)
    if err != nil {
        http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
        return
    }
    cloneSource := url.QueryEscape(sourceName)
    cloneName := url.QueryEscape(targetName)
    http.Redirect(w, r, "/ui/assets/overlays?new=1&clone_source="+cloneSource+"&clone_name="+cloneName, http.StatusFound)
}

func (u *UI) nextOverlayCloneName(sourceName string) (string, error) {
    base := strings.TrimSpace(sourceName)
    if base == "" {
        return "", fmt.Errorf("调试参数名称不能为空")
    }
    if strings.HasPrefix(base, "std_") {
        base = strings.TrimPrefix(base, "std_")
    }
    candidate := base + "_copy"
    if item, err := u.preview.GetOverlayAsset(candidate); err == nil && item != nil {
        for i := 2; i < 1000; i++ {
            name := fmt.Sprintf("%s_copy_%d", base, i)
            item, err := u.preview.GetOverlayAsset(name)
            if err != nil || item == nil {
                return name, nil
            }
        }
        return "", fmt.Errorf("无法生成可用的调试参数副本名称")
    }
    return candidate, nil
}

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)
}

func (u *UI) pageMonitor(w http.ResponseWriter, r *http.Request) {
    u.ensureDevicesLoaded()
    u.render(w, r, "monitor", PageData{Title: "视频监控", Devices: u.registry.GetDevices()})
}

func (u *UI) apiMonitorChannels(w http.ResponseWriter, r *http.Request) {
    deviceID := r.URL.Query().Get("device_id")
    if deviceID == "" {
        http.Error(w, "missing device_id", http.StatusBadRequest)
        return
    }
    dev, ok := u.findDevice(deviceID)
    if !ok {
        http.Error(w, "device not found", http.StatusNotFound)
        return
    }
    body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/preview/channels", nil)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    w.Write(body)
}

func (u *UI) proxyHLS(w http.ResponseWriter, r *http.Request) {
    // URL format: /hls/{deviceID}/{hls_path}
    path := strings.TrimPrefix(r.URL.Path, "/hls/")
    idx := strings.Index(path, "/")
    if idx < 0 {
        http.Error(w, "invalid path", http.StatusBadRequest)
        return
    }
    deviceID := path[:idx]
    hlsPath := path[idx+1:]
    dev, ok := u.findDevice(deviceID)
    if !ok {
        http.Error(w, "device not found", http.StatusNotFound)
        return
    }
    body, code, err := u.agent.Do("GET", dev.IP, dev.MediaPort, "/"+hlsPath, nil)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    // Set HLS-friendly headers
    if strings.HasSuffix(hlsPath, ".m3u8") {
        w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
        w.Header().Set("Access-Control-Allow-Origin", "*")
    } else if strings.HasSuffix(hlsPath, ".ts") {
        w.Header().Set("Content-Type", "video/mp2t")
        w.Header().Set("Access-Control-Allow-Origin", "*")
    }
    w.Header().Set("Cache-Control", "no-cache")
    w.WriteHeader(code)
    w.Write(body)
}


func (u *UI) actionFaceGalleryAdd(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseMultipartForm(10 << 20); err != nil {
        http.Error(w, "invalid form", http.StatusBadRequest)
        return
    }
    name := strings.TrimSpace(r.FormValue("name"))
    if name == "" {
        http.Error(w, "name required", http.StatusBadRequest)
        return
    }
    // Save photo to dataset directory
    datasetDir := filepath.Join("dataset")
    personDir := filepath.Join(datasetDir, name)
    if err := os.MkdirAll(personDir, 0o755); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    file, hdr, err := r.FormFile("photo")
    if err != nil {
        http.Error(w, "photo required", http.StatusBadRequest)
        return
    }
    defer file.Close()
    dst := filepath.Join(personDir, hdr.Filename)
    f, err := os.Create(dst)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer f.Close()
    io.Copy(f, file)
    http.Redirect(w, r, "/ui/face-gallery?msg=ok", http.StatusFound)
}

func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) {
    addFaceDir := filepath.Join("..", "AddFaceTo3588")
    pythonExe := filepath.Join(addFaceDir, ".venv", "Scripts", "python.exe")
    // Fallback to system python if venv not available
    if _, err := os.Stat(pythonExe); os.IsNotExist(err) {
        pythonExe = "python"
    }
    builder := &service.FaceGalleryBuilder{
        PythonExe:        pythonExe,
        BuildScript:      filepath.Join("tools", "build_gallery.py"),
        DetModel:         filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"),
        RecogModel:       filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"),
        DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"),
        DatasetDir:       filepath.Join("dataset"),
        OutputDB:         filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"),
    }
    output, err := builder.Build()
    if err != nil {
        msg := fmt.Sprintf("生成失败: %v", err)
        http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
        return
    }
    // Parse key stats from output for a clean message
    persons := "0"
    photos := "0"
    for _, line := range strings.Split(output, "\n") {
        if strings.HasPrefix(line, "enrolled_persons:") {
            persons = strings.TrimSpace(strings.TrimPrefix(line, "enrolled_persons:"))
        }
        if strings.HasPrefix(line, "ok_images:") {
            photos = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:"))
        }
    }
    msg := fmt.Sprintf("生成成功，共 %s 人，%s 张照片", persons, photos)
    http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
}

func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) {
    data := PageData{Title: "人脸库管理"}
    data.Message = r.URL.Query().Get("msg")
    if strings.TrimSpace(u.dbPath) != "" {
        if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil {
            if persons, err := repo.ListPersons(); err == nil {
                data.FaceGalleryPersons = persons
            }
        }
    }
    u.render(w, r, "face_gallery", data)
}

func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.FormValue("id"))
    if id <= 0 {
        http.Error(w, "invalid", http.StatusBadRequest)
        return
    }
    if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath)
    repo.DeletePerson(id)
    }
    http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
}


func (u *UI) actionFaceGalleryImport(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseMultipartForm(100 << 20); err != nil {
        http.Error(w, "form too large", http.StatusBadRequest)
        return
    }
    datasetDir := filepath.Join("dataset")
    count := 0
    for _, files := range r.MultipartForm.File {
        for _, hdr := range files {
            fp := fullFilename(hdr)
            personName := personNameFromPath(fp)
            fileName := filepath.Base(fp)
            if personName == "" || fileName == "" {
                continue
            }
            personDir := filepath.Join(datasetDir, personName)
            os.MkdirAll(personDir, 0o755)
            dst := filepath.Join(personDir, fileName)
            // Skip if file already exists
            if _, err := os.Stat(dst); err == nil {
                continue
            }
            file, err := hdr.Open()
            if err != nil { continue }
            f, err := os.Create(dst)
            if err != nil { file.Close(); continue }
            io.Copy(f, file)
            f.Close()
            file.Close()
            count++
            // Register in DB
            if strings.TrimSpace(u.dbPath) != "" {
                repo := storage.NewFaceGalleryRepo(u.dbPath)
                pid, _ := repo.FindOrCreatePerson(personName)
                repo.AddPhoto(pid, personName+"/"+fileName)
            }
        }
    }
    http.Redirect(w, r, fmt.Sprintf("/ui/face-gallery?msg=已导入 %d 张照片", count), http.StatusFound)
}

func (u *UI) rebuildFaceGallery() {
    builder := &service.FaceGalleryBuilder{
        PythonExe:        "python",
        BuildScript:      filepath.Join("tools", "build_gallery.py"),
        DetModel:         filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"),
        RecogModel:       filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"),
        DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"),
        DatasetDir:       filepath.Join("dataset"),
        OutputDB:         filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"),
    }
    builder.Build()
}

// fullFilename extracts the full relative path from a webkitdirectory file upload,
// bypassing Go 1.20+ sanitization that strips directory components.
func fullFilename(hdr *multipart.FileHeader) string {
    cd := hdr.Header.Get("Content-Disposition")
    _, params, err := mime.ParseMediaType(cd)
    if err == nil {
        if name := params["filename"]; name != "" {
            return name
        }
    }
    return hdr.Filename
}

// personNameFromPath extracts the person name from a webkitdirectory path like "李清/photo.jpg".
func personNameFromPath(path string) string {
    path = strings.ReplaceAll(path, "\\", "/")
    parts := strings.Split(path, "/")
    if len(parts) < 2 {
        return ""
    }
    return strings.TrimSpace(parts[len(parts)-2])
}

func (u *UI) serveFacePhoto(w http.ResponseWriter, r *http.Request) {
    path := chi.URLParam(r, "*")
    if path == "" || strings.Contains(path, "..") {
        http.Error(w, "invalid", http.StatusBadRequest)
        return
    }
    fullPath := filepath.Join("dataset", path)
    http.ServeFile(w, r, fullPath)
}

func (u *UI) actionFaceGalleryRename(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.FormValue("id"))
    name := strings.TrimSpace(r.FormValue("name"))
    if id <= 0 || name == "" {
        http.Error(w, "invalid", http.StatusBadRequest)
        return
    }
    if strings.TrimSpace(u.dbPath) != "" {
        storage.NewFaceGalleryRepo(u.dbPath).RenamePerson(id, name)
    }
    http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
}

func (u *UI) actionFaceGalleryAddPhoto(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "form error", http.StatusBadRequest); return }
    id, _ := strconv.Atoi(r.FormValue("id"))
    if id <= 0 { http.Error(w, "invalid id", http.StatusBadRequest); return }
    file, hdr, err := r.FormFile("photo")
    if err != nil { http.Error(w, "photo required", http.StatusBadRequest); return }
    defer file.Close()
    // Get person name for directory
    if strings.TrimSpace(u.dbPath) == "" { http.Redirect(w, r, "/ui/face-gallery", http.StatusFound); return }
    repo := storage.NewFaceGalleryRepo(u.dbPath)
    persons, _ := repo.ListPersons()
    var personName string
    for _, p := range persons { if p.ID == id { personName = p.Name; break } }
    if personName == "" { http.Error(w, "person not found", http.StatusNotFound); return }
    personDir := filepath.Join("dataset", personName)
    os.MkdirAll(personDir, 0o755)
    dst := filepath.Join(personDir, hdr.Filename)
    f, err := os.Create(dst)
    if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
    defer f.Close()
    io.Copy(f, file)
    repo.AddPhoto(id, personName+"/"+hdr.Filename)
    http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
}
func (u *UI) actionFaceGalleryDeletePhoto(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.FormValue("id"))
    if id > 0 && strings.TrimSpace(u.dbPath) != "" {
        storage.NewFaceGalleryRepo(u.dbPath).DeletePhoto(id)
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
func (u *UI) apiDeviceMetrics(w http.ResponseWriter, r *http.Request) {
    deviceID := r.URL.Query().Get("device_id")
    if deviceID == "" { http.Error(w, "missing device_id", http.StatusBadRequest); return }
    dev, ok := u.findDevice(deviceID)
    if !ok { http.Error(w, "device not found", http.StatusNotFound); return }
    body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
    if err != nil { http.Error(w, err.Error(), http.StatusBadGateway); return }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    w.Write(body)
}

type DeviceMetric struct {
    Name        string  `json:"name"`
    CPU         float64 `json:"cpu"`
    Mem         float64 `json:"mem"`
    NPU         float64 `json:"npu"`
    Disk        float64 `json:"disk"`
    Temperature float64 `json:"temperature"`
}

type ConsoleDeviceData struct {
    Device      *models.Device
    CPUUsage    float64
    MemUsage    float64
    NPUUsage    float64
    DiskUsage   float64
    Temperature float64
    MaxUnits    int
    Channels    []ConsoleChannel
    Features    []ConsoleFeature
    Cameras     []string
    Busy        bool
    BusyMsg     string
}

type ConsoleChannel struct {
    Name       string
    Display    string
    SourceName string
    URL        string
}

type ConsoleFeature struct {
    Key         string
    Label       string
    Description string
    Enabled     bool
    Available   bool // device actually supports this (from agent /v1/capabilities)
    Conflict    bool // enabled in DB but unavailable on device
}
