package service

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

	"cmr-backend/internal/apperr"
	"cmr-backend/internal/platform/assets"
	"cmr-backend/internal/platform/security"
	"cmr-backend/internal/store/postgres"
)

type ConfigService struct {
	store         *postgres.Store
	localEventDir string
	assetBaseURL  string
	publisher     *assets.OSSUtilPublisher
}

type ConfigPipelineSummary struct {
	SourceTable        string `json:"sourceTable"`
	BuildTable         string `json:"buildTable"`
	ReleaseAssetsTable string `json:"releaseAssetsTable"`
}

type LocalEventFile struct {
	FileName string `json:"fileName"`
	FullPath string `json:"fullPath"`
}

type EventConfigSourceView struct {
	ID              string         `json:"id"`
	EventID         string         `json:"eventId"`
	SourceVersionNo int            `json:"sourceVersionNo"`
	SourceKind      string         `json:"sourceKind"`
	SchemaID        string         `json:"schemaId"`
	SchemaVersion   string         `json:"schemaVersion"`
	Status          string         `json:"status"`
	Notes           *string        `json:"notes,omitempty"`
	Source          map[string]any `json:"source"`
}

type EventConfigBuildView struct {
	ID          string           `json:"id"`
	EventID     string           `json:"eventId"`
	SourceID    string           `json:"sourceId"`
	BuildNo     int              `json:"buildNo"`
	BuildStatus string           `json:"buildStatus"`
	BuildLog    *string          `json:"buildLog,omitempty"`
	Manifest    map[string]any   `json:"manifest"`
	AssetIndex  []map[string]any `json:"assetIndex"`
}

type PublishedReleaseView struct {
	EventID       string                    `json:"eventId"`
	Release       ResolvedReleaseView       `json:"release"`
	ReleaseNo     int                       `json:"releaseNo"`
	PublishedAt   string                    `json:"publishedAt"`
	Runtime       *RuntimeSummaryView       `json:"runtime,omitempty"`
	Presentation  *PresentationSummaryView  `json:"presentation,omitempty"`
	ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}

type ImportLocalEventConfigInput struct {
	EventPublicID string
	FileName      string  `json:"fileName"`
	Notes         *string `json:"notes,omitempty"`
}

type BuildPreviewInput struct {
	SourceID string `json:"sourceId"`
}

type PublishBuildInput struct {
	BuildID          string `json:"buildId"`
	RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
	PresentationID   string `json:"presentationId,omitempty"`
	ContentBundleID  string `json:"contentBundleId,omitempty"`
}

func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
	return &ConfigService{
		store:         store,
		localEventDir: localEventDir,
		assetBaseURL:  strings.TrimRight(assetBaseURL, "/"),
		publisher:     publisher,
	}
}

func (s *ConfigService) PipelineSummary() ConfigPipelineSummary {
	return ConfigPipelineSummary{
		SourceTable:        "event_config_sources",
		BuildTable:         "event_config_builds",
		ReleaseAssetsTable: "event_release_assets",
	}
}

func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) {
	dir, err := filepath.Abs(s.localEventDir)
	if err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory")
	}

	files := make([]LocalEventFile, 0)
	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}
		if strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
			continue
		}
		files = append(files, LocalEventFile{
			FileName: entry.Name(),
			FullPath: filepath.Join(dir, entry.Name()),
		})
	}
	sort.Slice(files, func(i, j int) bool {
		return files[i].FileName < files[j].FileName
	})
	return files, nil
}

func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) {
	event, err := s.requireEvent(ctx, eventPublicID)
	if err != nil {
		return nil, err
	}

	items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit)
	if err != nil {
		return nil, err
	}

	results := make([]EventConfigSourceView, 0, len(items))
	for i := range items {
		view, err := buildEventConfigSourceView(&items[i], event.PublicID)
		if err != nil {
			return nil, err
		}
		results = append(results, *view)
	}
	return results, nil
}

func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) {
	record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID))
	if err != nil {
		return nil, err
	}
	if record == nil {
		return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
	}
	return buildEventConfigSourceView(record, "")
}

func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
	record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID))
	if err != nil {
		return nil, err
	}
	if record == nil {
		return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
	}
	return buildEventConfigBuildView(record)
}

