package service

import (
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "sort"
    "strings"

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

var legacyBuiltinTemplateAliases = map[string]string{
    "workshop_face_shoe_alarm": "std_workshop_face_recognition_shoe_alarm",
    "std_service_smoke_stream": "std_service_test_stream",
}

type ConfigTemplateAsset struct {
    Name                  string            `json:"name"`
    Path                  string            `json:"path"`
    Origin                string            `json:"origin"`
    ReadOnly              bool              `json:"read_only"`
    Description           string            `json:"description"`
    Source                string            `json:"source"`
    Slots                 TemplateSlotGroup `json:"slots"`
    NodeCount             int               `json:"node_count"`
    EdgeCount             int               `json:"edge_count"`
    MinIOEndpoint         string            `json:"minio_endpoint"`
    MinIOBucket           string            `json:"minio_bucket"`
    ExternalGetTokenURL   string            `json:"external_get_token_url"`
    ExternalPutMessageURL string            `json:"external_put_message_url"`
    TenantCode            string            `json:"tenant_code"`
    AdvancedParams        map[string]any    `json:"advanced_params"`
    Raw                   map[string]any    `json:"raw"`
}

type ConfigProfileAsset struct {
    Name          string                       `json:"name"`
    Path          string                       `json:"path"`
    Description   string                       `json:"description"`
    BusinessName  string                       `json:"business_name"`
    QueueSize     int                          `json:"queue_size"`
    QueueStrategy string                       `json:"queue_strategy"`
    Instances     []ConfigProfileInstanceAsset `json:"instances"`
    Raw           map[string]any               `json:"raw"`
}

type ConfigProfileInstanceAsset struct {
    Name            string         `json:"name"`
    Template        string         `json:"template"`
    VideoSourceRef  string         `json:"video_source_ref"`
    DisplayName     string         `json:"display_name"`
    DeviceCode      string         `json:"device_code"`
    SiteName        string         `json:"site_name"`
    PublishHLSPath  string         `json:"publish_hls_path"`
    PublishRTSPPort string         `json:"publish_rtsp_port"`
    PublishRTSPPath string         `json:"publish_rtsp_path"`
    ChannelNo       string         `json:"channel_no"`
    AdvancedParams  map[string]any `json:"advanced_params"`
}

type ConfigOverlayAsset struct {
    Name              string         `json:"name"`
    Path              string         `json:"path"`
    Description       string         `json:"description"`
    ReadOnly          bool           `json:"read_only"`
    OverrideTargets   []string       `json:"override_targets"`
    OverrideTargetNum int            `json:"override_target_num"`
    Raw               map[string]any `json:"raw"`
}

type ConfigIntegrationServiceAsset struct {
    Name           string               `json:"name"`
    Path           string               `json:"path"`
    Type           string               `json:"type"`
    TypeLabel      string               `json:"type_label"`
    Description    string               `json:"description"`
    Enabled        bool                 `json:"enabled"`
    AddressSummary string               `json:"address_summary"`
    RefCount       int                  `json:"ref_count"`
    ObjectStorage  *ObjectStorageConfig `json:"object_storage,omitempty"`
    TokenService   *TokenServiceConfig  `json:"token_service,omitempty"`
    AlarmService   *AlarmServiceConfig  `json:"alarm_service,omitempty"`
    Raw            map[string]any       `json:"raw"`
}

type ObjectStorageConfig struct {
    Endpoint  string `json:"endpoint"`
    Bucket    string `json:"bucket"`
    AccessKey string `json:"access_key"`
    SecretKey string `json:"secret_key"`
}

type TokenServiceConfig struct {
    GetTokenURL string `json:"get_token_url"`
    Username    string `json:"username"`
    Password    string `json:"password"`
    TenantCode  string `json:"tenant_code"`
}

type AlarmServiceConfig struct {
    GetTokenURL   string `json:"get_token_url"`
    PutMessageURL string `json:"put_message_url"`
    TenantCode    string `json:"tenant_code"`
}

type ConfigVideoSourceAsset struct {
    Name            string            `json:"name"`
    Path            string            `json:"path"`
    SourceType      string            `json:"source_type"`
    SourceTypeLabel string            `json:"source_type_label"`
    Area            string            `json:"area"`
    Description     string            `json:"description"`
    RefCount        int               `json:"ref_count"`
    SourceSummary   string            `json:"source_summary"`
    Config          VideoSourceConfig `json:"config"`
    Raw             map[string]any    `json:"raw"`
}

type RecognitionUnitAsset struct {
    Ref               string         `json:"ref"`
    SceneTemplateName string         `json:"scene_template_name"`
    Name              string         `json:"name"`
    DisplayName       string         `json:"display_name"`
    SiteName          string         `json:"site_name"`
    VideoSourceRef    string         `json:"video_source_ref"`
    OutputChannel     string         `json:"output_channel"`
    RTSPPort          string         `json:"rtsp_port"`
    Description       string         `json:"description"`
    AdvancedParams    map[string]any `json:"advanced_params,omitempty"`
}

type DeviceAssignmentAsset struct {
    DeviceID          string   `json:"device_id"`
    ProfileName       string   `json:"profile_name"`
    Description       string   `json:"description"`
    RecognitionUnits  []string `json:"recognition_units"`
    RecognitionCount  int      `json:"recognition_count"`
}

type DeviceAssignmentBoardStats struct {
    TotalUnits        int     `json:"total_units"`
    TotalDevices      int     `json:"total_devices"`
    AssignedUnits     int     `json:"assigned_units"`
    UnassignedUnits   int     `json:"unassigned_units"`
    AverageLoad       float64 `json:"average_load"`
    OverloadedDevices int     `json:"overloaded_devices"`
}

type DeviceAssignmentBoardUnit struct {
    Ref               string `json:"ref"`
    SceneTemplateName string `json:"scene_template_name"`
    Name              string `json:"name"`
    DisplayName       string `json:"display_name"`
    VideoSourceRef    string `json:"video_source_ref"`
    OutputChannel     string `json:"output_channel"`
}

type DeviceAssignmentBoardCard struct {
    DeviceID       string                      `json:"device_id"`
    DeviceName     string                      `json:"device_name"`
    ProfileName    string                      `json:"profile_name"`
    AssignedCount  int                         `json:"assigned_count"`
    MaxUnits       int                         `json:"max_units"`
    Status         string                      `json:"status"`
    Units          []DeviceAssignmentBoardUnit `json:"units"`
}

type DeviceAssignmentBoard struct {
    MaxUnitsPerDevice int                         `json:"max_units_per_device"`
    Stats             DeviceAssignmentBoardStats  `json:"stats"`
    Cards             []DeviceAssignmentBoardCard `json:"cards"`
    Unassigned        []DeviceAssignmentBoardUnit `json:"unassigned"`
}

type VideoSourceConfig struct {
    URL         string `json:"url"`
    Resolution  string `json:"resolution"`
    FrameSize   string `json:"frame_size"`
    FPS         string `json:"fps"`
    VideoFormat string `json:"video_format"`
    FocalLength string `json:"focal_length"`
    MountHeight string `json:"mount_height"`
    MountAngle  string `json:"mount_angle"`
}

func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) {
    items := make([]ConfigTemplateAsset, 0)
    seen := map[string]bool{}

    if s != nil && s.assets != nil {
        records, err := s.assets.ListTemplates()
        if err != nil {
            return nil, err
        }
        for _, record := range records {
            name := strings.TrimSpace(record.Name)
            if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" {
                continue
            }
            readOnly := isStandardTemplateName(name)
            origin := "user"
            if readOnly {
                origin = "standard"
            }
            item, err := s.templateAssetFromRecord(record, origin, readOnly)
            if err != nil {
                continue
            }
            items = append(items, *item)
            seen[item.Name] = true
        }
    }

    sort.Slice(items, func(i, j int) bool {
        if items[i].ReadOnly != items[j].ReadOnly {
            return items[i].ReadOnly
        }
        return items[i].Name < items[j].Name
    })
    return items, nil
}

