package web

import (
    "bytes"
    "encoding/json"
    "fmt"
    "html/template"
    "io"
    "mime"
    "mime/multipart"
    "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
    alarmCollector *service.AlarmCollector
    autoConfig     *service.AutoConfigService

    tpl *template.Template
}

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

const version = "1.0"

type PageData struct {
    Title       string
    Version     string
    Year        int
    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
    AlarmRecords             []service.AlarmRecord
    TotalAlarmCount          int
    TotalAlarmPages          int
    PageNumbers              []int
    CurrentPage              int
    PerPage                  int
    SelectedFrom             string
    SelectedTo               string
    WeekStart                string
    WeekEnd                  string
    MonthStart               string
    MonthEnd                 string
    TodayAlarmCount          int
    DeviceMetrics            []DeviceMetric
    ConsoleDevices           []ConsoleDeviceData
    ConsoleVideoSources      []service.ConfigVideoSourceAsset
    ConsoleAllVideoSources   []service.ConfigVideoSourceAsset
    ConsoleUnassignedUnits   []service.RecognitionUnitAsset
    FaceGalleryPersons      []storage.PersonRecord
    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
    TemplateCloneName        string
    TemplateCloneDesc        string
    TemplateCreateMode       string
    OverlayDraftJSON         string
    IntegrationConfigDraft   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
            }
        },
        "ruleLabel": func(v string) string {
            switch strings.TrimSpace(v) {
            case "unknown_face":
                return "陌生人员告警"
            case "known_person":
                return "已登记人员"
            case "non_compliant_workshoe":
                return "未穿劳保鞋"
            case "non_black_shoe":
                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 "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 ""
            }
        },
        "sub": func(a, b int) int {
            return a - b
        },
        "loop": func(count int) []int {
            var r []int
            for i := 0; i < count; i++ {
                r = append(r, i)
            }
            return r
        },
        "mul": func(a, b float64) float64 {
            return a * b
        },
        "div": func(a, b float64) float64 {
            if b == 0 {
                return 0
            }
            return a / b
        },
        "shortID": func(v string) string {
            if len(v) > 8 {
                return v[:8]
            }
            return v
        },
        "add": func(a, b int) int {
            return a + b
        },
    }).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 (u *UI) SetAlarmCollector(ac *service.AlarmCollector) {
    if u == nil {
        return
    }
    u.alarmCollector = ac
}

