package web

import (
	"3588AdminBackend/internal/config"
	"3588AdminBackend/internal/models"
	"3588AdminBackend/internal/service"
	"3588AdminBackend/internal/storage"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"mime/multipart"
	"net"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
)

func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) {
	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	reg := service.NewRegistryService(cfg, nil)
	reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1", AgentPort: 9100, Online: true})
	reg.UpdateDevice(&models.Device{DeviceID: "dev2", IP: "127.0.0.1", AgentPort: 9100, Online: true})
	tasks := service.NewTaskService(cfg, nil, reg)

	ui, err := NewUI(nil, reg, nil, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	form := url.Values{}
	form.Set("action", "media_start")
	form.Add("device_id", "dev1")
	form.Add("device_id", "dev2")
	form.Set("config", "cam1")

	req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDevicesBatchAction(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String())
	}
	loc := rr.Header().Get("Location")
	if !strings.HasPrefix(loc, "/ui/tasks/") {
		t.Fatalf("expected redirect to /ui/tasks/*, got %q", loc)
	}
}

func TestUI_ActionDevicesBatchActionDeduplicatesKnownDevices(t *testing.T) {
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})

	form := url.Values{}
	form.Set("action", "reload")
	form.Add("device_id", "edge-01")
	form.Add("device_id", "edge-01")
	form.Add("device_id", "edge-02")
	form.Add("device_id", "missing")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDevicesBatchAction(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}

	loc := rr.Header().Get("Location")
	if !strings.HasPrefix(loc, "/ui/tasks/") {
		t.Fatalf("expected redirect to task page, got %q", loc)
	}

	taskID := strings.TrimPrefix(loc, "/ui/tasks/")
	items := ui.tasks.ListTasks()
	var task *models.Task
	for i := range items {
		if items[i].ID == taskID {
			t := items[i]
			task = &t
			break
		}
	}
	if task == nil {
		t.Fatalf("expected task %s to exist", taskID)
	}
	if got := len(task.DeviceIDs); got != 2 {
		t.Fatalf("expected deduplicated device count 2, got %d: %#v", got, task.DeviceIDs)
	}
	if task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" {
		t.Fatalf("expected selection order preserved, got %#v", task.DeviceIDs)
	}
}

func TestUI_SelectedDeviceQueryHelpersStayStable(t *testing.T) {
	ids := selectedIDsFromQuery([]string{" edge-02 ", "edge-01", "edge-02", ""})
	if len(ids) != 2 || ids[0] != "edge-02" || ids[1] != "edge-01" {
		t.Fatalf("selectedIDsFromQuery normalized to %#v", ids)
	}
	if got := selectedQueryString(ids); got != "selected=edge-02&selected=edge-01" {
		t.Fatalf("selectedQueryString returned %q", got)
	}
}

func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	reg := service.NewRegistryService(cfg, nil)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true})
	tasks := service.NewTaskService(cfg, nil, reg)

	ui, err := NewUI(nil, reg, nil, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
	rr := httptest.NewRecorder()

	ui.pageDevices(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{
		"视觉识别运维平台",
		"总览",
		"场景",
		"视频通道",
		"通道部署",
		"配置中心",
		"任务中心",
		"系统管理",
		"<h1>设备</h1>",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected rendered HTML to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_LayoutProvidesThemeSwitcherAndCompactTopbar(t *testing.T) {
	ui := newTestUI(t)

	body := renderPage(t, ui, "/ui/devices")

	for _, want := range []string{
		`data-theme="blue-dark"`,
		`class="topbar-actions"`,
		`data-theme-option="blue-dark"`,
		`data-theme-option="blue-light"`,
		`data-theme-option="graphite-gold"`,
		`const themeLabels = {`,
		`"blue-light": "蓝灰浅色"`,
		`"当前主题：" + themeLabels[nextTheme]`,
		`aria-label="主题"`,
		`aria-label="日志审计"`,
		`aria-label="系统"`,
		`localStorage.getItem("3588-admin-theme")`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected layout to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{
		`class="crumb"`,
		"多设备视觉识别运维平台",
		"Fleet Operations Console",
	} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected compact topbar to omit %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_ConsoleTypographyStaysModerate(t *testing.T) {
	css, err := os.ReadFile("ui/assets/style.css")
	if err != nil {
		t.Fatalf("read stylesheet: %v", err)
	}
	text := string(css)
	for _, want := range []string{
		`body[data-theme="blue-light"]`,
		`body[data-theme="graphite-gold"]`,
		"--radius:4px",
		".nav-section{padding:14px 10px 6px;font-size:11px",
		".side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}",
		".topbar h1{margin:0;font-size:18px;font-weight:600",
		".card h2{margin:0 0 8px;font-size:15px;font-weight:600;color:var(--text)}",
		".card h3{margin:0 0 6px;font-size:13px;font-weight:600;color:var(--text)}",
		".btn.ghost.icon-only{background:transparent;border-color:transparent;color:var(--muted)}",
		".topbar{height:60px;background:var(--topbar);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:100}",
		".topbar-icon-btn{position:relative",
		".theme-menu-panel{position:fixed;right:28px;top:54px;",
		"z-index:9999",
		".btn,button{display:inline-flex",
		".stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}",
		".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected stylesheet to contain %q", want)
		}
	}
}

func TestUI_BlueDarkThemeKeepsWorkspaceDarkAndTablesReadable(t *testing.T) {
	css, err := os.ReadFile("ui/assets/style.css")
	if err != nil {
		t.Fatalf("read stylesheet: %v", err)
	}
	text := string(css)
	for _, want := range []string{
		"--bg:#090909",
		"--surface:#121212",
		"--surface-soft:#1a1a1a",
		"--table-text:#c8c8c8",
		"--table-link:#f4f4f4",
		"--primary:#dddddd",
		"--selected-row:#303030",
		"--border:#303030",
		"--border-strong:#565656",
		"--button-soft:#1d1d1d",
		"--button-soft-hover:#292929",
		"--input-bg:#101010",
		"--green:#66c98f",
		"--amber:#d8a657",
		"--red:#e46f72",
		".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:14px 0;box-shadow:var(--shadow);color:var(--text)}",
		".form-hint{color:var(--muted);font-size:12px;line-height:1.35}",
		"th,td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;vertical-align:middle;line-height:1.25;color:var(--table-text)}",
		".table-wrap td a{color:var(--table-link)}",
		".device-name{font-size:13px;font-weight:400;color:var(--table-link)}",
		".asset-link>span:first-child{color:var(--table-link)}",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected blue dark theme stylesheet to contain %q", want)
		}
	}
}

func newTestUI(t *testing.T) *UI {
	t.Helper()
	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	t.Cleanup(func() { _ = store.Close() })
	reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0"})
	tasks := service.NewTaskService(cfg, nil, reg)
	ui, err := NewUI(nil, reg, nil, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}
	return ui
}

func renderPage(t *testing.T, ui *UI, path string) string {
	t.Helper()
	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("build routes: %v", err)
	}
	if strings.HasPrefix(path, "/ui/") {
		path = strings.TrimPrefix(path, "/ui")
	}
	req := httptest.NewRequest(http.MethodGet, path, nil)
	rr := httptest.NewRecorder()
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200 for %s, got %d: %s", path, rr.Code, rr.Body.String())
	}
	return rr.Body.String()
}

func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) {
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
	rr := httptest.NewRecorder()

	ui.pageDevices(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载运行配置", "回滚运行配置", "下发设备分配", "清空选择"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body)
		}
	}
}

func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) {
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
	req := httptest.NewRequest(http.MethodGet, "/ui/devices?selected=edge-01&selected=edge-02", nil)
	rr := httptest.NewRecorder()

	ui.pageDevices(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载运行配置", "回滚运行配置", "下发设备分配", "清空选择", "将重载当前运行配置", "将回滚到上一版运行配置"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"批量配置", "重载服务"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device overview batch controls should not contain %q, got:\n%s", forbidden, body)
		}
	}
	if !strings.Contains(body, `href="/ui/devices/batch-config?selected=edge-01&amp;selected=edge-02"`) {
		t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body)
	}
}