func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAsset, error) {
    name = canonicalTemplateAssetName(name)
    if err := validateConfigName(name); err != nil {
        return nil, err
    }
    if s != nil && s.assets != nil {
        record, err := s.assets.GetTemplate(name)
        if err != nil {
            return nil, err
        }
        if record != nil {
            readOnly := isStandardTemplateName(name)
            origin := "user"
            if readOnly {
                origin = "standard"
            }
            return s.templateAssetFromRecord(*record, origin, readOnly)
        }
    }
    return nil, os.ErrNotExist
}

func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string, bodyJSON string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    name = canonicalTemplateAssetName(name)
    if err := validateConfigName(name); err != nil {
        return err
    }
    if isStandardTemplateName(name) {
        return fmt.Errorf("standard template %q is read-only; please copy it before editing", name)
    }
    return s.assets.SaveTemplate(name, description, bodyJSON)
}

func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName string, description string, bodyJSON string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    oldName = canonicalTemplateAssetName(oldName)
    newName = canonicalTemplateAssetName(newName)
    if err := validateConfigName(oldName); err != nil {
        return err
    }
    if err := validateConfigName(newName); err != nil {
        return err
    }
    if isStandardTemplateName(oldName) {
        return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName)
    }
    if oldName != newName && isStandardTemplateName(newName) {
        return fmt.Errorf("standard template name %q is reserved", newName)
    }
    return s.assets.RenameTemplate(oldName, newName, description, bodyJSON)
}

func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    name = canonicalTemplateAssetName(name)
    if err := validateConfigName(name); err != nil {
        return err
    }
    if isStandardTemplateName(name) {
        return fmt.Errorf("standard template %q is read-only and cannot be deleted", name)
    }
    refs, err := s.profileNamesReferencingTemplate(name)
    if err != nil {
        return err
    }
    if len(refs) > 0 {
        return fmt.Errorf("template %q is used by business configs: %s", name, strings.Join(refs, ", "))
    }
    return s.assets.DeleteTemplate(name)
}

func (s *ConfigPreviewService) ListProfileAssets() ([]ConfigProfileAsset, error) {
    sources, err := s.ListSources()
    if err != nil {
        return nil, err
    }
    items := make([]ConfigProfileAsset, 0, len(sources.Profiles))
    for _, source := range sources.Profiles {
        item, err := s.GetProfileAsset(source.Name)
        if err != nil {
            continue
        }
        items = append(items, *item)
    }
    return items, nil
}

func (s *ConfigPreviewService) profileNamesReferencingTemplate(templateName string) ([]string, error) {
    if s == nil || s.assets == nil {
        return nil, nil
    }
    records, err := s.assets.ListProfiles()
    if err != nil {
        return nil, err
    }
    refs := make([]string, 0)
    for _, record := range records {
        if strings.TrimSpace(record.TemplateName) == templateName || strings.Contains(record.BodyJSON, `"`+"template"+`": "`+templateName+`"`) {
            refs = append(refs, record.Name)
        }
    }
    sort.Strings(refs)
    return refs, nil
}

func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset, error) {
    raw, path, err := s.readAssetJSON("profiles", name)
    if err != nil {
        return nil, err
    }
    queueMap, _ := raw["queue"].(map[string]any)
    units, err := s.ListRecognitionUnits()
    if err != nil {
        return nil, err
    }
    instances := make([]ConfigProfileInstanceAsset, 0)
    for _, unit := range units {
        if unit.SceneTemplateName != name {
            continue
        }
        channel := firstString(unit.OutputChannel, unit.Name)
        instances = append(instances, ConfigProfileInstanceAsset{
            Name:            unit.Name,
            Template:        stringValue(raw["primary_template_name"]),
            VideoSourceRef:  unit.VideoSourceRef,
            DisplayName:     unit.DisplayName,
            SiteName:        unit.SiteName,
            PublishHLSPath:  "./web/hls/" + unit.Name + "/index.m3u8",
            PublishRTSPPort: unit.RTSPPort,
            PublishRTSPPath: "/live/" + channel,
            ChannelNo:       channel,
            AdvancedParams:  cloneMap(unit.AdvancedParams),
        })
    }
    return &ConfigProfileAsset{
        Name:          firstString(raw["name"], name),
        Path:          path,
        Description:   stringValue(raw["description"]),
        BusinessName:  stringValue(raw["business_name"]),
        QueueSize:     intValue(queueMap["size"]),
        QueueStrategy: stringValue(queueMap["strategy"]),
        Instances:     instances,
        Raw:           raw,
    }, nil
}

func (s *ConfigPreviewService) DeleteProfileAsset(name string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    if err := validateConfigName(name); err != nil {
        return err
    }
    units, err := s.ListRecognitionUnits()
    if err != nil {
        return err
    }
    for _, unit := range units {
        if unit.SceneTemplateName == name {
            return fmt.Errorf("场景模板 %q 下仍有识别单元，无法删除", name)
        }
    }
    return s.assets.DeleteProfile(name)
}

func recognitionUnitRef(profileName string, unitName string) string {
    return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName)
}

func parseRecognitionUnitRef(ref string) (string, string, error) {
    parts := strings.SplitN(strings.TrimSpace(ref), "::", 2)
    if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
        return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref)
    }
    return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil
}