func (u *UI) SetAutoConfig(ac *service.AutoConfigService) {
    if u == nil {
        return
    }
    u.autoConfig = ac
}

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("/console", u.pageConsole)
    r.Post("/console", u.actionConsoleSave)
    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}/clone", u.actionAssetOverlayClone)
    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("/alarms", u.pageAlarms)
    r.Get("/face-gallery", u.pageFaceGallery)
    r.Get("/face-photo/*", u.serveFacePhoto)
    r.Post("/face-gallery/import", u.actionFaceGalleryImport)
    r.Post("/face-gallery/build", u.actionFaceGalleryBuild)
    r.Post("/face-gallery/add", u.actionFaceGalleryAdd)
    r.Post("/face-gallery/delete", u.actionFaceGalleryDelete)
    r.Post("/face-gallery/rename", u.actionFaceGalleryRename)
    r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto)
    r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto)
    r.Get("/monitor", u.pageMonitor)
    r.Get("/hls/*", u.proxyHLS)
    r.Get("/api/monitor/channels", u.apiMonitorChannels)
    r.Get("/api/device-metrics", u.apiDeviceMetrics)
    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) {
    data.Version = version
    data.Year = time.Now().Year()
    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 = "总览"
    if u.alarmCollector != nil {
        data.AlarmRecords = u.alarmCollector.GetRecent(5)
        all := u.alarmCollector.GetRecent(9999)
        today := time.Now().Format("2006-01-02")
        for _, a := range all {
            if strings.HasPrefix(a.Timestamp, today) {
                data.TodayAlarmCount++
            }
        }
    }
    data.Tasks = nil
    if u.tasks != nil {
        data.Tasks = u.tasks.ListTasks()
    }
    data.AttentionDevices = nil
    // Load device metrics
    if u.agent != nil {
        for _, dev := range data.Devices {
            if dev == nil || !dev.Online { continue }
            body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
            if err != nil || code != 200 { continue }
            var m struct {
                CPU    struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"`
                Memory struct{ TotalKB uint64 `json:"total_kb"`; AvailableKB uint64 `json:"available_kb"` } `json:"memory"`
                NPU struct {
                    UsagePct float64            `json:"usage_pct"`
                    Cores    map[string]float64 `json:"cores"`
                } `json:"npu"`
                Disk        struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"`
                Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"`
            }
            json.Unmarshal(body, &m)
            var mem float64
            if m.Memory.TotalKB > 0 { mem = float64(m.Memory.TotalKB-m.Memory.AvailableKB) / float64(m.Memory.TotalKB) * 100 }
            npuVal := m.NPU.UsagePct
            if len(m.NPU.Cores) > 0 {
                for _, v := range m.NPU.Cores {
                    if v > npuVal {
                        npuVal = v
                    }
                }
            }
            data.DeviceMetrics = append(data.DeviceMetrics, DeviceMetric{
                Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: mem, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius,
            })
        }
    }
    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) pageConsole(w http.ResponseWriter, r *http.Request) {
    u.ensureDevicesLoaded()
    data := PageData{Title: "管控台"}
    data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
    devices := u.registry.GetDevices()
    data.Devices = devices

    // Load video sources
    var allSources []service.ConfigVideoSourceAsset
    if u.preview != nil {
        if items, err := u.preview.ListVideoSources(); err == nil {
            allSources = items
        }
    }

    // Load recognition units and device assignments
    var units []service.RecognitionUnitAsset
    var assignments []service.DeviceAssignmentAsset
    if u.preview != nil {
        if items, err := u.preview.ListRecognitionUnits(); err == nil {
            units = items
        }
        if items, err := u.preview.ListDeviceAssignments(); err == nil {
            assignments = items
        }
    }

    // Build device->unit mapping
    assignedUnits := map[string][]service.RecognitionUnitAsset{}
    for _, a := range assignments {
        for _, ref := range a.RecognitionUnits {
            for _, u := range units {
                if u.Ref == ref {
                    assignedUnits[a.DeviceID] = append(assignedUnits[a.DeviceID], u)
                    break
                }
            }
        }
    }

    // Track used video sources
    usedSources := map[string]bool{}
    for _, unitList := range assignedUnits {
        for _, u := range unitList {
            if u.VideoSourceRef != "" {
                usedSources[u.VideoSourceRef] = true
            }
        }
    }

    // Build source name lookup
    sourceNames := map[string]string{}
    for _, s := range allSources {
        sourceNames[s.Name] = s.Name
    }

    // Query each device's capabilities from agent to filter available features.
    deviceAvailableFeatures := map[string][]ConsoleFeature{}
    if u.agent != nil {
        for _, dev := range devices {
            if dev == nil || !dev.Online || dev.DeviceID == "" {
                continue
            }
            body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil)
            if err != nil || code != 200 {
                continue
            }
            var capsResp struct {
                Capabilities []struct {
                    Key         string `json:"key"`
                    Label       string `json:"label"`
                    Available   bool   `json:"available"`
                    Description string `json:"description"`
                } `json:"capabilities"`
            }
            if json.Unmarshal(body, &capsResp) != nil {
                continue
            }
            features := make([]ConsoleFeature, 0, len(capsResp.Capabilities))
            for _, c := range capsResp.Capabilities {
                features = append(features, ConsoleFeature{
                    Key: c.Key, Label: c.Label, Description: c.Description,
                    Available: c.Available, Enabled: false,
                })
            }
            deviceAvailableFeatures[dev.DeviceID] = features
        }
    }

    // Load persisted device features as the source of truth.
    // This represents what capabilities the device is configured for,
    // not just what the last deployment used.
    persistedFeatures := map[string]map[string]bool{}
    if u.preview != nil {
        for _, dev := range devices {
            if dev == nil || dev.DeviceID == "" {
                continue
            }
            if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 {
                m := map[string]bool{}
                for _, f := range feats {
                    m[f] = true
                }
                persistedFeatures[dev.DeviceID] = m
            }
        }
    }

    // Load device metrics (NPU, etc)
    metrics := map[string]DeviceMetric{}
    if u.agent != nil {
        for _, dev := range devices {
            if dev == nil || !dev.Online {
                continue
            }
            body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
            if err != nil || code != 200 {
                continue
            }
            var m struct {
                CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"`
                Memory struct {
                    TotalKB     float64 `json:"total_kb"`
                    AvailableKB float64 `json:"available_kb"`
                } `json:"memory"`
                NPU struct {
                    UsagePct float64            `json:"usage_pct"`
                    Cores    map[string]float64 `json:"cores"`
                } `json:"npu"`
                Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"`
                Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"`
            }
            json.Unmarshal(body, &m)
            npuVal := m.NPU.UsagePct
            // Pick max core if per-core data available
            if len(m.NPU.Cores) > 0 {
                for _, v := range m.NPU.Cores {
                    if v > npuVal {
                        npuVal = v
                    }
                }
            }
            memPct := 0.0
            if m.Memory.TotalKB > 0 {
                memPct = (m.Memory.TotalKB - m.Memory.AvailableKB) / m.Memory.TotalKB * 100
            }
            metrics[dev.DeviceID] = DeviceMetric{
                Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: memPct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius,
            }
        }
    }

    // Build console device data
    var consoleDevices []ConsoleDeviceData
    for _, dev := range devices {
        if dev == nil {
            continue
        }
        maxUnits := 4

        // Gather channels (one per assigned recognition unit)
        var channels []ConsoleChannel
        var cameras []string
        for _, u := range assignedUnits[dev.DeviceID] {
            // HLS channel name matches recognition unit Name (used in publish path)
            chName := u.Name
            displayName := u.Name
            if u.DisplayName != "" {
                displayName = u.DisplayName
            }
            srcName := u.VideoSourceRef
            if srcName == "" {
                srcName = displayName
            }
            channels = append(channels, ConsoleChannel{Name: chName, Display: displayName, SourceName: srcName})
            if u.VideoSourceRef != "" {
                cameras = append(cameras, u.VideoSourceRef)
            }
        }

        // Determine which features are enabled from persisted device features.
        activeKeys := persistedFeatures[dev.DeviceID]
        if activeKeys == nil {
            activeKeys = map[string]bool{}
        }

        // Features from device capabilities (from agent /v1/capabilities).
        // Overlay persisted DB state for checkmarks and conflict detection.
        available := deviceAvailableFeatures[dev.DeviceID]
        var features []ConsoleFeature
        if len(available) > 0 {
            for _, f := range available {
                f.Enabled = activeKeys[f.Key]
                // Conflict: user enabled a feature that the device no longer supports.
                if f.Enabled && !f.Available {
                    f.Conflict = true
                }
                features = append(features, f)
            }
            // Show persisted features that the agent didn't report (e.g. new features
            // added to registry but old agent doesn't know them).
            seen := map[string]bool{}
            for _, f := range features {
                seen[f.Key] = true
            }
            for key := range activeKeys {
                if !seen[key] {
                    features = append(features, ConsoleFeature{
                        Key: key, Label: key, Enabled: true, Available: false, Conflict: true,
                    })
                }
            }
        } else {
            // Old agent: show persisted keys.
            for key := range activeKeys {
                features = append(features, ConsoleFeature{Key: key, Label: key, Enabled: true, Available: true})
            }
        }

        npu, diskUsage, temp, cpuUsage, memUsage := 0.0, 0.0, 0.0, 0.0, 0.0
        if m, ok := metrics[dev.DeviceID]; ok {
            cpuUsage = m.CPU
            memUsage = m.Mem
            npu = m.NPU
            diskUsage = m.Disk
            temp = m.Temperature
        }
        consoleDevices = append(consoleDevices, ConsoleDeviceData{
            Device:      dev,
            CPUUsage:    cpuUsage,
            MemUsage:    memUsage,
            NPUUsage:    npu,
            DiskUsage:   diskUsage,
            Temperature: temp,
            MaxUnits:    maxUnits,
            Channels:    channels,
            Features:    features,
            Cameras:     cameras,
        })
    }

    // Unassigned video sources
    var unassigned []service.ConfigVideoSourceAsset
    for _, s := range allSources {
        if !usedSources[s.Name] {
            unassigned = append(unassigned, s)
        }
    }

    data.ConsoleDevices = consoleDevices
    data.ConsoleVideoSources = unassigned
    data.ConsoleAllVideoSources = allSources

    u.render(w, r, "console", data)
}