func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
	t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply")
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo)
	mustImportAssetsForUI(t, ui.preview)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/devices/batch-config?selected=edge-01&selected=edge-02", nil)
	rr := httptest.NewRecorder()

	ui.pageDeviceBatchConfig(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{
		"下发当前设备分配",
		"场景配置",
		"已选设备",
		"入口识别节点",
		"辅助节点",
		"场景配置摘要",
		"local_3588_test",
		"A厂区视觉识别",
		"std_workshop_face_recognition_shoe_alarm",
		"东门入口",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"config_id", "config_version", "完整 JSON"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("batch config page should not expose internal config fields %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) {
	t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply")
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo)
	mustImportAssetsForUI(t, ui.preview)
	mustImportAssetsForUI(t, ui.preview)

	form := url.Values{}
	form.Add("device_id", "edge-01")
	form.Add("device_id", "edge-02")
	form.Set("profile", "local_3588_test")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDeviceBatchConfig(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	loc := rr.Header().Get("Location")
	if !strings.HasPrefix(loc, "/ui/tasks/") {
		t.Fatalf("expected redirect to task page, got %q", loc)
	}

	taskID := strings.TrimPrefix(loc, "/ui/tasks/")
	items := ui.tasks.ListTasks()
	var task *models.Task
	for i := range items {
		if items[i].ID == taskID {
			t := items[i]
			task = &t
			break
		}
	}
	if task == nil {
		t.Fatalf("expected task %s to exist", taskID)
	}
	if task.Type != "config_apply" {
		t.Fatalf("expected task type config_apply, got %q", task.Type)
	}
	if len(task.DeviceIDs) != 2 || task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" {
		t.Fatalf("expected selected devices preserved, got %#v", task.DeviceIDs)
	}
	payload, ok := task.Payload.(map[string]any)
	if !ok {
		t.Fatalf("expected payload map, got %#v", task.Payload)
	}
	configDoc, ok := payload["config"].(map[string]any)
	if !ok {
		t.Fatalf("expected payload.config object, got %#v", payload["config"])
	}
	metadata, ok := configDoc["metadata"].(map[string]any)
	if !ok {
		t.Fatalf("expected metadata object, got %#v", configDoc["metadata"])
	}
	if metadata["template"] != "std_workshop_face_recognition_shoe_alarm" {
		t.Fatalf("expected template metadata, got %#v", metadata["template"])
	}
	if metadata["profile"] != "local_3588_test" {
		t.Fatalf("expected profile metadata, got %#v", metadata["profile"])
	}
}

func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(t *testing.T) {
	t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply")
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigBrokenMediaRepo(t)}, repo)
	mustImportAssetsForUI(t, ui.preview)
	mustImportAssetsForUI(t, ui.preview)

	form := url.Values{}
	form.Add("device_id", "edge-01")
	form.Add("device_id", "edge-02")
	form.Set("profile", "local_3588_test")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDeviceBatchConfig(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{
		`name="device_id" value="edge-01"`,
		`name="device_id" value="edge-02"`,
		"入口识别节点",
		"辅助节点",
		`name="profile"`,
		`value="local_3588_test" selected`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{`name="config_id"`, `name="overlay"`, "完整 JSON"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected failure UI to avoid internal config field %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) {
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})

	form := url.Values{}
	form.Set("action", "nope")
	form.Add("device_id", "edge-01")
	form.Add("device_id", "edge-02")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDevicesBatchAction(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台", `value="edge-01" checked`, `value="edge-02" checked`} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected error render to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) {
	t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [
      {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
    ],
    "services": [
      {"name": "object_storage_main", "type": "object_storage", "required": false, "description": "对象存储"},
      {"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
      {"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
    ],
    "outputs": [
      {"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
    ]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.2/live"}}`)
	mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
	mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
	mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)
	req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil)
	req = withChiURLParam(req, "name", "local_3588_test")
	rr := httptest.NewRecorder()

	ui.pageAssetProfile(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{
		"场景配置",
		"场景名称",
		"业务名称",
		"调试参数",
		"站点名",
		"对象存储",
		"认证服务",
		"告警服务",
		"视频通道",
		"cam1",
		"cam2",
		"cam3",
		"cam4",
		"主视频输入",
		"主视频输出",
		"新建场景",
		"编辑",
		"A厂区视觉识别",
		"东门入口",
		"编辑模式",
		"正在编辑场景配置",
		`id="active-instance-input"`,
		`name="profile_name" value="local_3588_test"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected profile editor page to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"生成预览", "上传为候选配置", "目标设备", "发布入口", "请在设备页中预览并下发", "下发方式", "Profile 编辑页签", "设备编号", `instances[0].device_code`, `instances[0].site_name`} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body)
		}
	}
	for _, forbidden := range []string{"队列大小", "队列策略"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
	t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages")
	root := createProfileEditorMediaRepo(t)
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.2/live"}}`)
	mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.3/live"}}`)
	mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
	mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
	mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	form := url.Values{}
	form.Set("profile_name", "local_3588_test")
	form.Set("business_name", "B厂区视觉识别")
	form.Set("description", "updated profile")
	form.Set("overlay_name", "face_debug")
	form.Set("site_name", "B厂区")
	form.Set("instances[0].name", "cam1")
	form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
	form.Set("instances[0].display_name", "西门入口")
	form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01")
	form.Set("instances[0].service_bindings.object_storage_main.service_ref", "minio_main")
	form.Set("instances[0].service_bindings.token_service_main.service_ref", "token_main")
	form.Set("instances[0].service_bindings.alarm_service_main.service_ref", "alarm_main")
	form.Set("instances[0].output_bindings.stream_output_main.channel_no", "cam1")
	form.Set("instances[0].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam1/index.m3u8")
	form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8556")
	form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_path", "/live/cam1")
	form.Set("instances[0].advanced_params", `{"queue_debug":true}`)
	form.Set("instances[1].name", "cam2")
	form.Set("instances[1].template", "std_workshop_face_recognition_shoe_alarm")
	form.Set("instances[1].display_name", "视觉识别终端-C厂区")
	form.Set("instances[1].input_bindings.video_input_main.video_source_ref", "line_cam_02")
	form.Set("instances[1].output_bindings.stream_output_main.channel_no", "cam2")
	form.Set("instances[1].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam2/index.m3u8")
	form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_port", "8557")
	form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_path", "/live/cam2")
	form.Set("instances[1].delete", "1")
	req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode()))
	req = withChiURLParam(req, "name", "local_3588_test")
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionAssetProfileSave(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/plans?") || !strings.Contains(got, "name=local_3588_test") {
		t.Fatalf("expected redirect back to plans, got %q", got)
	}

	saved, err := repo.GetProfile("local_3588_test")
	if err != nil {
		t.Fatalf("GetProfile: %v", err)
	}
	if saved == nil {
		t.Fatal("expected saved profile in repo")
	}
	var doc map[string]any
	if err := json.Unmarshal([]byte(saved.BodyJSON), &doc); err != nil {
		t.Fatalf("unmarshal saved profile: %v", err)
	}
	if doc["description"] != "updated profile" {
		t.Fatalf("unexpected description: %#v", doc)
	}
	if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" {
		t.Fatalf("expected selected overlay to be saved, got %#v", doc["overlays"])
	}
	queue, _ := doc["queue"].(map[string]any)
	if queue["size"] != float64(8) || queue["strategy"] != "drop_oldest" {
		t.Fatalf("expected default queue config, got %#v", queue)
	}
	instances, _ := doc["instances"].([]any)
	if len(instances) != 1 {
		t.Fatalf("expected deleted second instance to be omitted, got %#v", instances)
	}
	instance, _ := instances[0].(map[string]any)
	params, _ := instance["params"].(map[string]any)
	if doc["business_name"] != "B厂区视觉识别" {
		t.Fatalf("expected updated business name, got %#v", doc)
	}
	inputBindings, _ := instance["input_bindings"].(map[string]any)
	videoInput, _ := inputBindings["video_input_main"].(map[string]any)
	if videoInput["video_source_ref"] != "gate_cam_01" {
		t.Fatalf("expected slot-driven video source ref, got %#v", inputBindings)
	}
	serviceBindings, _ := instance["service_bindings"].(map[string]any)
	objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
	if objectStorage["service_ref"] != "minio_main" {
		t.Fatalf("expected object storage binding, got %#v", serviceBindings)
	}
	outputBindings, _ := instance["output_bindings"].(map[string]any)
	streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
	if streamOutput["publish_rtsp_port"] != float64(8556) {
		t.Fatalf("expected numeric slot publish_rtsp_port, got %#v", streamOutput["publish_rtsp_port"])
	}
	sceneMeta, _ := instance["scene_meta"].(map[string]any)
	if sceneMeta["display_name"] != "西门入口" || sceneMeta["site_name"] != "B厂区" {
		t.Fatalf("expected scene meta on saved profile, got %#v", sceneMeta)
	}
	if params["queue_debug"] != true {
		t.Fatalf("expected advanced params to survive save, got %#v", params)
	}
	if _, exists := params["video_source_ref"]; exists {
		t.Fatalf("expected new scene document to avoid legacy param duplication, got %#v", params)
	}
}

func createBatchConfigMediaRepo(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
  "name":"std_workshop_face_recognition_shoe_alarm",
  "description":"helmet and shoe alarm",
  "template":{"nodes":[],"edges":[]}
}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
 "name":"local_3588_test",
  "business_name":"A厂区视觉识别",
  "description":"默认班次识别配置",
  "instances":[
    {
      "name":"cam1",
      "template":"std_workshop_face_recognition_shoe_alarm",
      "scene_meta":{"display_name":"东门入口","site_name":"A厂区"},
      "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
      "output_bindings":{"stream_output_main":{"channel_no":"cam1"}}
    }
  ]
}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
	writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse
import json
import os

parser = argparse.ArgumentParser()
parser.add_argument("--template", required=True)
parser.add_argument("--profile", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--config-id", required=True)
parser.add_argument("--config-version", required=True)
parser.add_argument("--rendered-at", required=True)
parser.add_argument("--overlay", action="append", default=[])
args = parser.parse_args()

with open(args.profile, "r", encoding="utf-8") as fh:
    profile_doc = json.load(fh)

doc = {
    "metadata": {
        "config_id": args.config_id,
        "config_version": args.config_version,
        "template": os.path.splitext(os.path.basename(args.template))[0],
        "profile": profile_doc.get("name") or os.path.splitext(os.path.basename(args.profile))[0],
        "overlays": [os.path.splitext(os.path.basename(item))[0] for item in args.overlay],
        "rendered_at": args.rendered_at,
    },
    "pipelines": [],
}

with open(args.out, "w", encoding="utf-8") as fh:
    json.dump(doc, fh, ensure_ascii=False, indent=2)
`)
	return root
}

func createProfileEditorMediaRepo(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "slots": {
    "inputs": [
      {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
    ],
    "services": [
      {"name": "object_storage_main", "type": "object_storage", "required": false, "description": "对象存储"},
      {"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
      {"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
    ],
    "outputs": [
      {"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
    ]
  },
  "template": {"nodes": [], "edges": []}
}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
  "name": "local_3588_test",
  "description": "test profile",
  "business_name": "A厂区视觉识别",
  "queue": {"size": 8, "strategy": "drop_oldest"},
  "instances": [
    {
      "name": "cam1",
      "template": "std_workshop_face_recognition_shoe_alarm",
      "params": {"queue_debug": true},
      "scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
      "input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
      "service_bindings": {
        "object_storage_main": {"service_ref": "minio_main"},
        "token_service_main": {"service_ref": "token_main"},
        "alarm_service_main": {"service_ref": "alarm_main"}
      },
      "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
    },
    {
      "name": "cam2",
      "template": "std_workshop_face_recognition_shoe_alarm",
      "scene_meta": {"display_name": "西门入口", "site_name": "A厂区"},
      "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_02"}},
      "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam2/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam2", "channel_no": "cam2"}}
    },
    {
      "name": "cam3",
      "template": "std_workshop_face_recognition_shoe_alarm",
      "scene_meta": {"display_name": "1号产线", "site_name": "A厂区"},
      "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_03"}},
      "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam3/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam3", "channel_no": "cam3"}}
    },
    {
      "name": "cam4",
      "template": "std_workshop_face_recognition_shoe_alarm",
      "scene_meta": {"display_name": "2号产线", "site_name": "A厂区"},
      "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_04"}},
      "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam4/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam4", "channel_no": "cam4"}}
    }
  ]
}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	return root
}

func withChiURLParam(req *http.Request, key string, value string) *http.Request {
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add(key, value)
	return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}

func createBatchConfigBrokenMediaRepo(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
  "name":"local_3588_test",
  "business_name":"A厂区视觉识别",
  "instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]
}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
	return root
}

func writeTestFile(t *testing.T, path string, body string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
	}
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}

func mustSaveIntegrationService(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) {
	t.Helper()
	if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil {
		t.Fatalf("SaveIntegrationService(%s): %v", name, err)
	}
}

func mustSaveTemplate(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) {
	t.Helper()
	if err := repo.SaveTemplate(name, description, body); err != nil {
		t.Fatalf("SaveTemplate(%s): %v", name, err)
	}
}

func mustImportAssetsForUI(t *testing.T, preview *service.ConfigPreviewService) {
	t.Helper()
	if _, err := preview.ImportAssetsFromMediaRepo(); err != nil {
		t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
	}
}

func mustSaveVideoSource(t *testing.T, repo *storage.AssetsRepo, name string, body string) {
	t.Helper()
	if err := repo.SaveVideoSource(name, "rtsp", "", name, body); err != nil {
		t.Fatalf("SaveVideoSource(%s): %v", name, err)
	}
}

func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
	t.Skip("task summary expectations need to be rewritten for device-assignment apply metadata")
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo)
	mustImportAssetsForUI(t, ui.preview)

	form := url.Values{}
	form.Add("device_id", "edge-01")
	form.Add("device_id", "edge-02")
	form.Set("template", "std_workshop_face_recognition_shoe_alarm")
	form.Set("profile", "local_3588_test")
	form.Set("config_id", "batch_edge")
	form.Set("config_version", "20260420.090000")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDeviceBatchConfig(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}

	loc := rr.Header().Get("Location")
	if !strings.HasPrefix(loc, "/ui/tasks/") {
		t.Fatalf("expected redirect to task page, got %q", loc)
	}

	rrTask := httptest.NewRecorder()
	reqTask := httptest.NewRequest(http.MethodGet, loc, nil)
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", strings.TrimPrefix(loc, "/ui/tasks/"))
	reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
	ui.pageTask(rrTask, reqTask)

	if rrTask.Code != http.StatusOK {
		t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String())
	}
	body := rrTask.Body.String()
	for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected task page to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"任务中心", "节点执行情况"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("task page should not contain %q, got:\n%s", forbidden, body)
		}
	}
	if strings.Contains(body, "返回操作审计") {
		t.Fatalf("task page should not point back to audit, got:\n%s", body)
	}
	if strings.Index(body, "edge-01") > strings.Index(body, "edge-02") {
		t.Fatalf("expected task devices to keep selection order, got:\n%s", body)
	}
}

func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
	rr := httptest.NewRecorder()

	ui.pageDevices(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"设备", "设备总数", "在线设备", "离线设备", "失败操作", "设备列表"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected dashboard HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"设备总览", "多台视觉识别设备的统一运维视图", "先看状态，再决定操作。默认只展示值班和维护最需要的信息。"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device page should not contain explanatory copy %q", forbidden)
		}
	}
}

func TestUI_DevicePageIncludesFilterAndControlEntry(t *testing.T) {
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
	rr := httptest.NewRecorder()

	ui.pageDevices(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"device-filter", "详情", "控制"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected device page HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"查看基础配置", "查看操作审计"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device overview should not contain redundant link %q", forbidden)
		}
	}
}

func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(t *testing.T) {
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/config/status" {
			t.Fatalf("unexpected agent path %s", r.URL.Path)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"ok": true,
			"config_path": "/opt/rk3588-media-server/etc/media-server.json",
			"exists": true,
			"sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c",
			"metadata": {
				"config_id": "local_3588_face_debug",
				"config_version": "20260419.104457",
				"template": "std_workshop_face_recognition_shoe_alarm",
				"profile": "local_3588_test",
				"overlays": ["face_debug", "production_quiet"]
			},
			"media_server": {"running": true, "pid": 1706009}
		}`))
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: false, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
	rr := httptest.NewRecorder()
	ui.pageDevices(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"在线", "运行中", "local_3588_face_debug"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected device overview HTML to contain %q, got:\n%s", want, body)
		}
	}
	if strings.Contains(body, "待在详情页查看") {
		t.Fatalf("device overview should show live config summary instead of placeholder text")
	}
	for _, forbidden := range []string{"20260419.104457", "face_debug, production_quiet"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device overview should not expose extra config details %q in main list", forbidden)
		}
	}
	if strings.Contains(body, "心跳") {
		t.Fatalf("device overview should not show heartbeat text in status cell")
	}
}