func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) {
	event, err := s.requireEvent(ctx, input.EventPublicID)
	if err != nil {
		return nil, err
	}

	fileName := strings.TrimSpace(filepath.Base(input.FileName))
	if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" {
		return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required")
	}

	dir, err := filepath.Abs(s.localEventDir)
	if err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
	}
	path := filepath.Join(dir, fileName)
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found")
	}

	source := map[string]any{}
	if err := json.Unmarshal(raw, &source); err != nil {
		return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json")
	}
	if err := validateSourceConfig(source); err != nil {
		return nil, err
	}

	nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID)
	if err != nil {
		return nil, err
	}

	note := input.Notes
	if note == nil || strings.TrimSpace(*note) == "" {
		defaultNote := "imported from local event file: " + fileName
		note = &defaultNote
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
		EventID:         event.ID,
		SourceVersionNo: nextVersion,
		SourceKind:      "event_bundle",
		SchemaID:        "event-source",
		SchemaVersion:   resolveSchemaVersion(source),
		Status:          "active",
		Source:          source,
		Notes:           note,
	})
	if err != nil {
		return nil, err
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return buildEventConfigSourceView(record, event.PublicID)
}

func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) {
	sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID))
	if err != nil {
		return nil, err
	}
	if sourceRecord == nil {
		return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
	}

	source, err := decodeJSONObject(sourceRecord.SourceJSON)
	if err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid")
	}
	if err := validateSourceConfig(source); err != nil {
		return nil, err
	}

	buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID)
	if err != nil {
		return nil, err
	}

	previewReleaseID := fmt.Sprintf("preview_%d", buildNo)
	manifest := s.buildPreviewManifest(source, previewReleaseID)
	assetIndex := s.buildAssetIndex(manifest)
	buildLog := "preview build generated from source " + sourceRecord.ID

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{
		EventID:     sourceRecord.EventID,
		SourceID:    sourceRecord.ID,
		BuildNo:     buildNo,
		BuildStatus: "success",
		BuildLog:    &buildLog,
		Manifest:    manifest,
		AssetIndex:  assetIndex,
	})
	if err != nil {
		return nil, err
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return buildEventConfigBuildView(record)
}

func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) {
	buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID))
	if err != nil {
		return nil, err
	}
	if buildRecord == nil {
		return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
	}
	if buildRecord.BuildStatus != "success" {
		return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable")
	}

	event, err := s.store.GetEventByID(ctx, buildRecord.EventID)
	if err != nil {
		return nil, err
	}
	if event == nil {
		return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
	}

	runtimeBindingID, runtimeSummary, err := s.resolvePublishRuntimeBinding(ctx, event.ID, input.RuntimeBindingID)
	if err != nil {
		return nil, err
	}
	presentationID, presentationSummary, err := s.resolvePublishPresentation(ctx, event.ID, input.PresentationID)
	if err != nil {
		return nil, err
	}
	contentBundleID, contentBundleSummary, err := s.resolvePublishContentBundle(ctx, event.ID, input.ContentBundleID)
	if err != nil {
		return nil, err
	}

	manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
	if err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
	}
	assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON)
	if err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid")
	}

	releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID)
	if err != nil {
		return nil, err
	}
	releasePublicID, err := security.GeneratePublicID("rel")
	if err != nil {
		return nil, err
	}

	configLabel := deriveConfigLabel(event, manifest, releaseNo)
	manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
	assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID)
	checksum := security.HashText(buildRecord.ManifestJSON)
	routeCode := deriveRouteCode(manifest)

	if s.publisher == nil || !s.publisher.Enabled() {
		return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured")
	}
	if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error())
	}
	if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil {
		return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error())
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{
		PublicID:         releasePublicID,
		EventID:          event.ID,
		ReleaseNo:        releaseNo,
		ConfigLabel:      configLabel,
		ManifestURL:      manifestURL,
		ManifestChecksum: &checksum,
		RouteCode:        routeCode,
		BuildID:          &buildRecord.ID,
		RuntimeBindingID: runtimeBindingID,
		PresentationID:   presentationID,
		ContentBundleID:  contentBundleID,
		Status:           "published",
		PayloadJSON:      buildRecord.ManifestJSON,
	})
	if err != nil {
		return nil, err
	}

	if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, assetIndexURL, &checksum, assetIndex)); err != nil {
		return nil, err
	}

	if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil {
		return nil, err
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return &PublishedReleaseView{
		EventID: event.PublicID,
		Release: ResolvedReleaseView{
			LaunchMode:             LaunchModeManifestRelease,
			Source:                 LaunchSourceEventCurrentRelease,
			EventID:                event.PublicID,
			ReleaseID:              releaseRecord.PublicID,
			ConfigLabel:            releaseRecord.ConfigLabel,
			ManifestURL:            releaseRecord.ManifestURL,
			ManifestChecksumSha256: releaseRecord.ManifestChecksum,
			RouteCode:              releaseRecord.RouteCode,
		},
		ReleaseNo:     releaseRecord.ReleaseNo,
		PublishedAt:   releaseRecord.PublishedAt.Format(timeRFC3339),
		Runtime:       runtimeSummary,
		Presentation:  presentationSummary,
		ContentBundle: contentBundleSummary,
	}, nil
}

