package service

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"3588AdminBackend/internal/config"
	"3588AdminBackend/internal/models"
	"3588AdminBackend/internal/storage"
)

func mustSaveTemplateRecord(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 mustReadFileBytes(t *testing.T, path string) []byte {
	t.Helper()
	body, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("ReadFile(%s): %v", path, err)
	}
	return body
}

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

func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
	root := t.TempDir()
	templateBody := `{
  "name": "std_workshop_face_recognition_shoe_alarm",
  "source": "standard",
  "params": {
    "minio_endpoint": "http://10.0.0.49:9000",
    "minio_bucket": "myminio",
    "external_get_token_url": "http://10.0.0.49:8080/api/getToken",
    "external_put_message_url": "http://10.0.0.49:8080/api/putMessage",
    "tenant_code": "32",
    "snapshot_region": "us-east-1"
  },
  "template": {"nodes": [], "edges": []}
}`
	mustWrite(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"}}
  }]
}`)
	mustWrite(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())
	mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody)

	svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil {
		t.Fatalf("SaveOverlay: %v", err)
	}
	item, err := svc.GetProfileAsset("local_3588_test")
	if err != nil {
		t.Fatalf("GetProfileAsset: %v", err)
	}

	if item.Name != "local_3588_test" || len(item.Instances) != 1 {
		t.Fatalf("unexpected profile summary: %#v", item)
	}
	inst := item.Instances[0]
	if inst.DisplayName != "东门入口" {
		t.Fatalf("expected display name to be parsed, got %#v", inst)
	}
	if inst.PublishRTSPPort != "8555" {
		t.Fatalf("expected rtsp port to be stringified, got %#v", inst.PublishRTSPPort)
	}
	if _, ok := inst.AdvancedParams["queue_debug"]; !ok {
		t.Fatalf("expected advanced params to preserve extra keys, got %#v", inst.AdvancedParams)
	}
	if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil {
		t.Fatalf("GetTemplateAsset: %v", err)
	} else {
		if _, ok := item.AdvancedParams["snapshot_region"]; !ok {
			t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams)
		}
	}
}

func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`)
	mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
	mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{
  "description": "启用人脸识别和陌生候选调试日志，用于联调和测试。",
  "instance_overrides": {
    "*": {"override": {}},
    "cam1": {"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())
	svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	mustImportPreviewAssets(t, svc)
	item, err := svc.GetOverlayAsset("face_debug")
	if err != nil {
		t.Fatalf("GetOverlayAsset: %v", err)
	}

	if item.OverrideTargetNum != 2 {
		t.Fatalf("expected 2 override targets, got %#v", item)
	}
	if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" {
		t.Fatalf("unexpected targets: %#v", item.OverrideTargets)
	}
	if item.Description != "启用人脸识别和陌生候选调试日志，用于联调和测试。" {
		t.Fatalf("expected localized overlay description, got %#v", item.Description)
	}
}

func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
	root := t.TempDir()
	mustWrite(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"}}
    },
    {
      "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"}}
    }
  ]
}`)

	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.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}
	if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}
	svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	editor, err := svc.GetProfileEditor("local_3588_test")
	if err != nil {
		t.Fatalf("GetProfileEditor: %v", err)
	}

	if editor.Name != "local_3588_test" {
		t.Fatalf("unexpected profile name: %#v", editor)
	}
	if editor.BusinessName != "A厂区视觉识别" {
		t.Fatalf("expected business name to be parsed, got %#v", editor.BusinessName)
	}
	if editor.SiteName != "A厂区" {
		t.Fatalf("expected profile site name to be parsed, got %#v", editor.SiteName)
	}
	if editor.DeviceCode != "rk3588-a-001" {
		t.Fatalf("expected legacy device code to be preserved internally, got %#v", editor.DeviceCode)
	}
	if len(editor.Instances) != 2 {
		t.Fatalf("expected two instances, got %#v", editor.Instances)
	}
	if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" {
		t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0])
	}
	if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" {
		t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1])
	}
	if editor.Instances[0].PublishRTSPPort != "8555" {
		t.Fatalf("expected rtsp port to be stringified, got %#v", editor.Instances[0].PublishRTSPPort)
	}
	if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" {
		t.Fatalf("unexpected queue model: %#v", editor.Queue)
	}
	if editor.Instances[0].VideoSourceRef != "gate_cam_01" {
		t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0])
	}
	if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok {
		t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams)
	}
}

func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
	svc := NewConfigPreviewService(&config.Config{})
	editor := ConfigProfileEditor{
		Name:         "local_3588_test",
		BusinessName: "A厂区视觉识别",
		Description:  "test profile",
		OverlayName:  "face_debug",
		DeviceCode:   "rk3588-a-001",
		SiteName:     "A厂区",
		Instances: []ConfigProfileInstanceEditor{
			{
				Name:            "cam1",
				Template:        "std_workshop_face_recognition_shoe_alarm",
				VideoSourceRef:  "gate_cam_01",
				DisplayName:     "东门入口",
				PublishHLSPath:  "./web/hls/cam1/index.m3u8",
				PublishRTSPPort: "8555",
				PublishRTSPPath: "/live/cam1",
				ChannelNo:       "cam1",
				AdvancedParams: map[string]any{
					"queue_debug": true,
				},
			},
			{
				Name:            "cam2",
				Template:        "std_workshop_face_recognition_shoe_alarm",
				VideoSourceRef:  "line_cam_02",
				DisplayName:     "视觉识别终端-B厂区",
				PublishHLSPath:  "./web/hls/cam2/index.m3u8",
				PublishRTSPPort: "8556",
				PublishRTSPPath: "/live/cam2",
				ChannelNo:       "cam2",
			},
		},
	}

	doc, err := svc.BuildProfileDocument(editor)
	if err != nil {
		t.Fatalf("BuildProfileDocument: %v", err)
	}

	if doc["name"] != "local_3588_test" {
		t.Fatalf("unexpected doc name: %#v", doc)
	}
	if doc["business_name"] != "A厂区视觉识别" {
		t.Fatalf("unexpected business name: %#v", doc)
	}
	if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" {
		t.Fatalf("unexpected overlays doc: %#v", doc["overlays"])
	}
	queue, _ := doc["queue"].(map[string]any)
	if queue["size"] != 8 || queue["strategy"] != "drop_oldest" {
		t.Fatalf("unexpected queue doc: %#v", queue)
	}
	instances, _ := doc["instances"].([]map[string]any)
	if len(instances) != 2 {
		t.Fatalf("expected two instances, got %#v", doc["instances"])
	}
	params, _ := instances[0]["params"].(map[string]any)
	if params["queue_debug"] != true {
		t.Fatalf("expected advanced param to survive rebuild, got %#v", params)
	}
	if _, exists := params["video_source_ref"]; exists {
		t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params)
	}
	params2, _ := instances[1]["params"].(map[string]any)
	if len(params2) != 0 {
		t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2)
	}
	sceneMeta, _ := instances[0]["scene_meta"].(map[string]any)
	if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" {
		t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta)
	}
}

func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) {
	svc := NewConfigPreviewService(&config.Config{})
	doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
		Name: "line_a",
		Instances: []ConfigProfileInstanceEditor{{
			Name:        "cam1",
			Template:    "std_workshop_face_recognition_shoe_alarm",
			DisplayName: "B厂区通道1",
			SiteName:    "B厂区",
			InputBindings: map[string]InputBindingEditor{
				"video_input_main": {VideoSourceRef: "gate_cam_01"},
			},
			ServiceBindings: map[string]ServiceBindingEditor{
				"object_storage_main": {ServiceRef: "minio_main"},
				"token_service_main":  {ServiceRef: "token_main"},
				"alarm_service_main":  {ServiceRef: "alarm_main"},
			},
			OutputBindings: map[string]OutputBindingEditor{
				"stream_output_main": {
					PublishHLSPath:  "./web/hls/cam1/index.m3u8",
					PublishRTSPPort: "8555",
					PublishRTSPPath: "/live/cam1",
					ChannelNo:       "cam1",
				},
			},
		}},
	})
	if err != nil {
		t.Fatalf("BuildProfileDocument: %v", err)
	}
	instances, _ := doc["instances"].([]map[string]any)
	if len(instances) != 1 {
		t.Fatalf("expected one instance, got %#v", doc["instances"])
	}
	inst := instances[0]
	inputBindings, _ := inst["input_bindings"].(map[string]any)
	if inputBindings == nil {
		t.Fatalf("expected input_bindings, got %#v", inst)
	}
	videoInput, _ := inputBindings["video_input_main"].(map[string]any)
	if videoInput["video_source_ref"] != "gate_cam_01" {
		t.Fatalf("unexpected input binding: %#v", videoInput)
	}
	serviceBindings, _ := inst["service_bindings"].(map[string]any)
	objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
	if objectStorage["service_ref"] != "minio_main" {
		t.Fatalf("unexpected service binding: %#v", serviceBindings)
	}
	outputBindings, _ := inst["output_bindings"].(map[string]any)
	streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
	if streamOutput["publish_rtsp_port"] != 8555 {
		t.Fatalf("unexpected output binding: %#v", streamOutput)
	}
	sceneMeta, _ := inst["scene_meta"].(map[string]any)
	if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" {
		t.Fatalf("unexpected scene meta: %#v", sceneMeta)
	}
}

func TestBuildProfileDocumentDefaultsStreamOutputFromInstanceName(t *testing.T) {
	svc := NewConfigPreviewService(&config.Config{})
	doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
		Name: "line_a",
		Instances: []ConfigProfileInstanceEditor{{
			Name:        "cam7",
			Template:    "std_workshop_face_recognition_shoe_alarm",
			DisplayName: "B厂区通道7",
			InputBindings: map[string]InputBindingEditor{
				"video_input_main": {VideoSourceRef: "gate_cam_07"},
			},
			OutputBindings: map[string]OutputBindingEditor{
				"stream_output_main": {
					PublishRTSPPort: "8558",
				},
			},
		}},
	})
	if err != nil {
		t.Fatalf("BuildProfileDocument: %v", err)
	}
	instances, _ := doc["instances"].([]map[string]any)
	inst := instances[0]
	outputBindings, _ := inst["output_bindings"].(map[string]any)
	streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
	if streamOutput["publish_hls_path"] != "./web/hls/cam7/index.m3u8" {
		t.Fatalf("expected default hls path, got %#v", streamOutput)
	}
	if streamOutput["publish_rtsp_path"] != "/live/cam7" {
		t.Fatalf("expected default rtsp path, got %#v", streamOutput)
	}
	if streamOutput["channel_no"] != "cam7" {
		t.Fatalf("expected default channel no, got %#v", streamOutput)
	}
	if streamOutput["publish_rtsp_port"] != 8558 {
		t.Fatalf("expected explicit rtsp port to be preserved, got %#v", streamOutput)
	}
}

func TestListVideoSources(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.SaveVideoSource(
		"gate_cam_01",
		"rtsp",
		"东门入口",
		"东门主入口摄像头",
		`{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`,
	); err != nil {
		t.Fatalf("SaveVideoSource: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{}, repo)
	items, err := svc.ListVideoSources()
	if err != nil {
		t.Fatalf("ListVideoSources: %v", err)
	}
	if len(items) != 1 {
		t.Fatalf("expected 1 video source, got %#v", items)
	}
	if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" {
		t.Fatalf("unexpected video source summary: %#v", items[0])
	}
	if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" {
		t.Fatalf("unexpected video source config: %#v", items[0])
	}
}

func TestDeleteVideoSourceBlocksWhenReferenced(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.SaveVideoSource(
		"gate_cam_01",
		"rtsp",
		"东门入口",
		"东门主入口摄像头",
		`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
	); err != nil {
		t.Fatalf("SaveVideoSource: %v", err)
	}
	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"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{}, repo)
	err = svc.DeleteVideoSource("gate_cam_01")
	if err == nil || !strings.Contains(err.Error(), "已被场景模板引用") {
		t.Fatalf("expected referenced delete to be blocked, got %v", err)
	}
}