func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) {
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/config/status" {
			t.Fatalf("unexpected agent path %s", r.URL.Path)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"ok": true,
			"metadata": {
				"config_id": "local_3588_face_debug",
				"config_version": "20260419.104457",
				"overlays": ["face_debug"]
			},
			"media_server": {"running": true}
		}`))
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "d12a4719c91641df91b76ab271280797", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "0.1.0", BuildID: "20260419.151628", GitSha: "5c04681"})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
	rr := httptest.NewRecorder()
	ui.pageDevices(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"设备", "状态", "当前配置", "操作", "orangepi5plus", "0.1.0", "#2026041"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected compact overview HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"#5c04681", "Git SHA"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device overview should not expose git sha marker %q", forbidden)
		}
	}
	for _, forbidden := range []string{"最后心跳", "版本</th>", "在线</th>", "服务</th>"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device overview should not keep verbose column header %q", forbidden)
		}
	}
	if strings.Contains(body, "心跳") {
		t.Fatalf("device overview compact view should not render heartbeat text")
	}
}

func TestUI_DeviceDetailIncludesWorkspaceSections(t *testing.T) {
	ui := newTestUI(t)
	routes, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	rr := httptest.NewRecorder()

	routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))

	body := rr.Body.String()
	for _, want := range []string{"设备详情", "当前设备", "设备工作台", "概览", "运行与服务", "设备分配"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected device detail HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"device-tabs", "返回设备总览", "进入管理"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device detail should not contain redundant nav %q", forbidden)
		}
	}
}

func TestUI_DeviceDetailUsesUnifiedWorkspace(t *testing.T) {
	ui := newTestUI(t)
	routes, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	rr := httptest.NewRecorder()

	routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))

	body := rr.Body.String()
	for _, want := range []string{"设备详情", "设备工作台", "运行与服务", "设备分配", "模型与资源", "日志与指标"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected unified device workspace HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device detail should not contain placeholder copy %q", forbidden)
		}
	}
	for _, forbidden := range []string{
		`action="/ui/devices/edge-01/alias"`,
	} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("device detail workspace should not contain obsolete entry %q", forbidden)
		}
	}
}

func TestUI_DeviceDetailShowsRunningConfigMetadata(t *testing.T) {
	t.Skip("device detail expectations need rewrite for device-assignment terminology")
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/config/status" {
			t.Fatalf("unexpected agent path %s", r.URL.Path)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"ok": true,
			"config_path": "/opt/rk3588-media-server/etc/media-server.json",
			"exists": true,
			"sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c",
			"metadata": {
				"config_id": "local_3588_face_debug",
				"config_version": "20260419.104457",
				"template": "std_workshop_face_recognition_shoe_alarm",
				"profile": "local_3588_test",
				"overlays": ["face_debug"],
				"rendered_by": "tools/render_config.py",
				"rendered_at": "2026-04-19T10:44:58+08:00"
			},
			"media_server": {"running": true, "pid": 1706009}
		}`))
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}
	routes, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	rr := httptest.NewRecorder()

	routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{
		"当前运行配置",
		"local_3588_face_debug",
		"20260419.104457",
		"std_workshop_face_recognition_shoe_alarm",
		"local_3588_test",
		"face_debug",
		"8d935c9d",
		"/opt/rk3588-media-server/etc/media-server.json",
		"运行中",
		"下发设备分配",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected device detail HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"当前运行场景", "当前模板", "最近下发任务"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected scene config panel to avoid duplicate runtime text %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_DeviceDetailDoesNotUseChannelDisplayNameAsDeviceName(t *testing.T) {
	t.Skip("device detail expectations need rewrite for device-assignment terminology")
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/config/status" {
			t.Fatalf("unexpected agent path %s", r.URL.Path)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"ok": true,
			"metadata": {
				"business_name": "A厂区视觉识别",
				"instance_name": "cam1",
				"instance_names": ["cam1"],
				"instance_display_names": ["东门入口"]
			},
			"media_server": {"running": true}
		}`))
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	reg := service.NewRegistryService(cfg, agent, storage.NewDevicesRepo(store.DB()))
	if err := reg.SetDeviceAlias("edge-01", "备用盒子-01"); err != nil {
		t.Fatalf("SetDeviceAlias: %v", err)
	}
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"})
	ui, err := NewUI(nil, reg, agent, service.NewTaskService(cfg, agent, reg), nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}
	routes, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	rr := httptest.NewRecorder()

	routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))

	body := rr.Body.String()
	for _, want := range []string{"备用盒子-01", "当前业务配置", "A厂区视觉识别", "通道名", "cam1"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected device detail to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{
		"<span>设备名称</span><strong>东门入口</strong>",
		"<span>当前业务配置</span><strong>东门入口</strong>",
	} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("channel display name should not be used as device or business name, got:\n%s", body)
		}
	}
}

func TestUI_DeviceSubpagesIncludeContextNavigation(t *testing.T) {
	ui := newTestUI(t)
	dev := &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}
	for _, content := range []string{"device_logs", "device_graphs", "config_ui"} {
		req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01", nil)
		rr := httptest.NewRecorder()
		ui.render(rr, req, content, PageData{Title: "节点子页面", Device: dev})
		body := rr.Body.String()
		for _, want := range []string{"当前设备"} {
			if !strings.Contains(body, want) {
				t.Fatalf("expected %s HTML to contain %q", content, want)
			}
		}
		if strings.Contains(body, "device-tabs") {
			t.Fatalf("expected %s HTML to avoid device cross-page tabs", content)
		}
	}
}

func TestUI_ConfigFriendlyIncludesWizard(t *testing.T) {
	ui := newTestUI(t)
	routes, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	rr := httptest.NewRecorder()

	routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-friendly", nil))

	body := rr.Body.String()
	for _, want := range []string{"config-wizard", "选择节点", "编辑视频通道", "预览变更", "部署结果", "data-wizard-section=\"channels\"", "下一步：编辑视频通道", "前往预览变更", "查看部署结果"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected config page HTML to contain %q", want)
		}
	}
}

func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) {
	t.Skip("legacy config-preview form replaced by device-assignment preview")
	ui := newTestUI(t)
	routes, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	rr := httptest.NewRecorder()

	routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-preview", nil))

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{
		"配置预览",
		"模板",
		"场景配置",
		"调试参数",
		"config_id",
		"config_version",
		"生成预览",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected config preview page to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"返回节点详情", "完整 JSON</h2>"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("config preview page should not contain redundant element %q", forbidden)
		}
	}
	for _, forbidden := range []string{"上传为候选配置", "应用候选配置"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("config preview page should not show action %q before preview is generated", forbidden)
		}
	}
}

func TestUI_ConfigPreviewPageKeepsApplyActionAfterUploadResult(t *testing.T) {
	t.Skip("legacy candidate-config preview flow removed from migrated device-assignment preview page")
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
	rr := httptest.NewRecorder()

	ui.render(rr, req, "config_preview", PageData{
		Title:  "配置预览",
		Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
		ConfigStatus: &ConfigStatusView{
			Candidate: &ConfigStatusLastGoodFile{Exists: true, Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
		},
		ConfigPreview: &service.ConfigPreviewResult{
			JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1"}}`,
			Metadata: map[string]any{
				"config_id":      "preview_edge-01",
				"config_version": "v1",
				"template":       "std_workshop_face_recognition_shoe_alarm",
				"profile":        "local_3588_test",
			},
			Size: 123,
		},
		RawText:     `{"ok":true}`,
		ResultTitle: "应用候选配置结果",
	})

	body := rr.Body.String()
	for _, want := range []string{
		"应用候选配置结果",
		"上传为候选配置",
		"应用候选配置",
		`formaction="/ui/devices/edge-01/config-candidate/apply"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected config preview upload result HTML to contain %q, got:\n%s", want, body)
		}
	}
	if strings.Contains(body, "<h2>执行结果</h2>") {
		t.Fatalf("config preview page should not render duplicate result card, got:\n%s", body)
	}
}

func TestUI_ActionDeviceConfigCandidateKeepsPreviewApplyAction(t *testing.T) {
	t.Skip("legacy candidate-config flow is no longer exposed in migrated UI")
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodGet && r.URL.Path == "/v1/config/status" {
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"ok":true,"candidate":{"exists":true,"path":"/opt/rk3588-media-server/etc/media-server.json.candidate.json"}}`))
			return
		}
		if r.Method != http.MethodPut || r.URL.Path != "/v1/config/candidate" {
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	form := url.Values{}
	form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`)
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.SetPathValue("id", "edge-01")
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", "edge-01")
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
	rr := httptest.NewRecorder()

	ui.actionDeviceConfigCandidate(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		"候选配置结果",
		"应用候选配置",
		`formaction="/ui/devices/edge-01/config-candidate/apply"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected actionDeviceConfigCandidate HTML to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ConfigPreviewKeepsSelectedOverlayAfterPreview(t *testing.T) {
	t.Skip("legacy config-preview form replaced by device-assignment preview")
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
	rr := httptest.NewRecorder()

	ui.render(rr, req, "config_preview", PageData{
		Title:            "配置预览",
		Device:           &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
		ConfigSources:    service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}, {Name: "face_test_sensitive"}}},
		SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
		SelectedProfile:  "local_3588_test",
		SelectedOverlays: []string{"face_test_sensitive"},
		SelectedConfigID: "preview_edge-01",
		ConfigPreview: &service.ConfigPreviewResult{
			JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test","overlays":["face_test_sensitive"]}}`,
			Metadata: map[string]any{
				"config_id":      "preview_edge-01",
				"config_version": "v1",
				"template":       "std_workshop_face_recognition_shoe_alarm",
				"profile":        "local_3588_test",
				"overlays":       []any{"face_test_sensitive"},
			},
			Size: 123,
		},
	})

	body := rr.Body.String()
	if !strings.Contains(body, `value="face_test_sensitive" checked`) {
		t.Fatalf("expected selected overlay to remain checked, got:\n%s", body)
	}
	if strings.Contains(body, `value="face_debug" checked`) {
		t.Fatalf("did not expect default overlay to stay checked, got:\n%s", body)
	}
}

func TestUI_ConfigPreviewCollapsesJSONAfterActionResult(t *testing.T) {
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
	rr := httptest.NewRecorder()

	ui.render(rr, req, "config_preview", PageData{
		Title:            "配置预览",
		Device:           &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
		ConfigSources:    service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}},
		SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
		SelectedProfile:  "local_3588_test",
		SelectedOverlays: []string{"face_debug"},
		SelectedConfigID: "preview_edge-01",
		ConfigPreview: &service.ConfigPreviewResult{
			JSON:     `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[]}`,
			Metadata: map[string]any{"config_id": "preview_edge-01"},
			Size:     64,
		},
		ResultTitle: "候选配置结果",
		RawText:     `{"ok":true}`,
	})

	body := rr.Body.String()
	if !strings.Contains(body, "<details") {
		t.Fatalf("expected json panel to use details, got:\n%s", body)
	}
	if strings.Contains(body, "<details open>") {
		t.Fatalf("expected json panel to be collapsed after action result, got:\n%s", body)
	}
}

func TestUI_ConfigPreviewShowsApplySummaryAfterApplyResult(t *testing.T) {
	t.Skip("legacy candidate-config apply summary removed from migrated device-assignment preview page")
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
	rr := httptest.NewRecorder()

	ui.render(rr, req, "config_preview", PageData{
		Title:            "配置预览",
		Device:           &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
		SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
		SelectedProfile:  "local_3588_test",
		SelectedOverlays: []string{"face_debug"},
		SelectedConfigID: "preview_edge-01",
		ConfigPreview: &service.ConfigPreviewResult{
			JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2"}}`,
			Metadata: map[string]any{
				"config_id":      "preview_edge-01",
				"config_version": "v2",
				"overlays":       []any{"face_test_sensitive", "production_quiet"},
			},
			Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5",
			Size:   64,
		},
		ConfigStatus: &ConfigStatusView{
			OK: true,
			Metadata: ConfigStatusMetadata{
				ConfigID:      "preview_edge-01",
				ConfigVersion: "v2",
				Overlays:      []string{"face_test_sensitive", "production_quiet"},
			},
			Candidate: &ConfigStatusLastGoodFile{
				Exists: false,
				Path:   "/opt/rk3588-media-server/etc/media-server.json.candidate.json",
			},
			PreviousConfig: &ConfigStatusLastGoodFile{
				Exists: true,
				Path:   "/opt/rk3588-media-server/etc/media-server.json.last_good.json",
				Sha256: "07a37fabd73e98c1c131d31ddfa7b6c0e0949be854225bf2a6e990a09e60ddd3",
				Metadata: ConfigStatusMetadata{
					ConfigID:      "local_3588_face_debug",
					ConfigVersion: "20260419.120246",
					Overlays:      []string{"face_debug"},
				},
			},
			MediaServer: ConfigStatusMediaServer{
				Running: true,
				PID:     1810489,
			},
		},
		ResultTitle: "应用候选配置结果",
		RawText:     `{"ok":true}`,
	})

	body := rr.Body.String()
	for _, want := range []string{
		"应用结果摘要",
		"当前运行",
		"preview_edge-01 / v2",
		"上一份配置",
		"local_3588_face_debug / 20260419.120246",
		"face_test_sensitive, production_quiet",
		"face_debug",
		"eecdf8d4",
		"07a37fab",
		"候选配置",
		"已清空",
		"视觉服务",
		"运行中",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected apply summary HTML to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) {
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
	rr := httptest.NewRecorder()

	ui.render(rr, req, "config_preview", PageData{
		Title:            "配置预览",
		Device:           &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
		SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
		SelectedProfile:  "local_3588_test",
		SelectedOverlays: []string{"face_debug"},
		SelectedConfigID: "preview_edge-01",
		ConfigPreview: &service.ConfigPreviewResult{
			JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","overlays":["face_debug"]}}`,
			Metadata: map[string]any{
				"config_id":      "preview_edge-01",
				"config_version": "v2",
				"template":       "std_workshop_face_recognition_shoe_alarm",
				"profile":        "local_3588_test",
				"overlays":       []any{"face_debug"},
			},
			Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5",
			Size:   64,
		},
	})

	body := rr.Body.String()
	for _, want := range []string{
		"config_id",
		"config_version",
		"SHA256",
		"调试参数",
		"face_debug",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected config preview HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{
		"是配置名，默认会带上",
		"表示本次生成版本",
		"是最终文件内容指纹",
	} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("config preview should not contain explanatory copy %q", forbidden)
		}
	}
}