func (s *ConfigPreviewService) ListRecognitionUnits() ([]RecognitionUnitAsset, error) {
    if s == nil || s.assets == nil {
        return nil, fmt.Errorf("asset repository is not configured")
    }
    records, err := s.assets.ListRecognitionUnits()
    if err != nil {
        return nil, err
    }
    items := make([]RecognitionUnitAsset, 0, len(records))
    for _, record := range records {
        item, err := recognitionUnitFromRecord(record)
        if err != nil {
            continue
        }
        items = append(items, *item)
    }
    sort.Slice(items, func(i, j int) bool {
        if items[i].SceneTemplateName != items[j].SceneTemplateName {
            return items[i].SceneTemplateName < items[j].SceneTemplateName
        }
        return items[i].Name < items[j].Name
    })
    return items, nil
}

func (s *ConfigPreviewService) GetRecognitionUnit(ref string) (*RecognitionUnitAsset, error) {
    sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
    if err != nil {
        return nil, err
    }
    record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
    if err != nil {
        return nil, err
    }
    if record == nil {
        return nil, os.ErrNotExist
    }
    return recognitionUnitFromRecord(*record)
}

func (s *ConfigPreviewService) SaveRecognitionUnit(unit RecognitionUnitAsset, originalRef string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    sceneTemplate := strings.TrimSpace(unit.SceneTemplateName)
    if err := validateConfigName(sceneTemplate); err != nil {
        return fmt.Errorf("invalid scene template: %w", err)
    }
    unitName := strings.TrimSpace(unit.Name)
    if err := validateConfigName(unitName); err != nil {
        return fmt.Errorf("invalid recognition unit name: %w", err)
    }
    if strings.TrimSpace(unit.VideoSourceRef) == "" {
        return fmt.Errorf("video source is required")
    }
    originalTemplate := sceneTemplate
    originalName := unitName
    if strings.TrimSpace(originalRef) != "" {
        if p, n, err := parseRecognitionUnitRef(originalRef); err == nil {
            originalTemplate = p
            originalName = n
        }
    }
    if _, err := s.GetProfileEditor(sceneTemplate); err != nil {
        return err
    }
    if sceneTemplate != originalTemplate {
        if _, err := s.GetProfileEditor(originalTemplate); err != nil {
            return err
        }
    }
    if sceneTemplate != originalTemplate || unitName != originalName {
        existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName)
        if err != nil {
            return err
        }
        if existing != nil {
            return fmt.Errorf("duplicate recognition unit name: %s", unitName)
        }
    }
    if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil {
        return err
    }
    if sceneTemplate != originalTemplate || unitName != originalName {
        if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil {
            return err
        }
    }
    return nil
}

func (s *ConfigPreviewService) loadRecognitionUnitEditors(sceneTemplateName string, templateName string) ([]ConfigProfileInstanceEditor, string, string, error) {
    units, err := s.ListRecognitionUnits()
    if err != nil {
        return nil, "", "", err
    }
    instances := make([]ConfigProfileInstanceEditor, 0)
    siteName := ""
    deviceCode := ""
    for _, unit := range units {
        if unit.SceneTemplateName != sceneTemplateName {
            continue
        }
        instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName))
        if siteName == "" && strings.TrimSpace(unit.SiteName) != "" {
            siteName = strings.TrimSpace(unit.SiteName)
        }
        if deviceCode == "" {
            if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" {
                deviceCode = code
            }
        }
    }
    return instances, siteName, deviceCode, nil
}

func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName string) ConfigProfileInstanceEditor {
    channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
    rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
    return ConfigProfileInstanceEditor{
        Name:           strings.TrimSpace(unit.Name),
        Template:       templateName,
        VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef),
        DisplayName:    strings.TrimSpace(unit.DisplayName),
        SiteName:       strings.TrimSpace(unit.SiteName),
        ChannelNo:      channel,
        PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
        PublishRTSPPort: rtspPort,
        PublishRTSPPath: "/live/" + channel,
        InputBindings: map[string]InputBindingEditor{
            "video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)},
        },
        OutputBindings: map[string]OutputBindingEditor{
            "stream_output_main": {
                PublishHLSPath:  "./web/hls/" + unit.Name + "/index.m3u8",
                PublishRTSPPort: rtspPort,
                PublishRTSPPath: "/live/" + channel,
                ChannelNo:       channel,
            },
        },
        AdvancedParams: cloneMap(unit.AdvancedParams),
    }
}

func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitRecord {
    channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
    if channel == "" {
        channel = strings.TrimSpace(unit.Name)
    }
    rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
    raw := map[string]any{
        "name": strings.TrimSpace(unit.Name),
        "scene_meta": map[string]any{
            "display_name": strings.TrimSpace(unit.DisplayName),
            "site_name":    strings.TrimSpace(unit.SiteName),
        },
        "input_bindings": map[string]any{
            "video_input_main": map[string]any{
                "video_source_ref": strings.TrimSpace(unit.VideoSourceRef),
            },
        },
        "output_bindings": map[string]any{
            "stream_output_main": map[string]any{
                "publish_hls_path":  "./web/hls/" + unit.Name + "/index.m3u8",
                "publish_rtsp_port": rtspPort,
                "publish_rtsp_path": "/live/" + channel,
                "channel_no":        channel,
            },
        },
    }
    if params := cloneMap(unit.AdvancedParams); len(params) > 0 {
        raw["params"] = params
    }
    body, _ := marshalConfigJSON(raw)
    return storage.RecognitionUnitRecord{
        SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName),
        Name:              strings.TrimSpace(unit.Name),
        DisplayName:       strings.TrimSpace(unit.DisplayName),
        SiteName:          strings.TrimSpace(unit.SiteName),
        VideoSourceRef:    strings.TrimSpace(unit.VideoSourceRef),
        OutputChannel:     channel,
        RTSPPort:          rtspPort,
        Description:       strings.TrimSpace(unit.Description),
        BodyJSON:          string(body),
    }
}

func recognitionUnitFromRecord(record storage.RecognitionUnitRecord) (*RecognitionUnitAsset, error) {
    raw := map[string]any{}
    if strings.TrimSpace(record.BodyJSON) != "" {
        if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
            return nil, err
        }
    }
    sceneMeta, _ := raw["scene_meta"].(map[string]any)
    params, _ := raw["params"].(map[string]any)
    advanced := cloneMap(params)
    if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" {
        advanced["device_code"] = deviceCode
    }
    return &RecognitionUnitAsset{
        Ref:               recognitionUnitRef(record.SceneTemplateName, record.Name),
        SceneTemplateName: strings.TrimSpace(record.SceneTemplateName),
        Name:              strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)),
        DisplayName:       strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)),
        SiteName:          strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)),
        VideoSourceRef:    strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)),
        OutputChannel:     strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)),
        RTSPPort:          strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)),
        Description:       strings.TrimSpace(record.Description),
        AdvancedParams:    advanced,
    }, nil
}

