package service

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

	"safesight-control/internal/config"
	"safesight-control/internal/storage"
)

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

func TestConfigPreviewServiceListsSources(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"), `{}`)

    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)
    mustImportAssetsFromMediaRepo(t, svc)
    sources, err := svc.ListSources()
    if err != nil {
        t.Fatalf("ListSources: %v", err)
    }

    if sources.Root != "SQLite" {
        t.Fatalf("expected root %q, got %q", "SQLite", sources.Root)
    }
    if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" {
        t.Fatalf("unexpected templates: %v", got)
    }
    if got := sourceNames(sources.Profiles); strings.Join(got, ",") != "local_3588_test" {
        t.Fatalf("unexpected profiles: %v", got)
    }
    if got := sourceNames(sources.Overlays); strings.Join(got, ",") != "face_debug" {
        t.Fatalf("unexpected overlays: %v", got)
    }
}

func TestConfigPreviewServiceListSourcesAllowsEmptyConfigsDir(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())
    if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
        t.Fatalf("SaveTemplate: %v", err)
    }
    if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
        t.Fatalf("SaveProfile: %v", err)
    }

    svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, 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] != "line_a" {
        t.Fatalf("unexpected profiles: %#v", got)
    }
}

func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
    root := t.TempDir()
    svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})

    _, err := svc.Render(ConfigPreviewRequest{
        Template:      "../secret",
        Profile:       "local_3588_test",
        ConfigID:      "preview",
        ConfigVersion: "v1",
    })
    if err == nil {
        t.Fatal("expected unsafe template name to be rejected")
    }
}

func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
    root := t.TempDir()
    mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
    mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","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","description":"overlay","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)
    }

    sources, err := svc.ListSources()
    if err != nil {
        t.Fatalf("ListSources: %v", err)
    }
    if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" {
        t.Fatalf("unexpected templates after import: %#v", got)
    }
    if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil {
        t.Fatalf("GetTemplate: %v", err)
    } else if record == nil {
        t.Fatal("expected imported standard template")
    }
}

func TestConfigPreviewServiceExportsAssetJSONFromSQLite(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)
    }

    svc := NewConfigPreviewService(&config.Config{}, repo)
    body, filename, err := svc.ExportAssetJSON("templates", "helmet")
    if err != nil {
        t.Fatalf("ExportAssetJSON: %v", err)
    }
    if filename != "helmet.json" {
        t.Fatalf("unexpected export filename: %q", filename)
    }
    if string(body) != raw {
        t.Fatalf("unexpected export body: %s", string(body))
    }
}