func (s *ConfigService) resolvePublishRuntimeBinding(ctx context.Context, eventID string, runtimeBindingPublicID string) (*string, *RuntimeSummaryView, error) {
	runtimeBindingPublicID = strings.TrimSpace(runtimeBindingPublicID)
	if runtimeBindingPublicID == "" {
		defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
		if err != nil {
			return nil, nil, err
		}
		if defaults == nil || defaults.RuntimeBindingID == nil || defaults.RuntimeBindingPublicID == nil || defaults.PlacePublicID == nil || defaults.MapAssetPublicID == nil || defaults.TileReleasePublicID == nil || defaults.CourseSetPublicID == nil || defaults.CourseVariantPublicID == nil {
			return nil, nil, nil
		}
		return defaults.RuntimeBindingID, &RuntimeSummaryView{
			RuntimeBindingID:  *defaults.RuntimeBindingPublicID,
			PlaceID:           *defaults.PlacePublicID,
			PlaceName:         defaults.PlaceName,
			MapID:             *defaults.MapAssetPublicID,
			MapName:           defaults.MapAssetName,
			TileReleaseID:     *defaults.TileReleasePublicID,
			CourseSetID:       *defaults.CourseSetPublicID,
			CourseVariantID:   *defaults.CourseVariantPublicID,
			CourseVariantName: defaults.CourseVariantName,
			RouteCode:         defaults.RuntimeRouteCode,
		}, nil
	}

	runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, runtimeBindingPublicID)
	if err != nil {
		return nil, nil, err
	}
	if runtimeBinding == nil {
		return nil, nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
	}
	if runtimeBinding.EventID != eventID {
		return nil, nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to build event")
	}

	return &runtimeBinding.ID, &RuntimeSummaryView{
		RuntimeBindingID: runtimeBinding.PublicID,
		PlaceID:          runtimeBinding.PlacePublicID,
		MapID:            runtimeBinding.MapAssetPublicID,
		TileReleaseID:    runtimeBinding.TileReleasePublicID,
		CourseSetID:      runtimeBinding.CourseSetPublicID,
		CourseVariantID:  runtimeBinding.CourseVariantPublicID,
		RouteCode:        nil,
	}, nil
}

func (s *ConfigService) resolvePublishPresentation(ctx context.Context, eventID string, presentationPublicID string) (*string, *PresentationSummaryView, error) {
	presentationPublicID = strings.TrimSpace(presentationPublicID)
	if presentationPublicID == "" {
		defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
		if err != nil {
			return nil, nil, err
		}
		if defaults != nil && defaults.PresentationID != nil && defaults.PresentationPublicID != nil {
			record, err := s.store.GetEventPresentationByPublicID(ctx, *defaults.PresentationPublicID)
			if err != nil {
				return nil, nil, err
			}
			if record != nil {
				summary, err := buildPresentationSummaryFromRecord(record)
				if err != nil {
					return nil, nil, err
				}
				return defaults.PresentationID, summary, nil
			}
		}
		record, err := s.store.GetDefaultEventPresentationByEventID(ctx, eventID)
		if err != nil {
			return nil, nil, err
		}
		if record == nil {
			return nil, nil, nil
		}
		summary, err := buildPresentationSummaryFromRecord(record)
		if err != nil {
			return nil, nil, err
		}
		return &record.ID, summary, nil
	}
	record, err := s.store.GetEventPresentationByPublicID(ctx, presentationPublicID)
	if err != nil {
		return nil, nil, err
	}
	if record == nil {
		return nil, nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
	}
	if record.EventID != eventID {
		return nil, nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to build event")
	}
	summary, err := buildPresentationSummaryFromRecord(record)
	if err != nil {
		return nil, nil, err
	}
	return &record.ID, summary, nil
}