func TestUI_ActionDeviceConfigCandidateApplyReloadsStatusAfterApply(t *testing.T) {
	t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI")
	statusCalls := 0
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
			statusCalls++
			w.Header().Set("Content-Type", "application/json")
			if statusCalls == 1 {
				_, _ = w.Write([]byte(`{
					"ok": true,
					"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
					"candidate": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
					"previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
					"media_server": {"running": true, "pid": 1810489}
				}`))
				return
			}
			_, _ = w.Write([]byte(`{
				"ok": true,
				"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
				"candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
				"previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
				"media_server": {"running": true, "pid": 1810489}
			}`))
			return
		case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"ok":true}`))
			return
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	form := url.Values{}
	form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`)
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.SetPathValue("id", "edge-01")
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", "edge-01")
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
	rr := httptest.NewRecorder()

	ui.actionDeviceConfigCandidateApply(rr, req)

	body := rr.Body.String()
	if statusCalls != 2 {
		t.Fatalf("expected status to be loaded twice, got %d", statusCalls)
	}
	for _, want := range []string{
		"应用结果摘要",
		"preview_edge-01 / v2",
		"已清空",
		"运行中",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected apply result HTML to contain %q, got:\n%s", want, body)
		}
	}
	if strings.Contains(body, "仍存在") {
		t.Fatalf("expected refreshed status after apply, got stale candidate state:\n%s", body)
	}
}

func TestUI_LoadConfigStatusPrefersPreviousConfigFields(t *testing.T) {
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet || r.URL.Path != "/v1/config/status" {
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"ok": true,
			"metadata": {"config_id": "preview-edge", "config_version": "v2"},
			"previous_config_path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json",
			"previous_config": {
				"exists": true,
				"path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json",
				"sha256": "abc12345",
				"metadata": {"config_id": "prev-edge", "config_version": "v1", "overlays": ["face_debug"]}
			},
			"media_server": {"running": true, "pid": 1234}
		}`))
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	status, _, err := ui.loadConfigStatus(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port})
	if err != nil {
		t.Fatalf("loadConfigStatus: %v", err)
	}
	if status == nil || status.PreviousConfig == nil {
		t.Fatalf("expected previous config to be present, got %#v", status)
	}
	if status.PreviousConfig.Metadata.ConfigID != "prev-edge" {
		t.Fatalf("previous config metadata = %#v", status.PreviousConfig.Metadata)
	}
}

func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/v1/models/status":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123},{"name":"best-640","file_name":"best-640.rknn","sha256":"sha-x","size_bytes":456}]}`))
		default:
			http.NotFound(w, r)
		}
	}))
	defer server.Close()
	host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.Listener.Addr().String(), "[::]"))
	if err != nil {
		t.Fatalf("SplitHostPort: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("Atoi: %v", err)
	}
	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	dbPath := filepath.Join(t.TempDir(), "models.db")
	store, err := storage.OpenSQLite(dbPath)
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
	agent := service.NewAgentClient(cfg)
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}
	modelsRepo := storage.NewModelsRepo(store.DB())
	if err := modelsRepo.Save(storage.StandardModelRecord{
		Name:      "face_det_scrfd_500m_640_rk3588",
		FileName:  "face_det_scrfd_500m_640_rk3588.rknn",
		Version:   "v1",
		SHA256:    "sha-1",
		SizeBytes: 123,
		ModelType: "face_detection",
	}); err != nil {
		t.Fatalf("Save: %v", err)
	}
	ui.SetDBPath(dbPath)
	req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
	rr := httptest.NewRecorder()

	ui.pageModels(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型", "非标准模型", "人脸检测"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected model management HTML to contain %q", want)
		}
	}
	for _, want := range []string{"1 个 · 更多", "best-640.rknn"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected model management HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"统一模型目录", "人脸库", "查看设备模型"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("model management page should not contain legacy text %q", forbidden)
		}
	}
}

func TestUI_ModelsPageLoadsDevicesBeforeBuildingStatusBoard(t *testing.T) {
	modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/v1/models/status":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123}]}`))
		default:
			http.NotFound(w, r)
		}
	}))
	defer modelServer.Close()
	host, portText, err := net.SplitHostPort(strings.TrimPrefix(modelServer.Listener.Addr().String(), "[::]"))
	if err != nil {
		t.Fatalf("SplitHostPort: %v", err)
	}
	agentPort, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("Atoi: %v", err)
	}

	discoveryConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 35689})
	if err != nil {
		t.Fatalf("ListenUDP: %v", err)
	}
	defer discoveryConn.Close()
	done := make(chan struct{})
	go func() {
		defer close(done)
		buf := make([]byte, 2048)
		_ = discoveryConn.SetReadDeadline(time.Now().Add(5 * time.Second))
		n, addr, err := discoveryConn.ReadFromUDP(buf)
		if err != nil {
			return
		}
		text := strings.TrimSpace(string(buf[:n]))
		lines := strings.SplitN(text, "\n", 3)
		if len(lines) < 2 || strings.TrimSpace(lines[0]) != "RK3588SYS_DISCOVERY_V1" {
			return
		}
		var req struct {
			ReqID string `json:"req_id"`
		}
		if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &req); err != nil {
			return
		}
		reply := fmt.Sprintf("RK3588SYS_DISCOVERY_V1\n{\"type\":\"discover_reply\",\"req_id\":%q,\"device_id\":\"edge-01\",\"device_name\":\"入口识别节点\",\"ip\":%q,\"agent_port\":%d,\"media_port\":9000}\n", req.ReqID, host, agentPort)
		_, _ = discoveryConn.WriteToUDP([]byte(reply), addr)
	}()

	cfg := &config.Config{
		Concurrency:        1,
		OfflineAfterMs:     1000000,
		DiscoveryTimeoutMs: 300,
		DiscoveryPort:      35689,
	}
	dbPath := filepath.Join(t.TempDir(), "models.db")
	store, err := storage.OpenSQLite(dbPath)
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
	discovery := service.NewDiscoveryService(cfg, reg)
	agent := service.NewAgentClient(cfg)
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(discovery, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}
	modelsRepo := storage.NewModelsRepo(store.DB())
	if err := modelsRepo.Save(storage.StandardModelRecord{
		Name:      "face_det_scrfd_500m_640_rk3588",
		FileName:  "face_det_scrfd_500m_640_rk3588.rknn",
		Version:   "v1",
		SHA256:    "sha-1",
		SizeBytes: 123,
		ModelType: "face_detection",
	}); err != nil {
		t.Fatalf("Save: %v", err)
	}
	ui.SetDBPath(dbPath)

	req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
	rr := httptest.NewRecorder()
	ui.pageModels(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"入口识别节点", "设备模型状态", "一致"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected model page to contain %q after lazy device load, got:\n%s", want, body)
		}
	}
	if strings.Contains(body, "暂无设备模型状态") {
		t.Fatalf("expected model page to build status board after lazy device load, got:\n%s", body)
	}
	<-done
}

func TestUI_ActionModelSyncCreatesTask(t *testing.T) {
	ui := newTestUI(t)
	form := url.Values{}
	form.Set("action", "model_sync_all")
	form.Add("device_id", "edge-01")
	req := httptest.NewRequest(http.MethodPost, "/ui/models/sync", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionModelSync(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	tasks := ui.tasks.ListTasks()
	if len(tasks) != 1 || tasks[0].Type != "model_sync_all" {
		t.Fatalf("unexpected tasks: %#v", tasks)
	}
}

func TestUI_ResourcesPageShowsUnifiedResourceStatus(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/resources")

	for _, want := range []string{"资源管理", "资源概览", "标准资源", "设备资源状态"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected resources HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"上传模型", "单台设备"} {
		if strings.Contains(html, forbidden) {
			t.Fatalf("resources page should not contain legacy text %q", forbidden)
		}
	}
}

func TestUI_LogAuditPageRendersLogAndMetricLinks(t *testing.T) {
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/diagnostics", nil)
	rr := httptest.NewRecorder()

	ui.pageDiagnostics(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"日志分析", "审计记录", "入口识别节点", "诊断日志", "运行指标"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected diagnostics HTML to contain %q", want)
		}
	}
	if strings.Contains(body, "进入系统状态") {
		t.Fatalf("log audit page should not contain system status section")
	}
}

func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) {
	t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions")
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
	rr := httptest.NewRecorder()

	ui.pageDevices(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		"总览",
		"设备",
		"场景配置",
		"基础配置",
		"任务",
		"系统管理",
		"模型管理",
		"资源管理",
		"日志审计",
		"系统状态",
		`href="/ui/dashboard"`,
		`href="/ui/devices"`,
		`href="/ui/plans"`,
		`href="/ui/assets"`,
		`href="/ui/tasks"`,
		`href="/ui/models"`,
		`href="/ui/resources"`,
		`href="/ui/diagnostics"`,
		`href="/ui/system"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected sidebar to contain %q", want)
		}
	}
	for _, old := range []string{"配置管理", "操作审计", `<a href="/ui/diagnostics"><span class="nav-icon">`} {
		if strings.Contains(body, old) {
			t.Fatalf("sidebar should not contain old label %q", old)
		}
	}
}

func TestUI_DeviceConfigPageShowsDeviceSelector(t *testing.T) {
	ui := newTestUI(t)
	req := httptest.NewRequest(http.MethodGet, "/ui/device-config", nil)
	rr := httptest.NewRecorder()

	ui.pageDeviceConfig(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"单设备配置已并入设备详情", "设备入口", "edge-01", "入口识别节点", `href="/ui/devices/edge-01#device-config"`} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"识别模板", "调试参数"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected config selector page to avoid asset-library copy %q", forbidden)
		}
	}
}

func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) {
	ui := newTestUI(t)
	routes, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	rr := httptest.NewRecorder()

	routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/device-config/edge-01", nil))
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); got != "/ui/devices/edge-01#device-config" {
		t.Fatalf("expected device-config detail redirect to device workspace, got %q", got)
	}
}

func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) {
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case r.Method == http.MethodPost && r.URL.Path == "/v1/media-server/reload":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"ok":true}`))
		case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{
				"ok": true,
				"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
				"previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
				"media_server": {"running": true, "pid": 1810489}
			}`))
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	form := url.Values{}
	form.Set("action", "reload")
	form.Set("return_to", "config")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/action", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", "edge-01")
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
	rr := httptest.NewRecorder()

	ui.actionDeviceAction(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/media-server/reload", "执行结果摘要"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(t *testing.T) {
	t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI")
	statusCalls := 0
	agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
			statusCalls++
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{
				"ok": true,
				"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
				"candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
				"previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
				"media_server": {"running": true, "pid": 1810489}
			}`))
		case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply":
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{"ok":true}`))
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer agentServer.Close()

	host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
	if err != nil {
		t.Fatalf("parse test server address: %v", err)
	}
	port, err := strconv.Atoi(portText)
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}

	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	agent := service.NewAgentClient(cfg)
	reg := service.NewRegistryService(cfg, agent)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
	tasks := service.NewTaskService(cfg, agent, reg)
	ui, err := NewUI(nil, reg, agent, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	form := url.Values{}
	form.Set("return_to", "config")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", "edge-01")
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
	rr := httptest.NewRecorder()

	ui.actionDeviceConfigCandidateApply(rr, req)

	if statusCalls < 2 {
		t.Fatalf("expected status to be refreshed for control page, got %d calls", statusCalls)
	}
	body := rr.Body.String()
	for _, want := range []string{"设备工作台", "应用候选配置结果", "preview_edge-01", "运行中"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected control page apply result HTML to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"services":[{"name":"object_storage_main","type":"object_storage","required":false,"description":"对象存储"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`)
	mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)
	req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil)
	rr := httptest.NewRecorder()

	ui.pageAssets(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"配置中心", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected assets HTML to contain %q", want)
		}
	}
	if !strings.Contains(body, "minio_main") {
		t.Fatalf("expected assets overview to contain integration service, got:\n%s", body)
	}
}