func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) {
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()

	svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
	err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
		Name:        "东门主入口",
		SourceType:  "rtsp",
		Area:        "东门",
		Description: "入口相机",
		Config: VideoSourceConfig{
			URL: "rtsp://10.0.0.1/live",
		},
	})
	if err != nil {
		t.Fatalf("expected chinese video source name to be accepted, got %v", err)
	}
	item, err := svc.GetVideoSource("东门主入口")
	if err != nil {
		t.Fatalf("GetVideoSource: %v", err)
	}
	if item == nil || item.Name != "东门主入口" {
		t.Fatalf("unexpected saved item: %#v", item)
	}
}

func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) {
	svc := NewConfigPreviewService(&config.Config{})
	_, err := svc.BuildProfileDocument(ConfigProfileEditor{
		Name: "local_3588_test",
		Instances: []ConfigProfileInstanceEditor{
			{
				Name:            "cam1",
				VideoSourceRef:  "gate_cam_01",
				DisplayName:     "视觉识别终端-A厂区",
				PublishRTSPPort: "bad-port",
			},
		},
	})
	if err == nil {
		t.Fatal("expected invalid port error")
	}
}

func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) {
	svc := NewConfigPreviewService(&config.Config{})
	doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
		Name: "local_3588_test",
		Instances: []ConfigProfileInstanceEditor{
			{
				Name:           "cam1",
				VideoSourceRef: "gate_cam_01",
				DisplayName:    "视觉识别终端-A厂区",
			},
		},
	})
	if err != nil {
		t.Fatalf("BuildProfileDocument: %v", err)
	}
	body, err := json.Marshal(doc)
	if err != nil {
		t.Fatalf("Marshal: %v", err)
	}
	if len(body) == 0 {
		t.Fatal("expected json body")
	}
}