func (s *ConfigService) resolvePublishContentBundle(ctx context.Context, eventID string, contentBundlePublicID string) (*string, *ContentBundleSummaryView, error) {
	contentBundlePublicID = strings.TrimSpace(contentBundlePublicID)
	if contentBundlePublicID == "" {
		defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
		if err != nil {
			return nil, nil, err
		}
		if defaults != nil && defaults.ContentBundleID != nil && defaults.ContentBundlePublicID != nil {
			record, err := s.store.GetContentBundleByPublicID(ctx, *defaults.ContentBundlePublicID)
			if err != nil {
				return nil, nil, err
			}
			if record != nil {
				summary, err := buildContentBundleSummaryFromRecord(record)
				if err != nil {
					return nil, nil, err
				}
				return defaults.ContentBundleID, summary, nil
			}
		}
		record, err := s.store.GetDefaultContentBundleByEventID(ctx, eventID)
		if err != nil {
			return nil, nil, err
		}
		if record == nil {
			return nil, nil, nil
		}
		summary, err := buildContentBundleSummaryFromRecord(record)
		if err != nil {
			return nil, nil, err
		}
		return &record.ID, summary, nil
	}
	record, err := s.store.GetContentBundleByPublicID(ctx, contentBundlePublicID)
	if err != nil {
		return nil, nil, err
	}
	if record == nil {
		return nil, nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
	}
	if record.EventID != eventID {
		return nil, nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to build event")
	}
	summary, err := buildContentBundleSummaryFromRecord(record)
	if err != nil {
		return nil, nil, err
	}
	return &record.ID, summary, nil
}

func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
	eventPublicID = strings.TrimSpace(eventPublicID)
	if eventPublicID == "" {
		return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
	}
	event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
	if err != nil {
		return nil, err
	}
	if event == nil {
		return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
	}
	return event, nil
}

func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) {
	source, err := decodeJSONObject(record.SourceJSON)
	if err != nil {
		return nil, err
	}
	view := &EventConfigSourceView{
		ID:              record.ID,
		EventID:         eventPublicID,
		SourceVersionNo: record.SourceVersionNo,
		SourceKind:      record.SourceKind,
		SchemaID:        record.SchemaID,
		SchemaVersion:   record.SchemaVersion,
		Status:          record.Status,
		Notes:           record.Notes,
		Source:          source,
	}
	return view, nil
}

func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) {
	manifest, err := decodeJSONObject(record.ManifestJSON)
	if err != nil {
		return nil, err
	}
	assetIndex, err := decodeJSONArray(record.AssetIndexJSON)
	if err != nil {
		return nil, err
	}
	return &EventConfigBuildView{
		ID:          record.ID,
		EventID:     record.EventID,
		SourceID:    record.SourceID,
		BuildNo:     record.BuildNo,
		BuildStatus: record.BuildStatus,
		BuildLog:    record.BuildLog,
		Manifest:    manifest,
		AssetIndex:  assetIndex,
	}, nil
}

func validateSourceConfig(source map[string]any) error {
	requiredMap := func(parent map[string]any, key string) (map[string]any, error) {
		value, ok := parent[key]
		if !ok {
			return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
		}
		asMap, ok := value.(map[string]any)
		if !ok {
			return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key)
		}
		return asMap, nil
	}
	requiredString := func(parent map[string]any, key string) error {
		value, ok := parent[key]
		if !ok {
			return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
		}
		text, ok := value.(string)
		if !ok || strings.TrimSpace(text) == "" {
			return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key)
		}
		return nil
	}

	if err := requiredString(source, "schemaVersion"); err != nil {
		return err
	}
	app, err := requiredMap(source, "app")
	if err != nil {
		return err
	}
	if err := requiredString(app, "id"); err != nil {
		return err
	}
	if err := requiredString(app, "title"); err != nil {
		return err
	}
	m, err := requiredMap(source, "map")
	if err != nil {
		return err
	}
	if err := requiredString(m, "tiles"); err != nil {
		return err
	}
	if err := requiredString(m, "mapmeta"); err != nil {
		return err
	}
	playfield, err := requiredMap(source, "playfield")
	if err != nil {
		return err
	}
	if err := requiredString(playfield, "kind"); err != nil {
		return err
	}
	playfieldSource, err := requiredMap(playfield, "source")
	if err != nil {
		return err
	}
	if err := requiredString(playfieldSource, "type"); err != nil {
		return err
	}
	if err := requiredString(playfieldSource, "url"); err != nil {
		return err
	}
	game, err := requiredMap(source, "game")
	if err != nil {
		return err
	}
	if err := requiredString(game, "mode"); err != nil {
		return err
	}
	return nil
}

func resolveSchemaVersion(source map[string]any) string {
	if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" {
		return value
	}
	return "1"
}