func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
	mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations", nil)
	rr := httptest.NewRecorder()
	ui.pageAssetIntegrations(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		"第三方服务列表",
		"第三方服务详情",
		"新增服务",
		"编辑",
		"删除",
		"minio_main",
		"token_main",
		"对象存储",
		"认证服务",
		"http://10.0.0.49:9000 / myminio",
		"1",
		"查看模式",
		`<strong class="mono">minio_main</strong>`,
		`data-nav-row`,
		`data-nav-href="/ui/assets/integrations?name=minio_main"`,
		`class="selected"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected integrations page to contain %q, got:\n%s", want, body)
		}
	}
	if strings.Contains(body, ">保存</span>") {
		t.Fatalf("expected readonly details to hide save button, got:\n%s", body)
	}
}

func TestUI_AssetIntegrationsPageNewModeClearsDetailForm(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?new=1", nil)
	rr := httptest.NewRecorder()
	ui.pageAssetIntegrations(rr, req)

	body := rr.Body.String()
	if strings.Contains(body, `value="minio_main"`) {
		t.Fatalf("expected new mode to clear selected service detail, got:\n%s", body)
	}
	if !strings.Contains(body, `name="name" value="" autofocus`) {
		t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body)
	}
}

func TestUI_AssetIntegrationsPageEditModeEnablesFormAndAutofocusesName(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?name=minio_main&edit=1", nil)
	rr := httptest.NewRecorder()
	ui.pageAssetIntegrations(rr, req)

	body := rr.Body.String()
	if !strings.Contains(body, `name="name" value="minio_main" autofocus`) {
		t.Fatalf("expected edit mode to autofocus selected name field, got:\n%s", body)
	}
	if !strings.Contains(body, ">保存</span>") {
		t.Fatalf("expected edit mode to show save button, got:\n%s", body)
	}
}

func TestUI_ActionAssetIntegrationSaveCreatesService(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	form := url.Values{}
	form.Set("name", "minio_main")
	form.Set("type", "object_storage")
	form.Set("description", "主对象存储")
	form.Set("enabled", "1")
	form.Set("endpoint", "http://10.0.0.49:9000")
	form.Set("bucket", "myminio")
	form.Set("access_key", "admin")
	form.Set("secret_key", "password")
	req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionAssetIntegrationSave(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	record, err := repo.GetIntegrationService("minio_main")
	if err != nil {
		t.Fatalf("GetIntegrationService: %v", err)
	}
	if record == nil {
		t.Fatal("expected service record")
	}
	if record.ServiceType != "object_storage" {
		t.Fatalf("unexpected service type: %#v", record)
	}
}

func TestUI_ActionAssetIntegrationDeleteBlocksReferencedService(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
	if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations/minio_main/delete", nil)
	req = withChiURLParam(req, "name", "minio_main")
	rr := httptest.NewRecorder()

	ui.actionAssetIntegrationDelete(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); !strings.Contains(got, "error=") {
		t.Fatalf("expected error redirect, got %q", got)
	}
}

func TestUI_AssetVideoSourcesPageShowsStructuredSources(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","fps":"25"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources", nil)
	rr := httptest.NewRecorder()
	ui.pageAssetVideoSources(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		"视频源列表",
		"视频源详情",
		"新增视频源",
		"编辑",
		"删除",
		"gate_cam_01",
		"RTSP",
		"东门入口",
		"1080p",
		"查看模式",
		`<strong class="mono">gate_cam_01</strong>`,
		`data-nav-row`,
		`data-nav-href="/ui/assets/video-sources?name=gate_cam_01"`,
		`class="selected"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected video sources page to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_AssetVideoSourcesPageNewModeClearsDetailForm(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources?new=1", nil)
	rr := httptest.NewRecorder()
	ui.pageAssetVideoSources(rr, req)

	body := rr.Body.String()
	if strings.Contains(body, `value="gate_cam_01"`) {
		t.Fatalf("expected new mode to clear selected source detail, got:\n%s", body)
	}
	if !strings.Contains(body, `name="name" value="" autofocus`) {
		t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body)
	}
}