func TestConfigPreviewServiceListsSourcesFromAssetsRepo(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)
	}
	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)
	}
	if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil {
		t.Fatalf("SaveOverlay: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{}, repo)
	sources, err := svc.ListSources()
	if err != nil {
		t.Fatalf("ListSources: %v", err)
	}
	if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
		t.Fatalf("unexpected templates: %#v", got)
	}
	if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "gate_a" {
		t.Fatalf("unexpected profiles: %#v", got)
	}
	if got := sourceNames(sources.Overlays); len(got) != 1 || got[0] != "night_relaxed" {
		t.Fatalf("unexpected overlays: %#v", got)
	}
}

func TestListIntegrationServices(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.SaveIntegrationService(
		"minio_primary",
		"object_storage",
		"primary object store",
		false,
		`{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false,"config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`,
	); err != nil {
		t.Fatalf("SaveIntegrationService: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{}, repo)
	items, err := svc.ListIntegrationServices()
	if err != nil {
		t.Fatalf("ListIntegrationServices: %v", err)
	}
	if len(items) != 1 {
		t.Fatalf("expected 1 integration service, got %#v", items)
	}
	if items[0].Name != "minio_primary" || items[0].Type != "object_storage" || items[0].Enabled {
		t.Fatalf("unexpected integration service summary: %#v", items[0])
	}

	item, err := svc.GetIntegrationService("minio_primary")
	if err != nil {
		t.Fatalf("GetIntegrationService: %v", err)
	}
	if item == nil {
		t.Fatal("expected integration service")
	}
	if item.Description != "primary object store" {
		t.Fatalf("unexpected integration service description: %#v", item)
	}
	if item.Type != "object_storage" || item.Enabled {
		t.Fatalf("unexpected integration service status: %#v", item)
	}
	if got := stringValue(item.Raw["type"]); got != "object_storage" {
		t.Fatalf("expected raw type to come from body_json, got %#v", item.Raw)
	}
	if got := stringValue(item.Raw["provider"]); got != "minio" {
		t.Fatalf("unexpected integration service provider: %#v", item.Raw)
	}
	if item.TypeLabel != "对象存储" || item.AddressSummary != "http://10.0.0.49:9000 / myminio" {
		t.Fatalf("unexpected integration display fields: %#v", item)
	}
	if item.ObjectStorage == nil || item.ObjectStorage.Bucket != "myminio" {
		t.Fatalf("expected object storage details, got %#v", item)
	}
	configMap, _ := item.Raw["config"].(map[string]any)
	if got := stringValue(configMap["endpoint"]); got != "http://10.0.0.49:9000" {
		t.Fatalf("unexpected integration service endpoint: %#v", item.Raw)
	}
	if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled {
		t.Fatalf("expected raw enabled=false, got %#v", item.Raw)
	}
}

func TestListIntegrationServicesCountsProfileReferences(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.SaveIntegrationService(
		"minio_primary",
		"object_storage",
		"primary object store",
		true,
		`{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`,
	); err != nil {
		t.Fatalf("SaveIntegrationService: %v", err)
	}
	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_primary"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{}, repo)
	items, err := svc.ListIntegrationServices()
	if err != nil {
		t.Fatalf("ListIntegrationServices: %v", err)
	}
	if len(items) != 1 || items[0].RefCount != 1 {
		t.Fatalf("expected one referenced service, got %#v", items)
	}
}

func TestGetIntegrationServiceNotFound(t *testing.T) {
	store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	defer store.Close()

	svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
	item, err := svc.GetIntegrationService("missing_service")
	if !strings.Contains(err.Error(), "file does not exist") && !os.IsNotExist(err) {
		t.Fatalf("expected not found error, got item=%#v err=%v", item, err)
	}
	if item != nil {
		t.Fatalf("expected nil item for missing integration service, got %#v", item)
	}
}

func TestGetIntegrationServicePrefersRecordTypeOverRawType(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.SaveIntegrationService(
		"minio_primary",
		"object_storage",
		"primary object store",
		true,
		`{"name":"minio_primary","type":"minio","provider":"minio","enabled":true}`,
	); err != nil {
		t.Fatalf("SaveIntegrationService: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{}, repo)
	item, err := svc.GetIntegrationService("minio_primary")
	if err != nil {
		t.Fatalf("GetIntegrationService: %v", err)
	}
	if item.Type != "object_storage" {
		t.Fatalf("expected canonical type from record, got %#v", item)
	}
	if got := stringValue(item.Raw["type"]); got != "minio" {
		t.Fatalf("expected raw type to preserve original body_json, got %#v", item.Raw)
	}
}

func TestDeleteIntegrationServiceBlocksWhenReferenced(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.SaveIntegrationService(
		"minio_primary",
		"object_storage",
		"primary object store",
		true,
		`{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`,
	); err != nil {
		t.Fatalf("SaveIntegrationService: %v", err)
	}
	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_primary"}}}]}`); err != nil {
		t.Fatalf("SaveProfile: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{}, repo)
	err = svc.DeleteIntegrationService("minio_primary")
	if err == nil || !strings.Contains(err.Error(), "used by scene configs") {
		t.Fatalf("expected referenced delete to be blocked, got %v", err)
	}
}

func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(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())
	svc := NewConfigPreviewService(&config.Config{}, repo)
	editor := ConfigProfileEditor{
		Name:         "gate_a",
		BusinessName: "厂区入口",
		Description:  "白班识别",
		SiteName:     "A厂区",
		Instances: []ConfigProfileInstanceEditor{
			{
				Name:           "cam1",
				Template:       "helmet",
				DisplayName:    "东门入口",
				VideoSourceRef: "gate_cam_01",
			},
		},
	}

	if err := svc.SaveProfileEditor(editor); err != nil {
		t.Fatalf("SaveProfileEditor: %v", err)
	}

	saved, err := repo.GetProfile("gate_a")
	if err != nil {
		t.Fatalf("GetProfile: %v", err)
	}
	if saved == nil {
		t.Fatal("expected saved profile")
	}
	if saved.BusinessName != "厂区入口" {
		t.Fatalf("expected business name, got %#v", saved)
	}
	if saved.TemplateName != "helmet" {
		t.Fatalf("expected template name to be inferred, got %#v", saved)
	}
	if saved.Description != "白班识别" {
		t.Fatalf("expected description, got %#v", saved)
	}
}

func TestConfigPreviewServicePrefersRepoTemplateOverBuiltinFallback(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{
  "name": "helmet",
  "description": "builtin template",
  "template": {"nodes": [{"id":"input_rtsp_main"}], "edges": []}
}`)
	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", "shadow template", `{"name":"helmet","description":"shadow template","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}

	svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
	item, err := svc.GetTemplateAsset("helmet")
	if err != nil {
		t.Fatalf("GetTemplateAsset: %v", err)
	}
	if item.ReadOnly || item.Origin != "user" {
		t.Fatalf("expected sqlite template to be preferred, got %#v", item)
	}
	if item.Description != "shadow template" {
		t.Fatalf("expected sqlite template payload, got %#v", item)
	}
	items, err := svc.ListTemplateAssets()
	if err != nil {
		t.Fatalf("ListTemplateAssets: %v", err)
	}
	if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly {
		t.Fatalf("expected only sqlite template in merged list, got %#v", items)
	}
}

func TestConfigPreviewServiceRejectsSavingStandardTemplateName(t *testing.T) {
	root := t.TempDir()
	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())
	svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
	if err == nil || !strings.Contains(err.Error(), "read-only") {
		t.Fatalf("expected readonly rejection, got %v", err)
	}
}

func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(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","description":"helmet template","template":{"nodes":[],"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)
	}
	svc := NewConfigPreviewService(&config.Config{}, repo)

	err = svc.RenameTemplateAsset("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`)
	if err != nil {
		t.Fatalf("RenameTemplateAsset: %v", err)
	}

	record, err := repo.GetTemplate("helmet_v2")
	if err != nil {
		t.Fatalf("GetTemplate: %v", err)
	}
	if record == nil || !strings.Contains(record.BodyJSON, `"name":"helmet_v2"`) {
		t.Fatalf("expected renamed template, got %#v", record)
	}
	profile, err := repo.GetProfile("gate_a")
	if err != nil {
		t.Fatalf("GetProfile: %v", err)
	}
	if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`)) {
		t.Fatalf("expected updated profile refs, got %#v", profile)
	}
}

func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(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)
	}
	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)
	}
	svc := NewConfigPreviewService(&config.Config{}, repo)

	err = svc.DeleteTemplateAsset("helmet")
	if err == nil || !strings.Contains(err.Error(), "used by business configs") {
		t.Fatalf("expected reference rejection, got %v", err)
	}
}

func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
	mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"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"}}}]}`)
	mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`)
	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())
	svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)

	result, err := svc.ImportAssetsFromMediaRepo()
	if err != nil {
		t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
	}
	if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
		t.Fatalf("unexpected import result: %#v", result)
	}
	record, err := repo.GetTemplate("std_face_recognition_stream")
	if err != nil {
		t.Fatalf("GetTemplate: %v", err)
	}
	if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) {
		t.Fatalf("expected standard template to be imported into sqlite, got %#v", record)
	}
}

func TestImportStandardTemplatesFromDirSyncsExisting(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`)
	mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`)
	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_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
		t.Fatalf("SaveTemplate: %v", err)
	}

	imported, err := ImportStandardTemplatesFromDir(repo, root)
	if err != nil {
		t.Fatalf("ImportStandardTemplatesFromDir: %v", err)
	}
	if imported != 2 {
		t.Fatalf("expected two synced standard templates, got %d", imported)
	}
	face, err := repo.GetTemplate("std_face_recognition_stream")
	if err != nil || face == nil {
		t.Fatalf("expected imported face template, got %#v err=%v", face, err)
	}
	if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) {
		t.Fatalf("expected imported template source marker, got %#v", face)
	}
	serviceRecord, err := repo.GetTemplate("std_service_test_stream")
	if err != nil || serviceRecord == nil {
		t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err)
	}
	if serviceRecord.Description != "standard service" {
		t.Fatalf("expected existing template to sync from directory, got %#v", serviceRecord)
	}
	if !strings.Contains(serviceRecord.BodyJSON, `"description": "standard service"`) && !strings.Contains(serviceRecord.BodyJSON, `"description":"standard service"`) {
		t.Fatalf("expected synced template body, got %#v", serviceRecord)
	}
}

func TestBuildDeviceAssignmentBoardDataSummarizesLoad(t *testing.T) {
	devices := []*models.Device{
		{DeviceID: "edge-01", DeviceName: "设备一"},
		{DeviceID: "edge-02", DeviceName: "设备二"},
		{DeviceID: "edge-03", DeviceName: "设备三"},
		{DeviceID: "edge-04", DeviceName: "设备四"},
	}
	units := []RecognitionUnitAsset{
		{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1"},
		{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2"},
		{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3", VideoSourceRef: "vs3"},
		{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4", VideoSourceRef: "vs4"},
		{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5", VideoSourceRef: "vs5"},
		{Ref: "scene_a::cam6", SceneTemplateName: "scene_a", Name: "cam6", VideoSourceRef: "vs6"},
	}
	assignments := []DeviceAssignmentAsset{
		{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
		{DeviceID: "edge-02", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam4"}, RecognitionCount: 1},
		{DeviceID: "edge-03", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam5", "scene_a::cam6"}, RecognitionCount: 2},
	}

	board := BuildDeviceAssignmentBoardData(devices, assignments, units, 4)

	if board.Stats.TotalUnits != 6 || board.Stats.TotalDevices != 4 {
		t.Fatalf("unexpected totals: %#v", board.Stats)
	}
	if board.Stats.AssignedUnits != 6 || board.Stats.UnassignedUnits != 0 {
		t.Fatalf("unexpected assignment counts: %#v", board.Stats)
	}
	if board.Stats.OverloadedDevices != 0 {
		t.Fatalf("expected no full devices in this dataset, got %#v", board.Stats)
	}
	if len(board.Cards) != 4 {
		t.Fatalf("expected four device cards, got %#v", board.Cards)
	}
	if board.Cards[0].DeviceID != "edge-01" || board.Cards[0].Status != "busy" {
		t.Fatalf("expected >50%% loaded device to sort first, got %#v", board.Cards)
	}
	if board.Cards[1].DeviceID != "edge-03" || board.Cards[1].Status != "busy" {
		t.Fatalf("expected 50%% loaded device to count as busy, got %#v", board.Cards)
	}
	if board.Cards[3].Status != "idle" {
		t.Fatalf("expected idle device card, got %#v", board.Cards[3])
	}
}

func TestBuildDeviceAssignmentBoardDataTreatsOverMaxAsFull(t *testing.T) {
	devices := []*models.Device{{DeviceID: "edge-01", DeviceName: "设备一"}}
	units := []RecognitionUnitAsset{
		{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
		{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
		{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
	}
	assignments := []DeviceAssignmentAsset{
		{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
	}

	board := BuildDeviceAssignmentBoardData(devices, assignments, units, 2)

	if board.Cards[0].Status != "full" {
		t.Fatalf("expected over-max card to collapse into full, got %#v", board.Cards[0])
	}
	if board.Stats.OverloadedDevices != 1 {
		t.Fatalf("expected full-device counter to include over-max card, got %#v", board.Stats)
	}
}

func TestBuildAutoDeviceAssignmentsBalancesUnits(t *testing.T) {
	devices := []*models.Device{
		{DeviceID: "edge-01"},
		{DeviceID: "edge-02"},
		{DeviceID: "edge-03"},
	}
	units := []RecognitionUnitAsset{
		{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
		{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
		{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
		{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4"},
		{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5"},
	}

	assignments := BuildAutoDeviceAssignments(devices, units, 2)
	if len(assignments) != 3 {
		t.Fatalf("expected three populated device assignments, got %#v", assignments)
	}
	counts := map[string]int{}
	total := 0
	for _, item := range assignments {
		counts[item.DeviceID] = len(item.RecognitionUnits)
		total += len(item.RecognitionUnits)
		if item.ProfileName != "scene_a" {
			t.Fatalf("expected scene template preserved, got %#v", item)
		}
		if len(item.RecognitionUnits) > 2 {
			t.Fatalf("expected max two units per device, got %#v", item)
		}
	}
	if total != 5 {
		t.Fatalf("expected all units assigned, got %#v", assignments)
	}
	if counts["edge-01"] != 2 || counts["edge-02"] != 2 || counts["edge-03"] != 1 {
		t.Fatalf("expected balanced distribution, got %#v", counts)
	}
}

func TestSaveDeviceAssignmentBoardPersistsAssignments(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.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)
	}
	if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
		SceneTemplateName: "scene_a",
		Name:              "cam2",
		VideoSourceRef:    "vs2",
		OutputChannel:     "cam2",
		RTSPPort:          "8555",
		BodyJSON:          `{"name":"cam2","input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`,
	}); err != nil {
		t.Fatalf("SaveRecognitionUnit cam2: %v", err)
	}
	svc := NewConfigPreviewService(&config.Config{}, repo)
	if err := svc.SaveDeviceAssignmentBoard(map[string][]string{
		"edge-01": []string{"scene_a::cam1", "scene_a::cam2"},
		"edge-02": []string{},
	}); err != nil {
		t.Fatalf("SaveDeviceAssignmentBoard: %v", err)
	}
	item, err := svc.GetDeviceAssignment("edge-01")
	if err != nil {
		t.Fatalf("GetDeviceAssignment: %v", err)
	}
	if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 2 {
		t.Fatalf("unexpected saved assignment: %#v", item)
	}
}