func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any {
	manifest := cloneJSONObject(source)
	manifest["releaseId"] = previewReleaseID
	manifest["preview"] = true
	manifest["assetBaseUrl"] = s.assetBaseURL
	if version, ok := manifest["version"]; !ok || version == "" {
		manifest["version"] = "preview"
	}

	if m, ok := manifest["map"].(map[string]any); ok {
		if tiles, ok := m["tiles"].(string); ok {
			m["tiles"] = s.normalizeAssetURL(tiles)
		}
		if meta, ok := m["mapmeta"].(string); ok {
			m["mapmeta"] = s.normalizeAssetURL(meta)
		}
	}
	if playfield, ok := manifest["playfield"].(map[string]any); ok {
		if src, ok := playfield["source"].(map[string]any); ok {
			if url, ok := src["url"].(string); ok {
				src["url"] = s.normalizeAssetURL(url)
			}
		}
	}
	if assets, ok := manifest["assets"].(map[string]any); ok {
		for key, value := range assets {
			if text, ok := value.(string); ok {
				assets[key] = s.normalizeAssetURL(text)
			}
		}
	}

	return manifest
}

func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any {
	var assets []map[string]any
	if m, ok := manifest["map"].(map[string]any); ok {
		if tiles, ok := m["tiles"].(string); ok {
			assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles})
		}
		if meta, ok := m["mapmeta"].(string); ok {
			assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta})
		}
	}
	if playfield, ok := manifest["playfield"].(map[string]any); ok {
		if src, ok := playfield["source"].(map[string]any); ok {
			if url, ok := src["url"].(string); ok {
				assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url})
			}
		}
	}
	if rawAssets, ok := manifest["assets"].(map[string]any); ok {
		keys := make([]string, 0, len(rawAssets))
		for key := range rawAssets {
			keys = append(keys, key)
		}
		sort.Strings(keys)
		for _, key := range keys {
			if url, ok := rawAssets[key].(string); ok {
				assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url})
			}
		}
	}
	return assets
}

func (s *ConfigService) normalizeAssetURL(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return value
	}
	if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
		return value
	}
	trimmed := strings.TrimPrefix(value, "../")
	trimmed = strings.TrimPrefix(trimmed, "./")
	trimmed = strings.TrimLeft(trimmed, "/")
	return s.assetBaseURL + "/" + trimmed
}

func cloneJSONObject(source map[string]any) map[string]any {
	raw, _ := json.Marshal(source)
	cloned := map[string]any{}
	_ = json.Unmarshal(raw, &cloned)
	return cloned
}

func decodeJSONObject(raw string) (map[string]any, error) {
	result := map[string]any{}
	if err := json.Unmarshal([]byte(raw), &result); err != nil {
		return nil, err
	}
	return result, nil
}

func decodeJSONArray(raw string) ([]map[string]any, error) {
	if strings.TrimSpace(raw) == "" {
		return []map[string]any{}, nil
	}
	var result []map[string]any
	if err := json.Unmarshal([]byte(raw), &result); err != nil {
		return nil, err
	}
	return result, nil
}

func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string {
	if app, ok := manifest["app"].(map[string]any); ok {
		if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" {
			return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo)
		}
	}
	if event != nil && strings.TrimSpace(event.DisplayName) != "" {
		return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo)
	}
	return fmt.Sprintf("Release %d", releaseNo)
}

func deriveRouteCode(manifest map[string]any) *string {
	if playfield, ok := manifest["playfield"].(map[string]any); ok {
		if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" {
			route := strings.TrimSpace(value)
			return &route
		}
	}
	return nil
}

func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL, assetIndexURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
	assets := []postgres.UpsertEventReleaseAssetParams{
		{
			EventReleaseID: eventReleaseID,
			AssetType:      "manifest",
			AssetKey:       "manifest",
			AssetURL:       manifestURL,
			Checksum:       checksum,
			Meta:           map[string]any{"source": "published-build"},
		},
		{
			EventReleaseID: eventReleaseID,
			AssetType:      "other",
			AssetKey:       "asset-index",
			AssetURL:       assetIndexURL,
			Meta:           map[string]any{"source": "published-build"},
		},
	}

	for _, asset := range assetIndex {
		assetType, _ := asset["assetType"].(string)
		assetKey, _ := asset["assetKey"].(string)
		assetURL, _ := asset["assetUrl"].(string)
		if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
			continue
		}
		mappedType := assetType
		if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
			mappedType = "other"
		}
		assets = append(assets, postgres.UpsertEventReleaseAssetParams{
			EventReleaseID: eventReleaseID,
			AssetType:      mappedType,
			AssetKey:       assetKey,
			AssetURL:       assetURL,
			Meta:           asset,
		})
	}

	return assets
}