func TestUI_PlanPageShowsVideoSourceSelector(t *testing.T) {
	t.Skip("legacy scene-config channel editor replaced by recognition-unit page")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [
      {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
    ],
    "services": [
      {"name": "object_storage_main", "type": "object_storage", "required": false, "description": "对象存储"},
      {"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
      {"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
    ],
    "outputs": [
      {"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
    ]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=local_3588_test&edit=1", nil)
	rr := httptest.NewRecorder()
	ui.pagePlans(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"主视频输入", `name="instances[0].input_bindings.video_input_main.video_source_ref"`, "gate_cam_01 - 东门入口", "编辑模式", "正在编辑场景配置"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected plan page to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ActionAssetVideoSourceSavePreservesFormOnError(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, storage.NewAssetsRepo(store.DB()))

	form := url.Values{}
	form.Set("name", "东门主入口")
	form.Set("source_type", "rtsp")
	form.Set("area", "东门")
	form.Set("description", "入口相机")
	req := httptest.NewRequest(http.MethodPost, "/ui/assets/video-sources", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionAssetVideoSourceSave(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected inline render on error, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, want := range []string{"视频源地址不能为空", `name="name" value="东门主入口" autofocus`, `name="area" value="东门"`, `name="description" value="入口相机"`} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected preserved error form to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
	t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages")
	ui := newTestUI(t)
	root := t.TempDir()
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
  "name":"local_3588_test",
  "business_name":"A厂区视觉识别",
  "description":"test profile",
  "queue":{"size":8,"strategy":"drop_oldest"},
  "instances":[{
    "name":"cam1",
    "template":"std_workshop_face_recognition_shoe_alarm",
    "params":{"queue_debug":true},
    "scene_meta":{"display_name":"东门入口","device_code":"rk3588-a-001","site_name":"A厂区"},
    "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
    "output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}}
  }]
}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil)
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context {
		rctx := chi.NewRouteContext()
		rctx.URLParams.Add("name", "local_3588_test")
		return rctx
	}()))
	rr := httptest.NewRecorder()

	ui.pageAssetProfile(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"场景配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "主视频输入", "主视频输出"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{"高级设置 JSON", `name="instances[0].advanced_params"`} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("profile asset HTML should omit %q, got:\n%s", forbidden, body)
		}
	}
	for _, forbidden := range []string{"MinIO", "取 token 接口", "告警上报接口", "设备编号"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("profile asset HTML should no longer contain shared template field %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_ProfileAssetPageEditActionOpensInstanceEditor(t *testing.T) {
	t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages")
	ui := newTestUI(t)
	root := t.TempDir()
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
  "name":"local_3588_test",
  "business_name":"A厂区视觉识别",
  "instances":[{
    "name":"cam1",
    "template":"std_workshop_face_recognition_shoe_alarm",
    "scene_meta":{"display_name":"东门入口"},
    "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
    "output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8"}}
  }]
}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil)
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context {
		rctx := chi.NewRouteContext()
		rctx.URLParams.Add("name", "local_3588_test")
		return rctx
	}()))
	rr := httptest.NewRecorder()

	ui.pageAssetProfile(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		`id="active-instance-input"`,
		`class="btn ghost js-open-instance-editor"`,
		`data-instance-index="0"`,
		`data-instance-editor="0"`,
		`保存场景配置`,
		`通道详情`,
		`场景名称<span class="required-mark">*</span>`,
		`通道名<span class="required-mark">*</span>`,
		`主视频输入<span class="required-mark">*</span>`,
		`const instanceEditorToggle = event.target.closest(".js-open-instance-editor");`,
		`activeInput.value = index;`,
		`panel.hidden = panel.getAttribute("data-instance-editor") !== index;`,
		`input:not([type=hidden]):not([readonly]), textarea, select`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{
		`href="#profile-instance-0"`,
		`class="card collapsible profile-instance-editor"`,
		`<span>保存</span></button>`,
	} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected legacy profile editor markup to omit %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_PlanPageHighlightsSelectedSceneAndShowsCrudActions(t *testing.T) {
	t.Skip("legacy scene-config page replaced by scene-template page")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [
      {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
    ],
    "outputs": [
      {"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
    ]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
		t.Fatalf("SaveProfile line_a: %v", err)
	}
	if err := repo.SaveProfile("line_b", "std_workshop_face_recognition_shoe_alarm", "产线B", "B 线", `{"name":"line_b","business_name":"产线B","instances":[{"name":"cam2","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
		t.Fatalf("SaveProfile line_b: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_b", nil)
	rr := httptest.NewRecorder()
	ui.pagePlans(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		`href="/ui/plans?new=1"`,
		`href="/ui/plans?name=line_b&edit=1"`,
		`action="/ui/plans/line_b/delete"`,
		`href="/ui/plans?name=line_b"`,
		`<strong class="mono">line_b</strong>`,
		`查看模式`,
		`data-profile-row`,
		`data-profile-href="/ui/plans?name=line_b"`,
		`class="selected"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected plan page to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ActionPlanDeleteBlocksProfileUsedByDevice(t *testing.T) {
	t.Skip("legacy scene-config page replaced by scene-template/device-assignment flow")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [
      {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
    ]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
	if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{
		DeviceID:     "edge-01",
		TemplateName: "std_workshop_face_recognition_shoe_alarm",
		ProfileName:  "line_a",
	}); err != nil {
		t.Fatalf("Upsert: %v", err)
	}
	ui.stateRepo = stateRepo

	req := httptest.NewRequest(http.MethodPost, "/ui/plans/line_a/delete", nil)
	req = withChiURLParam(req, "name", "line_a")
	rr := httptest.NewRecorder()

	ui.actionPlanDelete(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	loc := rr.Header().Get("Location")
	if !strings.Contains(loc, "/ui/plans?") || !strings.Contains(loc, "error=") || !strings.Contains(loc, "edge-01") {
		t.Fatalf("expected redirect with usage error, got %q", loc)
	}
}

func TestUI_ActionPlanCreateRedirectsToNewScene(t *testing.T) {
	t.Skip("legacy scene-config page replaced by scene-template page")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [
      {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
    ],
    "outputs": [
      {"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
    ]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	form := url.Values{}
	form.Set("profile_name", "line_c")
	form.Set("business_name", "产线C")
	form.Set("description", "C 线")
	form.Set("instances[0].name", "cam1")
	form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
	form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01")
	form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8555")
	req := httptest.NewRequest(http.MethodPost, "/ui/plans/create", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionPlanCreate(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); got != "/ui/plans?msg=%e5%9c%ba%e6%99%af%e9%85%8d%e7%bd%ae%e5%b7%b2%e4%bf%9d%e5%ad%98&name=line_c" {
		t.Fatalf("expected redirect to new scene, got %q", got)
	}
}

func TestUI_PlanPageUsesExportLabelAndSingleEditEntry(t *testing.T) {
	t.Skip("legacy scene-config page replaced by scene-template page")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [
      {"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
    ]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a", nil)
	rr := httptest.NewRecorder()
	ui.pagePlans(rr, req)

	body := rr.Body.String()
	if strings.Contains(body, "另存为 JSON") {
		t.Fatalf("expected export label update, got:\n%s", body)
	}
	if !strings.Contains(body, "导出为 JSON") {
		t.Fatalf("expected export label, got:\n%s", body)
	}
	if got := strings.Count(body, `href="/ui/plans?name=line_a&edit=1"`); got != 1 {
		t.Fatalf("expected only list header edit action to remain, got %d", got)
	}
	if got := strings.Count(body, ">编辑</"); got != 2 {
		t.Fatalf("expected list header edit plus row editor action, got %d", got)
	}
}

func TestUI_PlanPageReadOnlyUsesStaticDetailView(t *testing.T) {
	t.Skip("legacy scene-config page replaced by scene-template page")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}],
    "outputs": [{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"output_bindings":{"stream_output_main":{"publish_rtsp_port":8555}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a", nil)
	rr := httptest.NewRecorder()
	ui.pagePlans(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		`class="detail-sheet"`,
		`class="detail-grid profile-instance-grid detail-sheet"`,
		`href="/ui/plans?name=line_a&edit=1"`,
		`导出为 JSON`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected readonly plan view to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{
		`name="business_name"`,
		`name="overlay_name"`,
		`name="instances[0].name"`,
		`正在编辑场景配置`,
	} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected readonly plan view to omit %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_PlanPageEditModeShowsFormControls(t *testing.T) {
	t.Skip("legacy scene-config page replaced by scene-template page")
	ui := newTestUI(t)
	root := createProfileEditorMediaRepo(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "slots": {
    "inputs": [{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}],
    "outputs": [{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}]
  },
  "template": {"nodes": [], "edges": []}
}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"output_bindings":{"stream_output_main":{"publish_rtsp_port":8555}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a&edit=1", nil)
	rr := httptest.NewRecorder()
	ui.pagePlans(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		`正在编辑场景配置`,
		`name="business_name"`,
		`name="overlay_name"`,
		`name="instances[0].name"`,
		`保存场景配置`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected edit plan view to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ActionDevicePlanApplyCreatesSingleDeviceTask(t *testing.T) {
	t.Skip("device apply expectations need rewrite for device-assignment flow")
	ui := newTestUI(t)
	root := t.TempDir()
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`)
	mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
	if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "profile", `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	form := url.Values{}
	form.Set("profile", "local_3588_test")
	req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/plan-apply", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req = withChiURLParam(req, "id", "edge-01")
	rr := httptest.NewRecorder()

	ui.actionDevicePlanApply(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	loc := rr.Header().Get("Location")
	if !strings.HasPrefix(loc, "/ui/tasks/") {
		t.Fatalf("expected redirect to task page, got %q", loc)
	}
	taskID := strings.TrimPrefix(loc, "/ui/tasks/")
	items := ui.tasks.ListTasks()
	var task *models.Task
	for i := range items {
		if items[i].ID == taskID {
			t := items[i]
			task = &t
			break
		}
	}
	if task == nil {
		t.Fatalf("expected task %s to exist", taskID)
	}
	if task.Type != "config_apply" {
		t.Fatalf("expected task type config_apply, got %q", task.Type)
	}
	if len(task.DeviceIDs) != 1 || task.DeviceIDs[0] != "edge-01" {
		t.Fatalf("expected single target edge-01, got %#v", task.DeviceIDs)
	}
}

func TestUI_RequiredMarkUsesThemeColorClass(t *testing.T) {
	css, err := os.ReadFile("ui/assets/style.css")
	if err != nil {
		t.Fatalf("read stylesheet: %v", err)
	}
	text := string(css)
	for _, want := range []string{
		`.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}`,
		`--red:#e46f72`,
		`--red:#a43f3f`,
		`--red:#d66a63`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected stylesheet to contain %q", want)
		}
	}
}

func TestUI_ActionAssetProfileSaveAddInstanceDoesNotValidateBlankDraft(t *testing.T) {
	t.Skip("legacy add-instance flow removed in scene-template migration")
	ui := newTestUI(t)
	root := t.TempDir()
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
  "name":"local_3588_test",
  "business_name":"A厂区视觉识别",
  "instances":[{
    "name":"cam1",
    "template":"std_workshop_face_recognition_shoe_alarm",
    "scene_meta":{"display_name":"东门入口"},
    "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}
  }]
}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	form := url.Values{}
	form.Set("profile_name", "local_3588_test")
	form.Set("business_name", "A厂区视觉识别")
	form.Set("active_instance", "0")
	form.Set("instances[0].name", "cam1")
	form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
	form.Set("instances[0].display_name", "东门入口")
	form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01")
	form.Set("add_instance", "1")

	req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode()))
	req = withChiURLParam(req, "name", "local_3588_test")
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionAssetProfileSave(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	body := rr.Body.String()
	for _, forbidden := range []string{"instance name is required", "video source is required"} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected add instance draft to avoid validation error %q, got:\n%s", forbidden, body)
		}
	}
	for _, want := range []string{
		`value="1"`,
		`id="profile-instance-1"`,
		`data-instance-editor="1"`,
		`name="instances[1].name" value="cam2"`,
		`name="instances[1].output_bindings.stream_output_main.publish_rtsp_port" value="8555"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected add instance response to contain %q, got:\n%s", want, body)
		}
	}
	for _, forbidden := range []string{
		`name="instances[1].output_bindings.stream_output_main.publish_hls_path"`,
		`name="instances[1].output_bindings.stream_output_main.publish_rtsp_path"`,
		`name="instances[1].output_bindings.stream_output_main.channel_no"`,
	} {
		if strings.Contains(body, forbidden) {
			t.Fatalf("expected add instance response to omit %q, got:\n%s", forbidden, body)
		}
	}
}

func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`)
	mustSaveTemplate(t, repo, "helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{}],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates?name=helmet", nil)
	rr := httptest.NewRecorder()

	ui.pageAssetTemplates(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		"识别模板列表",
		"std_workshop_face_recognition_shoe_alarm",
		"helmet",
		"模板详情",
		"helmet template",
		`data-nav-row`,
		`data-nav-href="/ui/assets/templates?name=helmet"`,
		`class="selected"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) {
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"启用人脸识别和陌生候选调试日志，用于联调和测试。","instance_overrides":{"*":{"override":{}}}}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"description":"夜间场景下放宽识别约束，减少低照环境漏检。","instance_overrides":{"cam2":{"override":{}}}}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[{}],"edges":[]}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/overlays?name=night_relaxed", nil)
	rr := httptest.NewRecorder()

	ui.pageAssetOverlays(rr, req)

	body := rr.Body.String()
	for _, want := range []string{
		"调试参数列表",
		"face_debug",
		"night_relaxed",
		"夜间场景下放宽识别约束，减少低照环境漏检。",
		"cam2",
		`data-nav-row`,
		`data-nav-href="/ui/assets/overlays?name=night_relaxed"`,
		`class="selected"`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected overlay assets page to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_ProfileAssetsPageShowsListAndSelectedEditor(t *testing.T) {
	t.Skip("legacy profile assets page replaced by scene-template page")
	ui := newTestUI(t)
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
  "name":"local_3588_test",
  "business_name":"A厂区视觉识别",
  "description":"test profile",
  "queue":{"size":8,"strategy":"drop_oldest"},
  "instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]
}`)
	writeTestFile(t, filepath.Join(root, "configs", "profiles", "night_shift.json"), `{
  "name":"night_shift",
  "business_name":"夜班巡检",
  "description":"night profile",
  "queue":{"size":4,"strategy":"drop_oldest"},
  "instances":[{"name":"cam9","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"西门"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_09"}}}]
}`)
	writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}]},"template":{"nodes":[{}],"edges":[]}}`)
	ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportAssetsForUI(t, ui.preview)
	mustImportAssetsForUI(t, ui.preview)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles?name=night_shift", nil)
	rr := httptest.NewRecorder()

	ui.pageAssetProfiles(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"场景配置列表", "local_3588_test", "night_shift", "场景配置", "夜班巡检", "night profile", "西门", "gate_cam_09"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected profile assets page to contain %q, got:\n%s", want, body)
		}
	}
}

func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	reg := service.NewRegistryService(cfg, nil)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"})
	reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", BuildID: "20260419.151900", GitSha: "8eaf213"})
	tasks := service.NewTaskService(cfg, nil, reg)
	taskConfig, err := tasks.CreateTask("config_apply", []string{"edge-01", "edge-02"}, map[string]any{"config": map[string]any{}})
	if err != nil {
		t.Fatalf("CreateTask: %v", err)
	}
	taskService, err := tasks.CreateTask("media_restart", []string{"edge-01", "edge-02"}, nil)
	if err != nil {
		t.Fatalf("CreateTask: %v", err)
	}
	taskConfig.Mu.Lock()
	taskConfig.Status = models.TaskSuccess
	for _, did := range taskConfig.DeviceIDs {
		if ds, ok := taskConfig.Devices[did]; ok && ds != nil {
			ds.Status = models.TaskSuccess
		}
	}
	taskConfig.Mu.Unlock()
	taskService.Mu.Lock()
	taskService.Status = models.TaskSuccess
	for _, did := range taskService.DeviceIDs {
		if ds, ok := taskService.Devices[did]; ok && ds != nil {
			ds.Status = models.TaskSuccess
		}
	}
	taskService.Mu.Unlock()
	ui, err := NewUI(nil, reg, nil, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}

	rrAudit := httptest.NewRecorder()
	ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil))
	for _, want := range []string{"系统管理 / 日志审计 / 审计记录", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发设备分配", "重启视频分析服务"} {
		if !strings.Contains(rrAudit.Body.String(), want) {
			t.Fatalf("expected audit HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"框架版", "后续", "节点执行情况", `disabled`} {
		if strings.Contains(rrAudit.Body.String(), forbidden) {
			t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden)
		}
	}
	for _, forbidden := range []string{"success", "failed", "running"} {
		if strings.Contains(rrAudit.Body.String(), forbidden) {
			t.Fatalf("audit HTML should not leak raw status enum %q", forbidden)
		}
	}

	rrTasks := httptest.NewRecorder()
	ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil))
	for _, want := range []string{"执行历史", "目标设备数"} {
		if !strings.Contains(rrTasks.Body.String(), want) {
			t.Fatalf("expected tasks HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"节点执行情况", "创建任务", "<h2>目标设备</h2>", "高级参数（JSON）", `name="device_ids"`, `name="payload_json"`} {
		if strings.Contains(rrTasks.Body.String(), forbidden) {
			t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden)
		}
	}

	rrTaskConfig := httptest.NewRecorder()
	reqTask := httptest.NewRequest(http.MethodGet, "/ui/tasks/"+taskConfig.ID, nil)
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", taskConfig.ID)
	reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
	ui.pageTask(rrTaskConfig, reqTask)
	for _, want := range []string{"任务概览", "下发设备分配", "返回任务列表"} {
		if !strings.Contains(rrTaskConfig.Body.String(), want) {
			t.Fatalf("expected task detail HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"返回操作审计"} {
		if strings.Contains(rrTaskConfig.Body.String(), forbidden) {
			t.Fatalf("task detail HTML should not contain %q", forbidden)
		}
	}

	rrSystem := httptest.NewRecorder()
	ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil))
	for _, want := range []string{"服务状态", "数据备份", "数据恢复", "/health", "/openapi.json"} {
		if !strings.Contains(rrSystem.Body.String(), want) {
			t.Fatalf("expected system HTML to contain %q", want)
		}
	}
	for _, forbidden := range []string{"框架版", "后续", "平台状态与访问入口"} {
		if strings.Contains(rrSystem.Body.String(), forbidden) {
			t.Fatalf("system HTML should not contain placeholder marker %q", forbidden)
		}
	}
}

func TestUI_DeviceAssignmentsPageShowsBoard(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
		SceneTemplateName: "scene_a",
		Name:              "cam1",
		DisplayName:       "东门入口",
		VideoSourceRef:    "vs1",
		OutputChannel:     "cam1",
		RTSPPort:          "8555",
		BodyJSON:          `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
	}); err != nil {
		t.Fatalf("SaveRecognitionUnit cam1: %v", err)
	}
	if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
		SceneTemplateName: "scene_a",
		Name:              "cam2",
		DisplayName:       "西门入口",
		VideoSourceRef:    "vs2",
		OutputChannel:     "cam2",
		RTSPPort:          "8555",
		BodyJSON:          `{"name":"cam2","scene_meta":{"display_name":"西门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`,
	}); err != nil {
		t.Fatalf("SaveRecognitionUnit cam2: %v", err)
	}
	if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil {
		t.Fatalf("SaveDeviceAssignment: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})

	req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments", nil)
	rr := httptest.NewRecorder()
	ui.pageDeviceAssignments(rr, req)

	body := rr.Body.String()
	for _, want := range []string{"通道部署", "视频通道", "未分配", "每台最多", "自动平均分配", "保存通道部署", "东门入口", "西门入口"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected device assignment board to contain %q, got:\n%s", want, body)
		}
	}
	for _, want := range []string{"测试设备 03", "测试设备 08"} {
		if !strings.Contains(body, want) {
			t.Fatalf("expected device assignment board to include preview device %q, got:\n%s", want, body)
		}
	}
}

func TestUI_DeviceAssignmentsPageHidesAddControlsForFullDevice(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
		SceneTemplateName: "scene_a",
		Name:              "cam1",
		DisplayName:       "东门入口",
		VideoSourceRef:    "vs1",
		OutputChannel:     "cam1",
		RTSPPort:          "8555",
		BodyJSON:          `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
	}); err != nil {
		t.Fatalf("SaveRecognitionUnit cam1: %v", err)
	}
	if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil {
		t.Fatalf("SaveDeviceAssignment: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments?max_units_per_device=1", nil)
	rr := httptest.NewRecorder()
	ui.pageDeviceAssignments(rr, req)

	body := rr.Body.String()
	start := strings.Index(body, `data-device-card="edge-01"`)
	if start < 0 {
		t.Fatalf("expected edge-01 card in html: %s", body)
	}
	end := strings.Index(body[start:], `</section>`)
	if end < 0 {
		t.Fatalf("expected end of edge-01 card in html: %s", body)
	}
	cardHTML := body[start : start+end]
	if strings.Contains(cardHTML, "添加识别单元") {
		t.Fatalf("expected full device card to hide add controls, got:\n%s", cardHTML)
	}
}

func TestUI_ActionDeviceAssignmentSavePersistsBoardState(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
		SceneTemplateName: "scene_a",
		Name:              "cam1",
		VideoSourceRef:    "vs1",
		OutputChannel:     "cam1",
		RTSPPort:          "8555",
		BodyJSON:          `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
	}); err != nil {
		t.Fatalf("SaveRecognitionUnit cam1: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("max_units_per_device", "4")
	form.Set("board_state_json", `{"devices":{"edge-01":["scene_a::cam1"]}}`)
	req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDeviceAssignmentSave(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	item, err := ui.preview.GetDeviceAssignment("edge-01")
	if err != nil {
		t.Fatalf("GetDeviceAssignment: %v", err)
	}
	if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 1 || item.RecognitionUnits[0] != "scene_a::cam1" {
		t.Fatalf("unexpected saved assignment: %#v", item)
	}
}

func TestUI_ActionDeviceAssignmentSaveIgnoresPreviewDevices(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
		SceneTemplateName: "scene_a",
		Name:              "cam1",
		VideoSourceRef:    "vs1",
		OutputChannel:     "cam1",
		RTSPPort:          "8555",
		BodyJSON:          `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
	}); err != nil {
		t.Fatalf("SaveRecognitionUnit cam1: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("max_units_per_device", "4")
	form.Set("board_state_json", `{"devices":{"demo-edge-02":["scene_a::cam1"]}}`)
	req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionDeviceAssignmentSave(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	item, err := ui.preview.GetDeviceAssignment("demo-edge-02")
	if err == nil && item != nil {
		t.Fatalf("expected preview device assignment to be ignored, got %#v", item)
	}
}

func TestUI_DeviceAssignmentsBoardUsesDenseGridLayout(t *testing.T) {
	css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css"))
	if err != nil {
		t.Fatalf("ReadFile style.css: %v", err)
	}
	if !strings.Contains(string(css), ".assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));") {
		t.Fatalf("expected dense four-column assignment board grid, got:\n%s", string(css))
	}
}

func TestUI_DeviceAssignmentsBoardUsesBrightOrangeLoadStates(t *testing.T) {
	css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css"))
	if err != nil {
		t.Fatalf("ReadFile style.css: %v", err)
	}
	text := string(css)
	for _, want := range []string{
		"--assignment-low-bg:#24190d;",
		"--assignment-busy-bg:#3a2812;",
		"--assignment-full-bg:#4b3110;",
		"--assignment-low-bg:#f4ead8;",
		"--assignment-busy-bg:#f0dcc0;",
		"--assignment-full-bg:#eccb96;",
		"--assignment-low-bg:#332514;",
		"--assignment-busy-bg:#3f3015;",
		"--assignment-full-bg:#57401a;",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected brighter orange assignment state token %q in css", want)
		}
	}
}

func TestUI_SidebarMatchesApprovedIoTArchitecture(t *testing.T) {
	t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions")
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/devices")

	for _, label := range []string{"总览", "设备", "场景配置", "基础配置", "任务", "系统管理", "模型管理", "资源管理", "日志审计", "系统状态"} {
		if !strings.Contains(html, label) {
			t.Fatalf("expected sidebar label %q in html: %s", label, html)
		}
	}
	for _, removed := range []string{"配置管理", "操作审计", `<a href="/ui/diagnostics"><span class="nav-icon">`} {
		if strings.Contains(html, removed) {
			t.Fatalf("did not expect legacy top-level label %q in html: %s", removed, html)
		}
	}
}

func TestUI_DashboardShowsGlobalOperationsSummary(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/dashboard")

	for _, text := range []string{"全局 KPI", "在线率", "最近任务", "异常设备"} {
		if !strings.Contains(html, text) {
			t.Fatalf("expected dashboard text %q in html: %s", text, html)
		}
	}
}

func TestUI_DashboardOmitsRedundantExplanatoryChrome(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/dashboard")

	for _, text := range []string{
		"只看 fleet 级运行态势、最近任务和需要关注的异常设备。",
		"当前版本基于节点在线状态和视频分析服务查询入口汇总。",
		"按常见现场操作顺序进入对应页面。",
		"视频分析概览",
		"运维工作流",
		"快速入口",
		"任务健康",
		"失败任务",
		"成功任务",
		`<a class="btn ghost" href="/ui/devices">设备列表</a>`,
		`<a class="btn ghost" href="/ui/tasks">任务</a>`,
	} {
		if strings.Contains(html, text) {
			t.Fatalf("dashboard should omit redundant chrome %q in html: %s", text, html)
		}
	}
}

func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/tasks")

	for _, text := range []string{"执行历史", "目标设备数"} {
		if !strings.Contains(html, text) {
			t.Fatalf("expected tasks text %q in html: %s", text, html)
		}
	}
}

func TestUI_LogAuditPageOwnsLogsAndAudit(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/diagnostics")

	for _, text := range []string{"日志", "审计"} {
		if !strings.Contains(html, text) {
			t.Fatalf("expected diagnostics text %q in html: %s", text, html)
		}
	}
	if strings.Contains(html, "进入系统状态") {
		t.Fatalf("log audit page should not include system status: %s", html)
	}
}

func TestUI_DeviceDetailActsAsSingleDeviceWorkspace(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/devices/edge-01")

	for _, text := range []string{"概览", "运行与服务", "设备分配", "日志与指标"} {
		if !strings.Contains(html, text) {
			t.Fatalf("expected device workspace text %q in html: %s", text, html)
		}
	}
	if strings.Contains(html, "进入配置管理") {
		t.Fatalf("device detail should not contain cross-module config shortcut: %s", html)
	}
}

func TestUI_DeviceWorkspaceContainsSingleDeviceOperations(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/devices/edge-01")

	for _, text := range []string{"概览", "运行与服务", "设备分配", "模型与资源", "日志与指标"} {
		if !strings.Contains(html, text) {
			t.Fatalf("expected device workspace section %q in html: %s", text, html)
		}
	}
	for _, removed := range []string{"返回设备选择", "配置管理面向单台设备"} {
		if strings.Contains(html, removed) {
			t.Fatalf("did not expect legacy standalone config flow text %q in html: %s", removed, html)
		}
	}
}

func TestUI_TaskDetailPresentsExecutionSections(t *testing.T) {
	ui := newTestUI(t)
	task, err := ui.tasks.CreateTask("config_apply", []string{"edge-01"}, map[string]any{"config": map[string]any{}})
	if err != nil {
		t.Fatalf("create task: %v", err)
	}

	html := renderPage(t, ui, "/ui/tasks/"+task.ID)
	for _, text := range []string{"任务概览", "执行进度", "设备结果"} {
		if !strings.Contains(html, text) {
			t.Fatalf("expected task detail section %q in html: %s", text, html)
		}
	}
}

func TestUI_SystemManagementPagesUseSystemManagementCrumb(t *testing.T) {
	ui := newTestUI(t)

	for _, item := range []struct {
		path string
		want string
	}{
		{path: "/ui/system", want: "服务状态"},
		{path: "/ui/audit", want: "系统管理 / 日志审计 / 审计记录"},
		{path: "/ui/api", want: "系统管理 / 日志审计 / 高级调试"},
	} {
		html := renderPage(t, ui, item.path)
		if !strings.Contains(html, item.want) {
			t.Fatalf("expected %s to contain crumb %q, got: %s", item.path, item.want, html)
		}
	}
}

func TestUI_TasksPageDoesNotExposeTaskCreationForm(t *testing.T) {
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})

	html := renderPage(t, ui, "/ui/tasks?selected=edge-02")
	for _, forbidden := range []string{"<h2>目标设备</h2>", `name="device_id"`, `name="device_ids"`, `name="payload_json"`, "创建任务", "<option value=\"config_apply\">下发识别配置</option>"} {
		if strings.Contains(html, forbidden) {
			t.Fatalf("tasks page should not expose task-creation UI %q, got: %s", forbidden, html)
		}
	}
}

func TestUI_TasksPageDoesNotShowTransitionQuickActions(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/tasks")
	for _, forbidden := range []string{"常用动作", "<span class=\"pill\">批量下发</span>", "<span class=\"pill\">批量重启</span>", "<span class=\"pill\">批量回滚</span>"} {
		if strings.Contains(html, forbidden) {
			t.Fatalf("tasks page should not contain transition quick action block %q, got: %s", forbidden, html)
		}
	}
}

func TestUI_ActionCreateTaskUsesSelectedDeviceCheckboxes(t *testing.T) {
	ui := newTestUI(t)
	ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})

	form := url.Values{}
	form.Set("type", "media_restart")
	form.Add("device_id", "edge-01")
	form.Add("device_id", "edge-02")
	form.Set("payload_json", `{}`)
	req := httptest.NewRequest(http.MethodPost, "/ui/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	ui.actionCreateTask(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}

	loc := rr.Header().Get("Location")
	taskID := strings.TrimPrefix(loc, "/ui/tasks/")
	var created *models.Task
	for _, item := range ui.tasks.ListTasks() {
		if item.ID == taskID {
			task := item
			created = &task
			break
		}
	}
	if created == nil {
		t.Fatalf("expected created task %q to exist", taskID)
	}
	if got := strings.Join(created.DeviceIDs, ","); got != "edge-01,edge-02" {
		t.Fatalf("expected selected devices preserved in order, got %q", got)
	}
}

func TestUI_TasksPageShowsPersistedHistory(t *testing.T) {
	cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
	reg := service.NewRegistryService(cfg, nil)
	reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true})
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewTasksRepo(store.DB())

	task := models.NewTask("task-persisted", "reload", []string{"edge-01"}, nil)
	task.Status = models.TaskSuccess
	task.Devices["edge-01"].Status = models.TaskSuccess
	task.Devices["edge-01"].Progress = 1
	if err := repo.Save(task); err != nil {
		t.Fatalf("Save: %v", err)
	}

	tasks := service.NewTaskService(cfg, nil, reg, repo)
	if err := tasks.LoadPersistedTasks(); err != nil {
		t.Fatalf("LoadPersistedTasks: %v", err)
	}

	ui, err := NewUI(nil, reg, nil, tasks, nil)
	if err != nil {
		t.Fatalf("NewUI: %v", err)
	}
	html := renderPage(t, ui, "/ui/tasks")
	for _, want := range []string{"task-persisted", "重载识别服务", "1 台"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected persisted task history to contain %q, got: %s", want, html)
		}
	}
}

func TestUI_AssetsOverviewOmitsLegacyImportAction(t *testing.T) {
	ui := newTestUI(t)
	html := renderPage(t, ui, "/ui/assets")
	for _, forbidden := range []string{"资产操作", "导入现有 JSON", `action="/ui/assets/import"`} {
		if strings.Contains(html, forbidden) {
			t.Fatalf("expected assets overview to omit legacy import action %q, got: %s", forbidden, html)
		}
	}
}

func TestUI_AssetTemplateExportsJSON(t *testing.T) {
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	const raw = "{\n  \"name\": \"helmet\",\n  \"template\": {\n    \"nodes\": [],\n    \"edges\": []\n  }\n}\n"
	if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}

	ui := newTestUI(t)
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates/helmet/export", nil)
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("name", "helmet")
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
	rr := httptest.NewRecorder()

	ui.pageAssetTemplateExport(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "helmet.json") {
		t.Fatalf("expected attachment filename, got %q", got)
	}
	if rr.Body.String() != raw {
		t.Fatalf("unexpected export body: %s", rr.Body.String())
	}
}

func TestUI_AssetTemplateShowsSaveAsExportButton(t *testing.T) {
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}

	ui := newTestUI(t)
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
	html := renderPage(t, ui, "/ui/assets/templates?name=helmet")

	for _, want := range []string{"导出为 JSON", `data-export-url="/ui/assets/templates/helmet/export"`, `data-default-filename="helmet.json"`, "showSaveFilePicker", "当前浏览器不支持选择保存目录和文件名"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected asset template html to contain %q, got: %s", want, html)
		}
	}
}

func TestUI_AssetTemplatesPageShowsDirectStandardCloneAction(t *testing.T) {
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("std_face_recognition_stream", "标准人脸流模板", `{"name":"std_face_recognition_stream","description":"标准人脸流模板","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}

	ui := newTestUI(t)
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
	html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream")

	for _, want := range []string{"标准模板应作为基线", "复制标准模板", "模板详情"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected template page to contain %q, got: %s", want, html)
		}
	}
	for _, forbidden := range []string{"新增模板", `name="clone_source" value="std_face_recognition_stream"`, "复制并编辑"} {
		if strings.Contains(html, forbidden) {
			t.Fatalf("expected template page to omit %q, got: %s", forbidden, html)
		}
	}
}

func TestUI_AssetTemplateGraphPageRendersEditorShell(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{
	  "name": "helmet",
	  "description": "helmet template",
	  "template": {
	    "nodes": [{"id":"in","type":"input_rtsp","role":"source","enable":true}],
	    "edges": []
	  }
	}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	html := renderPage(t, ui, "/ui/assets/templates/helmet/graph")
	for _, want := range []string{
		"模板可视化编辑",
		"graph-editor",
		"graph_editor.css",
		"graph_editor.js",
		"graph-template-json",
		`"id":"in"`,
		`"type":"input_rtsp"`,
		"graph-node-form",
		`name="id"`,
		`name="role"`,
		`name="enable"`,
		"graph-connect-target",
		"graph-typed-param-fields",
		"graph-auto-layout",
		"graph-param-editor",
		"graph-edge-form",
		"graph-node-palette-list",
		`data-catalog-url="/ui/api/graph-node-types"`,
		`name="from"`,
		`name="to"`,
		"常用参数",
		"高级 JSON",
		"自动布局",
	} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected graph editor page to contain %q, got: %s", want, html)
		}
	}
}

func TestUI_AssetTemplatePageShowsEditAndDeleteForUserTemplate(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	html := renderPage(t, ui, "/ui/assets/templates?name=helmet")
	for _, want := range []string{`href="/ui/assets/templates?name=helmet&edit=1"`, `action="/ui/assets/templates/helmet/delete"`, "模板详情", "查看模式"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected template detail to contain %q, got: %s", want, html)
		}
	}
}

func TestUI_ActionAssetTemplateCloneCreatesDefaultCopyAndRedirectsToEdit(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("std_face_recognition_stream", "helmet template", `{"name":"std_face_recognition_stream","description":"helmet template","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","det"]]},"extra":{"mode":"std"}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/clone", nil)
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=face_recognition_stream_copy&edit=1") {
		t.Fatalf("expected edit redirect, got %q", got)
	}
	saved, err := repo.GetTemplate("face_recognition_stream_copy")
	if err != nil {
		t.Fatalf("GetTemplate: %v", err)
	}
	for _, want := range []string{`"name": "face_recognition_stream_copy"`, `"description": "helmet template"`, `"type": "input_rtsp"`, `"mode": "std"`} {
		if saved == nil || !strings.Contains(saved.BodyJSON, want) {
			t.Fatalf("expected cloned template to contain %q, got %#v", want, saved)
		}
	}
}

func TestUI_GraphNodeTypesAPIFallbackListsCatalog(t *testing.T) {
	ui := newTestUI(t)
	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	req := httptest.NewRequest(http.MethodGet, "/api/graph-node-types", nil)
	rr := httptest.NewRecorder()

	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	var payload struct {
		Items []graphNodeTypeInfo `json:"items"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
		t.Fatalf("invalid json: %v", err)
	}
	got := map[string]graphNodeTypeInfo{}
	for _, item := range payload.Items {
		got[item.Type] = item
	}
	for _, want := range []string{"input_rtsp", "ai_shoe_det", "event_fusion", "zlm_http"} {
		item, ok := got[want]
		if !ok {
			t.Fatalf("expected graph node type %q in %#v", want, payload.Items)
		}
		if item.Label == "" || item.Icon == "" || item.Description == "" {
			t.Fatalf("expected %q to include label, icon and description: %#v", want, item)
		}
		if item.Defaults["type"] != want {
			t.Fatalf("expected %q defaults.type, got %#v", want, item.Defaults)
		}
	}
}

func TestUI_AssetTemplateGraphSavePersistsNodeAndEdgeParameters(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source","enable":true,"url":"${slot:video_input_main.url}"},{"id":"det","type":"ai_yolo","role":"filter","enable":true,"conf":0.42}],"edges":[{"from":"in","to":"det","stream":"video"}]}}`)
	req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	saved, err := repo.GetTemplate("helmet")
	if err != nil {
		t.Fatalf("GetTemplate: %v", err)
	}
	for _, want := range []string{`"url": "${slot:video_input_main.url}"`, `"conf": 0.42`, `"stream": "video"`} {
		if saved == nil || !strings.Contains(saved.BodyJSON, want) {
			t.Fatalf("expected saved graph parameter %q, got %#v", want, saved)
		}
	}
}

func TestUI_AssetTemplateGraphSavePersistsLayout(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("json", `{"name":"helmet","description":"helmet template","ui":{"layout":{"version":1,"nodes":{"in":{"x":123,"y":456}}}},"template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`)
	req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	saved, err := repo.GetTemplate("helmet")
	if err != nil {
		t.Fatalf("GetTemplate: %v", err)
	}
	if saved == nil || !strings.Contains(saved.BodyJSON, `"x": 123`) || !strings.Contains(saved.BodyJSON, `"y": 456`) {
		t.Fatalf("expected saved layout, got %#v", saved)
	}
}

func TestUI_AssetTemplateGraphSaveCanRenameTemplate(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("name", "helmet_v2")
	form.Set("description", "helmet v2")
	form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`)
	req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates/helmet_v2/graph") {
		t.Fatalf("expected renamed graph redirect, got %q", got)
	}
	oldRecord, err := repo.GetTemplate("helmet")
	if err != nil {
		t.Fatalf("GetTemplate old: %v", err)
	}
	if oldRecord != nil {
		t.Fatalf("expected old template removed, got %#v", oldRecord)
	}
	newRecord, err := repo.GetTemplate("helmet_v2")
	if err != nil {
		t.Fatalf("GetTemplate new: %v", err)
	}
	if newRecord == nil || !strings.Contains(newRecord.BodyJSON, `"name": "helmet_v2"`) {
		t.Fatalf("expected renamed template saved, got %#v", newRecord)
	}
	profile, err := repo.GetProfile("gate_a")
	if err != nil {
		t.Fatalf("GetProfile: %v", err)
	}
	if profile == nil || profile.TemplateName != "helmet_v2" {
		t.Fatalf("expected profile ref updated, got %#v", profile)
	}
}

func TestUI_ActionAssetTemplateRenameFromDetailPage(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("name", "helmet_v2")
	form.Set("description", "helmet v2")
	req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/rename", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=helmet_v2") {
		t.Fatalf("expected detail redirect, got %q", got)
	}
	record, err := repo.GetTemplate("helmet_v2")
	if err != nil {
		t.Fatalf("GetTemplate: %v", err)
	}
	if record == nil || record.Description != "helmet_v2" && record.Description != "helmet v2" {
		t.Fatalf("expected renamed template, got %#v", record)
	}
}

func TestUI_ActionAssetTemplateDeleteFromDetailPage(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/delete", nil)
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?msg=") {
		t.Fatalf("expected list redirect with message, got %q", got)
	}
	record, err := repo.GetTemplate("helmet")
	if err != nil {
		t.Fatalf("GetTemplate: %v", err)
	}
	if record != nil {
		t.Fatalf("expected deleted template, got %#v", record)
	}
}

func TestUI_AssetTemplateGraphSaveRejectsUnknownEdgeEndpoint(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("json", `{"name":"helmet","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","missing"]]}}`)
	req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
	}
	if !strings.Contains(rr.Body.String(), "edge references unknown node") {
		t.Fatalf("expected edge validation error, got: %s", rr.Body.String())
	}
}

func TestUI_AssetTemplatePageMarksBuiltinTemplateReadonly(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream")
	for _, want := range []string{"标准模板（只读）", "复制标准模板", "可视化预览", "builtin helmet"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected builtin template page to contain %q, got: %s", want, html)
		}
	}
}

func TestUI_AssetTemplateGraphSaveRejectsBuiltinTemplate(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	repo := storage.NewAssetsRepo(store.DB())
	if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)

	form := url.Values{}
	form.Set("json", `{"name":"std_face_recognition_stream","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`)
	req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/graph", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()

	router, err := ui.Routes()
	if err != nil {
		t.Fatalf("Routes: %v", err)
	}
	router.ServeHTTP(rr, req)
	if rr.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
	}
	if !strings.Contains(rr.Body.String(), "read-only") {
		t.Fatalf("expected readonly rejection, got: %s", rr.Body.String())
	}
}