func bindingStringFromAnyMap(raw map[string]any, bindingKey string, slot string, field string) string {
    bindings, _ := raw[bindingKey].(map[string]any)
    if bindings == nil {
        return ""
    }
    return bindingStringFromBindings(bindings, slot, field)
}

func bindingStringFromBindings(bindings map[string]any, slot string, field string) string {
    entry, _ := bindings[slot].(map[string]any)
    return strings.TrimSpace(stringAny(entry[field]))
}

func (e *ConfigProfileEditor) RawTemplateName() string {
    if e == nil {
        return ""
    }
    if strings.TrimSpace(e.PrimaryTemplateName) != "" {
        return strings.TrimSpace(e.PrimaryTemplateName)
    }
    return firstProfileTemplate(e.Instances)
}

func (s *ConfigPreviewService) DeleteRecognitionUnit(ref string) error {
    sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
    if err != nil {
        return err
    }
    if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil {
        return err
    } else if len(refs) > 0 {
        return fmt.Errorf("识别单元 %q 已被设备分配引用：%s", unitName, strings.Join(refs, ", "))
    }
    item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
    if err != nil {
        return err
    }
    if item == nil {
        return os.ErrNotExist
    }
    return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName)
}

func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) {
    sources, err := s.ListSources()
    if err != nil {
        return nil, err
    }
    items := make([]ConfigOverlayAsset, 0, len(sources.Overlays))
    for _, source := range sources.Overlays {
        item, err := s.GetOverlayAsset(source.Name)
        if err != nil {
            continue
        }
        items = append(items, *item)
    }
    sort.Slice(items, func(i, j int) bool {
        if items[i].ReadOnly != items[j].ReadOnly {
            return items[i].ReadOnly
        }
        return items[i].Name < items[j].Name
    })
    return items, nil
}

func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset, error) {
    raw, path, err := s.readAssetJSON("overlays", name)
    if err != nil {
        return nil, err
    }
    targets := make([]string, 0)
    if overrides, ok := raw["instance_overrides"].(map[string]any); ok {
        for key := range overrides {
            targets = append(targets, key)
        }
        sort.Strings(targets)
    }
    return &ConfigOverlayAsset{
        Name:              name,
        Path:              path,
        Description:       stringValue(raw["description"]),
        ReadOnly:          isStandardTemplateName(name),
        OverrideTargets:   targets,
        OverrideTargetNum: len(targets),
        Raw:               raw,
    }, nil
}

func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw map[string]any) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    name := strings.TrimSpace(asset.Name)
    if err := validateConfigName(name); err != nil {
        return err
    }
    if isStandardTemplateName(name) {
        return fmt.Errorf("标准调试参数 %q 为只读，请先复制后再编辑", name)
    }
    if raw == nil {
        raw = map[string]any{}
    }
    raw["name"] = name
    raw["description"] = strings.TrimSpace(asset.Description)
    body, err := marshalConfigJSON(raw)
    if err != nil {
        return err
    }
    return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body))
}

func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    if err := validateConfigName(name); err != nil {
        return err
    }
    if isStandardTemplateName(name) {
        return fmt.Errorf("标准调试参数 %q 为只读，无法删除", name)
    }
    refs, err := s.profileNamesReferencingOverlay(name)
    if err != nil {
        return err
    }
    if len(refs) > 0 {
        return fmt.Errorf("调试参数 %q 已被场景模板引用：%s", name, strings.Join(refs, ", "))
    }
    return s.assets.DeleteOverlay(name)
}

func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) {
    if s == nil || s.assets == nil {
        return nil, nil
    }
    records, err := s.assets.ListIntegrationServices()
    if err != nil {
        return nil, err
    }
    items := make([]ConfigIntegrationServiceAsset, 0, len(records))
    for _, record := range records {
        item, err := integrationServiceAssetFromRecord(record)
        if err != nil {
            continue
        }
        if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil {
            item.RefCount = len(refs)
        }
        items = append(items, *item)
    }
    sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
    return items, nil
}

func (s *ConfigPreviewService) ListVideoSources() ([]ConfigVideoSourceAsset, error) {
    if s == nil || s.assets == nil {
        return nil, nil
    }
    records, err := s.assets.ListVideoSources()
    if err != nil {
        return nil, err
    }
    items := make([]ConfigVideoSourceAsset, 0, len(records))
    for _, record := range records {
        item, err := videoSourceAssetFromRecord(record)
        if err != nil {
            continue
        }
        if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
            item.RefCount = len(refs)
        }
        items = append(items, *item)
    }
    sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
    return items, nil
}

func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAsset, error) {
    if s == nil || s.assets == nil {
        return nil, fmt.Errorf("基础配置仓库未初始化")
    }
    if err := validateVideoSourceName(name); err != nil {
        return nil, err
    }
    record, err := s.assets.GetVideoSource(name)
    if err != nil {
        return nil, err
    }
    if record == nil {
        return nil, os.ErrNotExist
    }
    item, err := videoSourceAssetFromRecord(*record)
    if err != nil {
        return nil, err
    }
    if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
        item.RefCount = len(refs)
    }
    return item, nil
}

func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("基础配置仓库未初始化")
    }
    name := strings.TrimSpace(asset.Name)
    if err := validateVideoSourceName(name); err != nil {
        return fmt.Errorf("视频源名称不合法：%w", err)
    }
    sourceType := strings.TrimSpace(asset.SourceType)
    if sourceType == "" {
        return fmt.Errorf("视频源类型不能为空")
    }
    urlValue := strings.TrimSpace(asset.Config.URL)
    if urlValue == "" {
        return fmt.Errorf("视频源地址不能为空")
    }
    raw := map[string]any{
        "name":        name,
        "source_type": sourceType,
        "area":        strings.TrimSpace(asset.Area),
        "description": strings.TrimSpace(asset.Description),
        "config":      map[string]any{},
    }
    configMap := raw["config"].(map[string]any)
    setAnyString(configMap, "url", asset.Config.URL)
    setAnyString(configMap, "resolution", asset.Config.Resolution)
    setAnyString(configMap, "frame_size", asset.Config.FrameSize)
    setAnyString(configMap, "fps", asset.Config.FPS)
    setAnyString(configMap, "video_format", asset.Config.VideoFormat)
    setAnyString(configMap, "focal_length", asset.Config.FocalLength)
    setAnyString(configMap, "mount_height", asset.Config.MountHeight)
    setAnyString(configMap, "mount_angle", asset.Config.MountAngle)
    body, err := marshalConfigJSON(raw)
    if err != nil {
        return err
    }
    return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body))
}