func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testing.T) {
    root := t.TempDir()
    mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
  "name":"std_workshop_face_recognition_shoe_alarm",
  "template":{
    "nodes":[
      {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
      {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
    ],
    "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.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)
    }
    saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
    saveIntegrationServiceForPreviewTest(t, repo, "token_main", "alarm_service", `{"name":"token_main","type":"alarm_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
    saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)

    svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
    mustImportAssetsFromMediaRepo(t, svc)
    editor := ConfigProfileEditor{
        Name: "line_a",
        Instances: []ConfigProfileInstanceEditor{{
            Name:            "cam1",
            Template:        "std_workshop_face_recognition_shoe_alarm",
            VideoSourceRef:  "gate_cam_01",
            PublishHLSPath:  "./web/hls/cam1/index.m3u8",
            PublishRTSPPort: "8555",
            PublishRTSPPath: "/live/cam1",
            ChannelNo:       "cam1",
            ServiceBindings: map[string]ServiceBindingEditor{
                "object_storage_main": {ServiceRef: "minio_main"},
                "token_service_main":  {ServiceRef: "token_main"},
                "alarm_service_main":  {ServiceRef: "alarm_main"},
            },
        }},
    }

    result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{
        Template:      "std_workshop_face_recognition_shoe_alarm",
        ConfigID:      "preview",
        ConfigVersion: "v1",
    })
    if err != nil {
        t.Fatalf("RenderProfileEditor: %v", err)
    }

	var doc map[string]any
	if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
		t.Fatalf("unmarshal render result: %v", err)
	}
	templates, _ := doc["templates"].(map[string]any)
	renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
	nodes, _ := renderedTemplate["nodes"].([]any)
	inputNode, _ := nodes[0].(map[string]any)
	if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
		t.Fatalf("expected expanded input url, got %#v", inputNode)
	}
	publishNode, _ := nodes[1].(map[string]any)
	outputs, _ := publishNode["outputs"].([]any)
	output, _ := outputs[0].(map[string]any)
	if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" {
		t.Fatalf("expected expanded output path, got %#v", output)
	}
	metadata, _ := doc["metadata"].(map[string]any)
	if got := stringValue(metadata["rendered_by"]); got != "safesightd" {
		t.Fatalf("expected safesightd renderer metadata, got %#v", metadata)
	}
}

func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) {
    root := t.TempDir()
    mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
  "name":"std_workshop_face_recognition_shoe_alarm",
  "source":"standard",
  "slots":{
    "inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],
    "services":[{"name":"token_service_main","type":"alarm_service","required":false,"description":"告警服务"}],
    "outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]
  },
  "template":{
    "nodes":[
      {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
      {"id":"alarm_violation","type":"alarm","outputs":[{"external_api":{"getTokenUrl":"${slot:token_service_main.get_token_url}"}}]}
    ],
    "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.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)
    }

    svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
    mustImportAssetsFromMediaRepo(t, svc)
    editor := ConfigProfileEditor{
        Name: "line_a",
        Instances: []ConfigProfileInstanceEditor{{
            Name:            "cam1",
            Template:        "std_workshop_face_recognition_shoe_alarm",
            VideoSourceRef:  "gate_cam_01",
            PublishRTSPPort: "8555",
            ChannelNo:       "cam1",
        }},
    }

    result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{
        Template:      "std_workshop_face_recognition_shoe_alarm",
        ConfigID:      "preview",
        ConfigVersion: "v1",
    })
    if err != nil {
        t.Fatalf("RenderProfileEditor: %v", err)
    }

    var doc map[string]any
    if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
        t.Fatalf("unmarshal render result: %v", err)
    }
    templates, _ := doc["templates"].(map[string]any)
    renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
    nodes, _ := renderedTemplate["nodes"].([]any)
    alarmNode, _ := nodes[1].(map[string]any)
    outputs, _ := alarmNode["outputs"].([]any)
    output, _ := outputs[0].(map[string]any)
    externalAPI, _ := output["external_api"].(map[string]any)
    if got := stringValue(externalAPI["getTokenUrl"]); got != "" {
        t.Fatalf("expected empty getTokenUrl for unbound optional slot, got %#v", externalAPI)
    }
}

func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
    root := t.TempDir()
    mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
  "name":"std_service_test_stream",
  "template":{
    "nodes":[
      {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
      {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
    ],
    "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.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
  "name":"line_a",
  "business_name":"Line A",
  "instances":[
    {
      "name":"cam1",
      "template":"std_service_test_stream",
      "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"}}
    }
  ]
}`); err != nil {
        t.Fatalf("SaveProfile: %v", err)
    }
    if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil {
        t.Fatalf("SaveOverlay: %v", err)
    }
    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)
    }

    svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
    mustImportAssetsFromMediaRepo(t, svc)
    result, err := svc.Render(ConfigPreviewRequest{
        Template:      "std_service_test_stream",
        Profile:       "line_a",
        Overlays:      []string{"night_relaxed"},
        ConfigID:      "preview",
        ConfigVersion: "v1",
    })
    if err != nil {
        t.Fatalf("Render: %v", err)
    }

    var doc map[string]any
    if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
        t.Fatalf("unmarshal render result: %v", err)
    }
    metadata, _ := doc["metadata"].(map[string]any)
    if got := stringValue(metadata["profile"]); got != "line_a" {
        t.Fatalf("expected sqlite profile metadata, got %#v", metadata)
    }
    names, _ := metadata["overlays"].([]any)
    if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
        t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"])
    }
    templates, _ := doc["templates"].(map[string]any)
    renderedTemplate, _ := templates["std_service_test_stream__cam1"].(map[string]any)
    nodes, _ := renderedTemplate["nodes"].([]any)
    inputNode, _ := nodes[0].(map[string]any)
    if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
        t.Fatalf("expected expanded input url, got %#v", inputNode)
    }
    instances, _ := doc["instances"].([]any)
    instance, _ := instances[0].(map[string]any)
    params, _ := instance["params"].(map[string]any)
    if got := boolValue(params["debug"], false); !got {
        t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance)
    }
}