func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	auditRepo := storage.NewAuditLogsRepo(store.DB())
	if err := auditRepo.Append(storage.AuditLogRecord{
		Actor:       "system",
		Action:      "config_apply",
		TargetType:  "device",
		TargetID:    "edge-01",
		DetailsJSON: `{"task_id":"task-99","profile":"gate_a","status":"success"}`,
	}); err != nil {
		t.Fatalf("Append: %v", err)
	}
	ui.auditRepo = auditRepo

	html := renderPage(t, ui, "/ui/audit")
	for _, want := range []string{"task-99", "gate_a", "下发设备分配", "成功", "edge-01"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected audit page to contain %q, got: %s", want, html)
		}
	}
	if strings.Contains(html, "暂无审计记录") {
		t.Fatalf("expected persisted audit logs to replace empty state, got: %s", html)
	}
	if strings.Contains(html, "config_apply") || strings.Contains(html, "success") {
		t.Fatalf("expected audit page to avoid raw enums, got: %s", html)
	}
}

func TestUI_DeviceDetailFallsBackToPersistedConfigState(t *testing.T) {
	ui := newTestUI(t)
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()
	stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
	if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{
		DeviceID:          "edge-01",
		TemplateName:      "helmet",
		ProfileName:       "gate_a",
		OverlaysJSON:      `["night_relaxed"]`,
		ConfigID:          "cfg-001",
		ConfigVersion:     "20260427.1",
		LastAppliedTaskID: "task-1",
	}); err != nil {
		t.Fatalf("Upsert: %v", err)
	}
	ui.stateRepo = stateRepo

	html := renderPage(t, ui, "/ui/devices/edge-01")
	for _, want := range []string{"cfg-001", "20260427.1", "helmet", "gate_a", "night_relaxed"} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected device detail to contain %q, got: %s", want, html)
		}
	}
}