func (s *ConfigPreviewService) DeleteVideoSource(name string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("基础配置仓库未初始化")
    }
    if err := validateVideoSourceName(name); err != nil {
        return err
    }
    refs, err := s.profileNamesReferencingVideoSource(name)
    if err != nil {
        return err
    }
    if len(refs) > 0 {
        return fmt.Errorf("视频源 %q 已被场景模板引用：%s", name, strings.Join(refs, ", "))
    }
    return s.assets.DeleteVideoSource(name)
}

func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) {
    if s == nil || s.assets == nil {
        return nil, fmt.Errorf("asset repository is not configured")
    }
    if err := validateConfigName(name); err != nil {
        return nil, err
    }
    record, err := s.assets.GetIntegrationService(name)
    if err != nil {
        return nil, err
    }
    if record == nil {
        return nil, os.ErrNotExist
    }
    item, err := integrationServiceAssetFromRecord(*record)
    if err != nil {
        return nil, err
    }
    if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil {
        item.RefCount = len(refs)
    }
    return item, nil
}

func (s *ConfigPreviewService) DeleteIntegrationService(name string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    if err := validateConfigName(name); err != nil {
        return err
    }
    refs, err := s.profileNamesReferencingIntegrationService(name)
    if err != nil {
        return err
    }
    if len(refs) > 0 {
        return fmt.Errorf("third-party service %q is used by scene configs: %s", name, strings.Join(refs, ", "))
    }
    return s.assets.DeleteIntegrationService(name)
}

func (s *ConfigPreviewService) ListDeviceAssignments() ([]DeviceAssignmentAsset, error) {
    if s == nil || s.assets == nil {
        return nil, nil
    }
    records, err := s.assets.ListDeviceAssignments()
    if err != nil {
        return nil, err
    }
    items := make([]DeviceAssignmentAsset, 0, len(records))
    for _, record := range records {
        item, err := deviceAssignmentFromRecord(record)
        if err != nil {
            continue
        }
        items = append(items, *item)
    }
    sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID })
    return items, nil
}

func DefaultMaxUnitsPerDevice() int {
    return 4
}

func (s *ConfigPreviewService) BuildDeviceAssignmentBoard(devices []*models.Device, maxUnitsPerDevice int) (*DeviceAssignmentBoard, error) {
    assignments, err := s.ListDeviceAssignments()
    if err != nil {
        return nil, err
    }
    units, err := s.ListRecognitionUnits()
    if err != nil {
        return nil, err
    }
    return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil
}

func BuildAutoDeviceAssignments(devices []*models.Device, units []RecognitionUnitAsset, maxUnitsPerDevice int) []DeviceAssignmentAsset {
    maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
    type card struct {
        deviceID   string
        deviceName string
        profile    string
        refs       []string
    }
    cards := make([]*card, 0, len(devices))
    for _, dev := range devices {
        if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
            continue
        }
        cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()})
    }
    sort.Slice(cards, func(i, j int) bool {
        return cards[i].deviceID < cards[j].deviceID
    })
    sortedUnits := append([]RecognitionUnitAsset(nil), units...)
    sort.Slice(sortedUnits, func(i, j int) bool {
        if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName {
            return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName
        }
        return sortedUnits[i].Name < sortedUnits[j].Name
    })
    for _, unit := range sortedUnits {
        var target *card
        for _, candidate := range cards {
            if candidate.profile != "" && candidate.profile != unit.SceneTemplateName {
                continue
            }
            if len(candidate.refs) >= maxUnitsPerDevice {
                continue
            }
            if target == nil || len(candidate.refs) < len(target.refs) {
                target = candidate
            }
        }
        if target == nil {
            continue
        }
        if target.profile == "" {
            target.profile = unit.SceneTemplateName
        }
        target.refs = append(target.refs, unit.Ref)
    }
    out := make([]DeviceAssignmentAsset, 0, len(cards))
    for _, card := range cards {
        if len(card.refs) == 0 {
            continue
        }
        out = append(out, DeviceAssignmentAsset{
            DeviceID:         card.deviceID,
            ProfileName:      card.profile,
            RecognitionUnits: append([]string(nil), card.refs...),
            RecognitionCount: len(card.refs),
        })
    }
    sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID })
    return out
}

func BuildDeviceAssignmentBoardData(devices []*models.Device, assignments []DeviceAssignmentAsset, units []RecognitionUnitAsset, maxUnitsPerDevice int) *DeviceAssignmentBoard {
    maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
    unitMap := make(map[string]RecognitionUnitAsset, len(units))
    for _, unit := range units {
        unitMap[unit.Ref] = unit
    }
    deviceByID := map[string]*models.Device{}
    for _, dev := range devices {
        if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
            continue
        }
        deviceByID[strings.TrimSpace(dev.DeviceID)] = dev
    }
    assignmentByDevice := map[string]DeviceAssignmentAsset{}
    for _, assignment := range assignments {
        assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment
    }
    deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice))
    seenDeviceIDs := map[string]struct{}{}
    for deviceID := range deviceByID {
        seenDeviceIDs[deviceID] = struct{}{}
        deviceIDs = append(deviceIDs, deviceID)
    }
    for deviceID := range assignmentByDevice {
        if _, ok := seenDeviceIDs[deviceID]; ok {
            continue
        }
        seenDeviceIDs[deviceID] = struct{}{}
        deviceIDs = append(deviceIDs, deviceID)
    }
    sort.Strings(deviceIDs)

    assignedRefs := map[string]struct{}{}
    cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs))
    for _, deviceID := range deviceIDs {
        assignment := assignmentByDevice[deviceID]
        card := DeviceAssignmentBoardCard{
            DeviceID:    deviceID,
            DeviceName:  boardDeviceName(deviceByID[deviceID], deviceID),
            ProfileName: strings.TrimSpace(assignment.ProfileName),
            MaxUnits:    maxUnitsPerDevice,
        }
        for _, ref := range assignment.RecognitionUnits {
            unit, ok := unitMap[ref]
            if !ok {
                continue
            }
            card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit))
            assignedRefs[ref] = struct{}{}
        }
        card.AssignedCount = len(card.Units)
        card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice)
        cards = append(cards, card)
    }
    sort.Slice(cards, func(i, j int) bool {
        li := boardStatusRank(cards[i].Status)
        lj := boardStatusRank(cards[j].Status)
        if li != lj {
            return li < lj
        }
        if cards[i].AssignedCount != cards[j].AssignedCount {
            return cards[i].AssignedCount > cards[j].AssignedCount
        }
        return cards[i].DeviceID < cards[j].DeviceID
    })

    unassigned := make([]DeviceAssignmentBoardUnit, 0)
    for _, unit := range units {
        if _, ok := assignedRefs[unit.Ref]; ok {
            continue
        }
        unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit))
    }
    sort.Slice(unassigned, func(i, j int) bool {
        if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName {
            return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName
        }
        return unassigned[i].Name < unassigned[j].Name
    })

    stats := DeviceAssignmentBoardStats{
        TotalUnits:      len(units),
        TotalDevices:    len(deviceIDs),
        AssignedUnits:   len(assignedRefs),
        UnassignedUnits: len(unassigned),
    }
    if stats.TotalDevices > 0 {
        stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices)
    }
    for _, card := range cards {
        if card.Status == "full" {
            stats.OverloadedDevices++
        }
    }
    return &DeviceAssignmentBoard{
        MaxUnitsPerDevice: maxUnitsPerDevice,
        Stats:             stats,
        Cards:             cards,
        Unassigned:        unassigned,
    }
}