func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testing.T) {
    root := t.TempDir()
    mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
  "name":"std_service_test_stream",
  "template":{
    "nodes":[
      {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}
    ],
    "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.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
  "name":"line_a",
  "business_name":"Line A",
  "overlays":["night_relaxed"],
  "instances":[
    {
      "name":"cam1",
      "template":"std_service_test_stream",
      "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":{"params":{"debug":true}}}}`); err != nil {
        t.Fatalf("SaveOverlay: %v", err)
    }
    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)
    }

    svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
    mustImportAssetsFromMediaRepo(t, svc)
    result, err := svc.Render(ConfigPreviewRequest{
        Template:      "std_service_test_stream",
        Profile:       "line_a",
        ConfigID:      "preview",
        ConfigVersion: "v1",
    })
    if err != nil {
        t.Fatalf("Render: %v", err)
    }

    var doc map[string]any
    if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
        t.Fatalf("unmarshal render result: %v", err)
    }
    metadata, _ := doc["metadata"].(map[string]any)
    names, _ := metadata["overlays"].([]any)
    if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
        t.Fatalf("expected profile overlay metadata, got %#v", metadata["overlays"])
    }
    instances, _ := doc["instances"].([]any)
    instance, _ := instances[0].(map[string]any)
    params, _ := instance["params"].(map[string]any)
    if got := boolValue(params["debug"], false); !got {
        t.Fatalf("expected profile overlay params to merge into runtime instance, got %#v", instance)
    }
}

func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) {
    svc := NewConfigPreviewService(&config.Config{})
    err := svc.SaveProfileEditor(ConfigProfileEditor{
        Name: "line_a",
        Instances: []ConfigProfileInstanceEditor{{
            Name:           "cam1",
            Template:       "helmet",
            VideoSourceRef: "gate_cam_01",
        }},
    })
    if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") {
        t.Fatalf("expected asset repository error, got %v", err)
    }
}

func TestConfigPreviewServiceResolveSceneBindings(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)
    }
    saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)

    svc := NewConfigPreviewService(&config.Config{}, repo)
    resolved, err := svc.resolveSceneBindings(map[string]any{
        "name": "line_a",
        "instances": []any{
            map[string]any{
                "name":     "cam1",
                "template": "std_workshop_face_recognition_shoe_alarm",
                "input_bindings": map[string]any{
                    "video_input_main": map[string]any{
                        "video_source_ref": "gate_cam_01",
                    },
                },
                "service_bindings": map[string]any{
                    "object_storage_main": map[string]any{
                        "service_ref": "minio_main",
                    },
                },
            },
        },
    })
    if err != nil {
        t.Fatalf("resolveSceneBindings: %v", err)
    }
    instances, _ := resolved["instances"].([]any)
    instanceMap, _ := instances[0].(map[string]any)
    inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
    videoInput, _ := inputBindings["video_input_main"].(map[string]any)
    resolvedInput, _ := videoInput["resolved"].(map[string]any)
    if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" {
        t.Fatalf("expected resolved input url, got %#v", resolvedInput)
    }
    serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
    objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
    resolvedService, _ := objectStorage["resolved"].(map[string]any)
    if got := stringValue(resolvedService["bucket"]); got != "myminio" {
        t.Fatalf("expected resolved service binding, got %#v", resolvedService)
    }
}

func saveIntegrationServiceForPreviewTest(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 sourceNames(items []ConfigSource) []string {
    out := make([]string, 0, len(items))
    for _, item := range items {
        out = append(out, item.Name)
    }
    return out
}

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