func TestUI_SystemPageShowsDatabaseBackupAction(t *testing.T) {
	ui := newTestUI(t)
	ui.dbPath = filepath.Join(t.TempDir(), "app.db")

	html := renderPage(t, ui, "/ui/system")
	for _, want := range []string{
		"数据备份",
		"恢复数据库",
		`class="btn ghost js-export-db"`,
		`data-export-url="/ui/system/db-backup"`,
		`data-default-filename="app.db"`,
		"showSaveFilePicker",
		"当前浏览器不支持选择保存目录和文件名",
	} {
		if !strings.Contains(html, want) {
			t.Fatalf("expected system page to contain %q, got: %s", want, html)
		}
	}
}

func TestUI_SystemPageShowsFlashMessageFromQuery(t *testing.T) {
	ui := newTestUI(t)
	ui.dbPath = filepath.Join(t.TempDir(), "app.db")

	html := renderPage(t, ui, "/ui/system?msg=%E6%95%B0%E6%8D%AE%E5%BA%93%E6%81%A2%E5%A4%8D%E5%AE%8C%E6%88%90")
	if !strings.Contains(html, "数据库恢复完成") {
		t.Fatalf("expected system page to show success message, got: %s", html)
	}
}

func TestUI_SystemDBBackupUsesTimestampedFilename(t *testing.T) {
	ui := newTestUI(t)
	dir := t.TempDir()
	ui.dbPath = filepath.Join(dir, "app.db")
	if err := os.WriteFile(ui.dbPath, []byte("sqlite-bytes"), 0644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/ui/system/db-backup", nil)
	rr := httptest.NewRecorder()
	ui.pageSystemDBBackup(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
	}
	got := rr.Header().Get("Content-Disposition")
	if !strings.Contains(got, "attachment;") || !strings.Contains(got, "app-") || !strings.Contains(got, ".db") {
		t.Fatalf("expected timestamped filename, got %q", got)
	}
}

func TestUI_SystemDBRestoreReplacesDatabaseFile(t *testing.T) {
	ui := newTestUI(t)
	dir := t.TempDir()
	ui.dbPath = filepath.Join(dir, "app.db")
	if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "restore.db")
	if err != nil {
		t.Fatalf("CreateFormFile: %v", err)
	}
	if _, err := part.Write([]byte("new-db")); err != nil {
		t.Fatalf("Write part: %v", err)
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("Close writer: %v", err)
	}

	req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	rr := httptest.NewRecorder()
	ui.actionSystemDBRestore(rr, req)

	if rr.Code != http.StatusFound {
		t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
	}
	restored, err := os.ReadFile(ui.dbPath)
	if err != nil {
		t.Fatalf("ReadFile: %v", err)
	}
	if string(restored) != "new-db" {
		t.Fatalf("expected restored db contents, got %q", string(restored))
	}
}

func TestUI_SystemDBRestoreRequiresSelectedFile(t *testing.T) {
	ui := newTestUI(t)
	dir := t.TempDir()
	ui.dbPath = filepath.Join(dir, "app.db")
	if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	if err := writer.Close(); err != nil {
		t.Fatalf("Close writer: %v", err)
	}

	req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	rr := httptest.NewRecorder()
	ui.actionSystemDBRestore(rr, req)

	if rr.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
	}
	html := rr.Body.String()
	if !strings.Contains(html, "请先选择数据库备份文件") {
		t.Fatalf("expected friendly error message, got: %s", html)
	}
	if strings.Contains(html, "http: no such file") {
		t.Fatalf("expected raw form-file error to stay hidden, got: %s", html)
	}
}