func deviceAssignmentFromRecord(record storage.DeviceAssignmentRecord) (*DeviceAssignmentAsset, error) {
    raw := map[string]any{}
    if strings.TrimSpace(record.BodyJSON) != "" {
        if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
            return nil, err
        }
    }
    unitRefs := make([]string, 0)
    if items, ok := raw["recognition_units"].([]any); ok {
        for _, item := range items {
            if v := strings.TrimSpace(stringValue(item)); v != "" {
                unitRefs = append(unitRefs, v)
            }
        }
    }
    return &DeviceAssignmentAsset{
        DeviceID:         record.DeviceID,
        ProfileName:      firstString(record.ProfileName, stringValue(raw["profile_name"])),
        Description:      firstString(record.Description, stringValue(raw["description"])),
        RecognitionUnits: unitRefs,
        RecognitionCount: len(unitRefs),
    }, nil
}

func normalizeMaxUnitsPerDevice(v int) int {
    if v < 1 {
        return DefaultMaxUnitsPerDevice()
    }
    if v > 8 {
        return 8
    }
    return v
}

func boardDeviceName(dev *models.Device, fallback string) string {
    if dev == nil {
        return fallback
    }
    return firstString(strings.TrimSpace(dev.DisplayName()), fallback)
}

func boardUnitFromRecognitionUnit(unit RecognitionUnitAsset) DeviceAssignmentBoardUnit {
    return DeviceAssignmentBoardUnit{
        Ref:               unit.Ref,
        SceneTemplateName: unit.SceneTemplateName,
        Name:              unit.Name,
        DisplayName:       unit.DisplayName,
        VideoSourceRef:    unit.VideoSourceRef,
        OutputChannel:     firstString(unit.OutputChannel, unit.Name),
    }
}

func boardCardStatus(count int, max int) string {
    if count <= 0 {
        return "idle"
    }
    if count >= max {
        return "full"
    }
    if count*2 >= max {
        return "busy"
    }
    return "low"
}

func boardStatusRank(status string) int {
    switch status {
    case "full":
        return 0
    case "busy":
        return 1
    case "low":
        return 2
    case "idle":
        return 3
    default:
        return 4
    }
}

func (s *ConfigPreviewService) GetDeviceAssignment(deviceID string) (*DeviceAssignmentAsset, error) {
    if s == nil || s.assets == nil {
        return nil, fmt.Errorf("asset repository is not configured")
    }
    record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID))
    if err != nil {
        return nil, err
    }
    if record == nil {
        return nil, os.ErrNotExist
    }
    return deviceAssignmentFromRecord(*record)
}

func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    deviceID := strings.TrimSpace(asset.DeviceID)
    if deviceID == "" {
        return fmt.Errorf("device id is required")
    }
    if strings.TrimSpace(asset.ProfileName) == "" {
        return fmt.Errorf("scene template is required")
    }
    seen := map[string]struct{}{}
    for _, ref := range asset.RecognitionUnits {
        profileName, _, err := parseRecognitionUnitRef(ref)
        if err != nil {
            return err
        }
        if profileName != asset.ProfileName {
            return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName)
        }
        if _, ok := seen[ref]; ok {
            return fmt.Errorf("duplicate recognition unit: %s", ref)
        }
        seen[ref] = struct{}{}
    }
    raw := map[string]any{
        "device_id":         deviceID,
        "profile_name":      strings.TrimSpace(asset.ProfileName),
        "description":       strings.TrimSpace(asset.Description),
        "recognition_units": asset.RecognitionUnits,
    }
    body, err := marshalConfigJSON(raw)
    if err != nil {
        return err
    }
    return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body))
}

func (s *ConfigPreviewService) SaveDeviceAssignmentBoard(assignments map[string][]string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    existing, err := s.ListDeviceAssignments()
    if err != nil {
        return err
    }
    existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing))
    for _, item := range existing {
        existingByDevice[item.DeviceID] = item
    }
    for deviceID, refs := range assignments {
        deviceID = strings.TrimSpace(deviceID)
        if deviceID == "" {
            continue
        }
        cleanRefs := make([]string, 0, len(refs))
        seen := map[string]struct{}{}
        for _, ref := range refs {
            ref = strings.TrimSpace(ref)
            if ref == "" {
                continue
            }
            if _, ok := seen[ref]; ok {
                continue
            }
            seen[ref] = struct{}{}
            cleanRefs = append(cleanRefs, ref)
        }
        if len(cleanRefs) == 0 {
            if _, ok := existingByDevice[deviceID]; ok {
                if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil {
                    return err
                }
            }
            continue
        }
        firstUnit, err := s.GetRecognitionUnit(cleanRefs[0])
        if err != nil {
            return err
        }
        profileName := firstUnit.SceneTemplateName
        for _, ref := range cleanRefs[1:] {
            unit, err := s.GetRecognitionUnit(ref)
            if err != nil {
                return err
            }
            if unit.SceneTemplateName != profileName {
                return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID)
            }
        }
        description := existingByDevice[deviceID].Description
        if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{
            DeviceID:         deviceID,
            ProfileName:      profileName,
            Description:      description,
            RecognitionUnits: cleanRefs,
        }); err != nil {
            return err
        }
    }
    return nil
}

func (s *ConfigPreviewService) DeleteDeviceAssignment(deviceID string) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID))
}

func (s *ConfigPreviewService) deviceAssignmentsReferencingRecognitionUnit(ref string) ([]string, error) {
    items, err := s.ListDeviceAssignments()
    if err != nil {
        return nil, err
    }
    out := make([]string, 0)
    for _, item := range items {
        for _, unitRef := range item.RecognitionUnits {
            if unitRef == ref {
                out = append(out, item.DeviceID)
                break
            }
        }
    }
    sort.Strings(out)
    return out, nil
}