func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseForm(); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    if u.autoConfig == nil || u.preview == nil {
        http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound)
        return
    }

    u.ensureDevicesLoaded()
    devices := u.registry.GetDevices()

    // Step 1: collect form features per device.
    formFeatures := map[string][]string{}
    for _, dev := range devices {
        if dev == nil || dev.DeviceID == "" {
            continue
        }
        features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"])
        if len(features) > 0 {
            formFeatures[dev.DeviceID] = features
        }
    }

    // Step 2: build requests using form features (user intent) and
    // existing recognition unit video sources.
    var requests []service.AutoConfigRequest
    for _, dev := range devices {
        if dev == nil || dev.DeviceID == "" {
            continue
        }
        features := formFeatures[dev.DeviceID]
        var sources []string
        if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil {
            for _, ref := range assignment.RecognitionUnits {
                if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil {
                    if unit.VideoSourceRef != "" {
                        sources = append(sources, unit.VideoSourceRef)
                    }
                }
            }
        }
        if len(features) == 0 {
            continue
        }
        requests = append(requests, service.AutoConfigRequest{
            DeviceID:    dev.DeviceID,
            Features:    features,
            SourceNames: sources,
        })
    }

    if len(requests) == 0 {
        http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("未选择检测功能"), http.StatusFound)
        return
    }

    // Step 3: save features to DB BEFORE creating tasks (avoids SQLITE_BUSY race).
    for _, req := range requests {
        _ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features)
    }

    // Step 4: run pipeline (creates tasks asynchronously).
    results, _ := u.autoConfig.BuildPipelineBatch(requests, true)

    // Collect task IDs for redirect.
    taskIDs := make([]string, 0)
    var errs []string
    for _, res := range results {
        if res.TaskID != "" {
            taskIDs = append(taskIDs, res.TaskID)
        }
        if res.Error != "" {
            errs = append(errs, res.DeviceID+": "+res.Error)
        }
    }

    if len(taskIDs) == 1 {
        http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound)
        return
    }
    if len(taskIDs) > 1 {
        http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound)
        return
    }
    if len(errs) > 0 {
        http.Redirect(w, r, "/ui/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound)
        return
    }
    http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound)
}

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