func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error {
    if s == nil || s.assets == nil {
        return fmt.Errorf("asset repository is not configured")
    }
    name := strings.TrimSpace(asset.Name)
    if err := validateConfigName(name); err != nil {
        return fmt.Errorf("invalid third-party service name: %w", err)
    }
    serviceType := strings.TrimSpace(asset.Type)
    if serviceType == "" {
        return fmt.Errorf("third-party service type is required")
    }
    raw := map[string]any{
        "name":        name,
        "type":        serviceType,
        "description": strings.TrimSpace(asset.Description),
        "enabled":     asset.Enabled,
    }
    configMap := map[string]any{}
    switch serviceType {
    case "object_storage":
        if asset.ObjectStorage == nil {
            return fmt.Errorf("object storage config is required")
        }
        setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint)
        setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket)
        setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey)
        setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey)
        for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} {
            if strings.TrimSpace(stringValue(configMap[key])) == "" {
                return fmt.Errorf("object storage %s is required", key)
            }
        }
    case "alarm_service":
        if asset.AlarmService == nil {
            return fmt.Errorf("alarm service config is required")
        }
        setAnyString(configMap, "get_token_url", asset.AlarmService.GetTokenURL)
        setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL)
        setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode)
        if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" && strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" {
            return fmt.Errorf("alarm service requires at least one of get_token_url or put_message_url")
        }
    case "custom":
        raw["config"] = asset.Raw
    default:
        return fmt.Errorf("unsupported third-party service type: %s", serviceType)
    }
    if serviceType != "custom" {
        raw["config"] = configMap
    }
    body, err := marshalConfigJSON(raw)
    if err != nil {
        return err
    }
    return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body))
}

func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name string) ([]string, error) {
    if s == nil || s.assets == nil {
        return nil, nil
    }
    units, err := s.ListRecognitionUnits()
    if err != nil {
        return nil, err
    }
    name = strings.TrimSpace(name)
    if name == "" {
        return nil, nil
    }
    seen := map[string]struct{}{}
    refs := make([]string, 0)
    for _, unit := range units {
        record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name)
        if err != nil || record == nil {
            continue
        }
        raw := map[string]any{}
        if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
            continue
        }
        for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
            if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name {
                if _, ok := seen[unit.SceneTemplateName]; !ok {
                    seen[unit.SceneTemplateName] = struct{}{}
                    refs = append(refs, unit.SceneTemplateName)
                }
                break
            }
        }
    }
    sort.Strings(refs)
    return refs, nil
}

func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) {
    if s == nil || s.assets == nil {
        return nil, nil
    }
    units, err := s.ListRecognitionUnits()
    if err != nil {
        return nil, err
    }
    name = strings.TrimSpace(name)
    if name == "" {
        return nil, nil
    }
    seen := map[string]struct{}{}
    refs := make([]string, 0)
    for _, unit := range units {
        if strings.TrimSpace(unit.VideoSourceRef) == name {
            if _, ok := seen[unit.SceneTemplateName]; !ok {
                seen[unit.SceneTemplateName] = struct{}{}
                refs = append(refs, unit.SceneTemplateName)
            }
        }
    }
    sort.Strings(refs)
    return refs, nil
}

func (s *ConfigPreviewService) profileNamesReferencingOverlay(name string) ([]string, error) {
    if s == nil || s.assets == nil {
        return nil, nil
    }
    records, err := s.assets.ListProfiles()
    if err != nil {
        return nil, err
    }
    name = strings.TrimSpace(name)
    if name == "" {
        return nil, nil
    }
    refs := make([]string, 0)
    for _, record := range records {
        var raw map[string]any
        if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
            continue
        }
        for _, overlay := range profileOverlayNames(raw) {
            if strings.TrimSpace(overlay) == name {
                refs = append(refs, record.Name)
                break
            }
        }
    }
    sort.Strings(refs)
    return refs, nil
}

func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) {
    if s == nil || s.assets == nil {
        return nil, "", fmt.Errorf("asset repository is not configured")
    }
    raw, path, ok, err := s.readRepoAssetJSON(kind, name)
    if err != nil {
        return nil, "", err
    }
    if ok {
        return raw, path, nil
    }
    return nil, "", os.ErrNotExist
}

func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) {
    if err := validateConfigName(name); err != nil {
        return nil, "", false, err
    }
    var (
        record *storage.AssetRecord
        err    error
    )
    switch kind {
    case "templates":
        record, err = s.assets.GetTemplate(name)
    case "profiles":
        record, err = s.assets.GetProfile(name)
    case "overlays":
        record, err = s.assets.GetOverlay(name)
    default:
        return nil, "", false, fmt.Errorf("unsupported asset kind: %s", kind)
    }
    if err != nil {
        return nil, "", true, err
    }
    if record == nil {
        return nil, "", false, nil
    }
    var raw map[string]any
    if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
        return nil, "", true, err
    }
    if raw == nil {
        raw = map[string]any{}
    }
    if strings.TrimSpace(record.Description) != "" {
        raw["description"] = record.Description
    }
    if kind == "profiles" {
        if strings.TrimSpace(record.TemplateName) != "" {
            raw["primary_template_name"] = record.TemplateName
        }
        if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" {
            raw["business_name"] = record.BusinessName
        }
    }
    return raw, repoAssetPath(kind, name), true, nil
}

func cloneMap(in map[string]any) map[string]any {
    if len(in) == 0 {
        return map[string]any{}
    }
    out := make(map[string]any, len(in))
    for k, v := range in {
        out[k] = v
    }
    return out
}

func bindingAny(bindings map[string]any, slot string, field string) any {
    entry, _ := bindings[slot].(map[string]any)
    return entry[field]
}

func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) {
    var raw map[string]any
    if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
        return nil, err
    }
    if raw == nil {
        raw = map[string]any{}
    }
    if strings.TrimSpace(record.Description) != "" {
        raw["description"] = record.Description
    }
    return buildTemplateAsset(raw, repoAssetPath("templates", record.Name), origin, readOnly), nil
}

func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly bool) *ConfigTemplateAsset {
    templateMap, _ := raw["template"].(map[string]any)
    paramsMap, _ := raw["params"].(map[string]any)
    nodes, _ := templateMap["nodes"].([]any)
    edges, _ := templateMap["edges"].([]any)
    slots, _ := parseTemplateSlots(raw)
    advanced := cloneMap(paramsMap)
    for _, key := range []string{
        "minio_endpoint",
        "minio_bucket",
        "external_get_token_url",
        "external_put_message_url",
        "tenant_code",
    } {
        delete(advanced, key)
    }
    if len(advanced) == 0 {
        advanced = nil
    }
    return &ConfigTemplateAsset{
        Name:                  firstString(raw["name"], filepath.Base(strings.TrimSuffix(path, filepath.Ext(path)))),
        Path:                  path,
        Origin:                origin,
        ReadOnly:              readOnly,
        Description:           stringValue(raw["description"]),
        Source:                stringValue(raw["source"]),
        Slots:                 slots,
        NodeCount:             len(nodes),
        EdgeCount:             len(edges),
        MinIOEndpoint:         stringValue(paramsMap["minio_endpoint"]),
        MinIOBucket:           stringValue(paramsMap["minio_bucket"]),
        ExternalGetTokenURL:   stringValue(paramsMap["external_get_token_url"]),
        ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]),
        TenantCode:            valueString(paramsMap["tenant_code"]),
        AdvancedParams:        advanced,
        Raw:                   raw,
    }
}

func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) (*ConfigIntegrationServiceAsset, error) {
    var raw map[string]any
    if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
        return nil, err
    }
    if raw == nil {
        raw = map[string]any{}
    }
    configMap, _ := raw["config"].(map[string]any)
    sourceMap := raw
    if len(configMap) > 0 {
        sourceMap = configMap
    }
    item := &ConfigIntegrationServiceAsset{
        Name:        firstString(raw["name"], record.Name),
        Path:        repoAssetPath("integration_services", record.Name),
        Type:        firstString(record.ServiceType, stringValue(raw["type"])),
        Description: firstString(raw["description"], record.Description),
        Enabled:     boolValue(raw["enabled"], record.Enabled),
        Raw:         raw,
    }
    item.TypeLabel = integrationTypeLabel(item.Type)
    switch item.Type {
    case "object_storage":
        item.ObjectStorage = &ObjectStorageConfig{
            Endpoint:  stringValue(sourceMap["endpoint"]),
            Bucket:    stringValue(sourceMap["bucket"]),
            AccessKey: firstString(sourceMap["access_key"], stringValue(sourceMap["minio_access_key"])),
            SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])),
        }
        item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /"))
    case "alarm_service":
        item.AlarmService = &AlarmServiceConfig{
            GetTokenURL:   stringValue(sourceMap["get_token_url"]),
            PutMessageURL: stringValue(sourceMap["put_message_url"]),
            TenantCode:    stringValue(sourceMap["tenant_code"]),
        }
        summary := strings.TrimSpace(item.AlarmService.PutMessageURL)
        if summary == "" {
            summary = item.AlarmService.GetTokenURL
        }
        item.AddressSummary = summary
    case "custom":
        item.AddressSummary = "自定义"
    }
    return item, nil
}

func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoSourceAsset, error) {
    var raw map[string]any
    if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
        return nil, err
    }
    if raw == nil {
        raw = map[string]any{}
    }
    configMap, _ := raw["config"].(map[string]any)
    sourceMap := raw
    if len(configMap) > 0 {
        sourceMap = configMap
    }
    item := &ConfigVideoSourceAsset{
        Name:            firstString(raw["name"], record.Name),
        Path:            repoAssetPath("video_sources", record.Name),
        SourceType:      firstString(record.SourceType, stringValue(raw["source_type"])),
        Area:            firstString(raw["area"], record.Area),
        Description:     firstString(raw["description"], record.Description),
        SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))),
        Config: VideoSourceConfig{
            URL:         stringValue(sourceMap["url"]),
            Resolution:  stringValue(sourceMap["resolution"]),
            FrameSize:   stringValue(sourceMap["frame_size"]),
            FPS:         valueString(sourceMap["fps"]),
            VideoFormat: stringValue(sourceMap["video_format"]),
            FocalLength: stringValue(sourceMap["focal_length"]),
            MountHeight: stringValue(sourceMap["mount_height"]),
            MountAngle:  stringValue(sourceMap["mount_angle"]),
        },
        Raw: raw,
    }
    item.SourceSummary = item.Config.URL
    return item, nil
}

func integrationTypeLabel(v string) string {
    switch strings.TrimSpace(v) {
    case "object_storage":
        return "对象存储"
    case "alarm_service":
        return "告警服务"
    case "custom":
        return "自定义"
    default:
        return strings.TrimSpace(v)
    }
}

func videoSourceTypeLabel(v string) string {
    switch strings.TrimSpace(v) {
    case "rtsp":
        return "RTSP"
    case "rtmp":
        return "RTMP"
    case "file":
        return "文件"
    case "usb_camera":
        return "USB 摄像头"
    default:
        return strings.TrimSpace(v)
    }
}

func validateVideoSourceName(name string) error {
    name = strings.TrimSpace(name)
    if name == "" {
        return fmt.Errorf("不能为空")
    }
    if strings.Contains(name, "..") {
        return fmt.Errorf("不能包含 '..'")
    }
    if strings.ContainsAny(name, `/\`) {
        return fmt.Errorf("不能包含 / 或 \\")
    }
    return nil
}

func (s *ConfigPreviewService) templateIsBuiltin(name string) bool {
    return isStandardTemplateName(name)
}

func isStandardTemplateName(name string) bool {
    name = canonicalTemplateAssetName(name)
    return strings.HasPrefix(strings.TrimSpace(name), "std_")
}

func canonicalTemplateAssetName(name string) string {
    name = strings.TrimSpace(name)
    if next := strings.TrimSpace(legacyBuiltinTemplateAliases[name]); next != "" {
        return next
    }
    return name
}

func stringValue(v any) string {
    if s, ok := v.(string); ok {
        return strings.TrimSpace(s)
    }
    return ""
}

func valueString(v any) string {
    switch value := v.(type) {
    case string:
        return strings.TrimSpace(value)
    case float64:
        if float64(int(value)) == value {
            return fmt.Sprintf("%d", int(value))
        }
        return fmt.Sprintf("%v", value)
    case int:
        return fmt.Sprintf("%d", value)
    case int64:
        return fmt.Sprintf("%d", value)
    default:
        return ""
    }
}

func boolValue(v any, fallback bool) bool {
    switch value := v.(type) {
    case bool:
        return value
    case float64:
        return value != 0
    case int:
        return value != 0
    case int64:
        return value != 0
    case string:
        value = strings.TrimSpace(strings.ToLower(value))
        return value == "1" || value == "true" || value == "yes" || value == "on"
    default:
        return fallback
    }
}

func firstString(v any, fallback string) string {
    if got := stringValue(v); got != "" {
        return got
    }
    return fallback
}

func intValue(v any) int {
    switch value := v.(type) {
    case int:
        return value
    case int64:
        return int(value)
    case float64:
        return int(value)
    default:
        return 0
    }
}
