package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"regexp"
	"strings"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/wallet-service/internal/domain/ledger"
	resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
)

var roomNameResourceColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$`)

var vipVisualResourceFormats = map[string]struct{}{
	"gif": {}, "jpeg": {}, "mp4": {}, "pag": {}, "png": {}, "svga": {}, "webp": {},
}

// ListResources 返回后台资源库列表；App 入口会设置 active_only 只读上架资源。
func (r *Repository) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceListQuery(query)

	where, args := resourceWhereSQL(query)
	total, err := r.countResourceRows(ctx, "resources", where, args...)
	if err != nil {
		return nil, 0, err
	}
	// sort_order 是运营展示权重，统一按大值优先；resource_id 只负责同权重下的稳定分页。
	rows, err := r.db.QueryContext(ctx, resourceSelectSQL()+`
		`+where+`
		ORDER BY sort_order DESC, resource_id DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]resourcedomain.Resource, 0, query.PageSize)
	for rows.Next() {
		item, err := scanResource(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

// GetResource 读取单个资源详情。
func (r *Repository) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ?`, appcode.FromContext(ctx), resourceID)
	resource, err := scanResource(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	return resource, err
}

// CreateResource 写入一个资源配置。所有资源事实只保存在 wallet-service 库。
func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceCommand(command)
	if err := validateResourceCommand(command); err != nil {
		return resourcedomain.Resource{}, err
	}

	nowMs := time.Now().UnixMilli()
	usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()

	result, err := tx.ExecContext(ctx, `
		INSERT INTO resources (
		   app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy,
		   wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url,
		   animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id,
		   created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		command.AppCode,
		command.ResourceCode,
		command.ResourceType,
		command.Name,
		command.Status,
		command.Grantable,
		command.ManagerGrantEnabled,
		command.GrantStrategy,
		command.WalletAssetType,
		command.WalletAssetAmount,
		command.PriceType,
		command.CoinPrice,
		command.GiftPointAmount,
		usageJSON,
		command.AssetURL,
		command.PreviewURL,
		command.AnimationURL,
		metadataJSON,
		command.SortOrder,
		command.OperatorUserID,
		command.OperatorUserID,
		nowMs,
		nowMs,
	)
	if err != nil {
		return resourcedomain.Resource{}, mapResourceWriteError(err)
	}
	resourceID, err := result.LastInsertId()
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	resource, err := r.getResourceForUpdate(ctx, tx, resourceID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:create:%d", resourceID, nowMs), 0, resourceID, resource, nowMs),
	}); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

// UpdateResource 全量更新资源配置；
func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ResourceID <= 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceCommand(command)
	if err := validateResourceCommand(command); err != nil {
		return resourcedomain.Resource{}, err
	}

	nowMs := time.Now().UnixMilli()
	usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()

	result, err := tx.ExecContext(ctx, `
		UPDATE resources
		SET resource_code = ?, resource_type = ?, name = ?, status = ?, grantable = ?, manager_grant_enabled = ?,
		    grant_strategy = ?, wallet_asset_type = ?, wallet_asset_amount = ?,
		    price_type = ?, coin_price = ?, gift_point_amount = ?,
		    usage_scope_json = ?, asset_url = ?, preview_url = ?, animation_url = ?,
		    metadata_json = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND resource_id = ?`,
		command.ResourceCode,
		command.ResourceType,
		command.Name,
		command.Status,
		command.Grantable,
		command.ManagerGrantEnabled,
		command.GrantStrategy,
		command.WalletAssetType,
		command.WalletAssetAmount,
		command.PriceType,
		command.CoinPrice,
		command.GiftPointAmount,
		usageJSON,
		command.AssetURL,
		command.PreviewURL,
		command.AnimationURL,
		metadataJSON,
		command.SortOrder,
		command.OperatorUserID,
		nowMs,
		command.AppCode,
		command.ResourceID,
	)
	if err != nil {
		return resourcedomain.Resource{}, mapResourceWriteError(err)
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.Resource{}, err
	} else if affected == 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	events := []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs),
	}
	giftEvents, err := r.syncGiftPricesForResourceTx(ctx, tx, resource, nowMs)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	events = append(events, giftEvents...)
	if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

func (r *Repository) syncGiftPricesForResourceTx(ctx context.Context, tx *sql.Tx, resource resourcedomain.Resource, nowMs int64) ([]walletOutboxEvent, error) {

	if resource.ResourceType != resourcedomain.TypeGift {
		return nil, nil
	}
	priceType := resourcedomain.NormalizePriceType(resource.PriceType)
	if priceType != resourcedomain.PriceTypeCoin && priceType != resourcedomain.PriceTypeFree {
		return nil, nil
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT gift_id
		FROM gift_configs
		WHERE app_code = ? AND resource_id = ?
		ORDER BY gift_id`,
		appcode.FromContext(ctx), resource.ResourceID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	giftIDs := make([]string, 0)
	for rows.Next() {
		var giftID string
		if err := rows.Scan(&giftID); err != nil {
			return nil, err
		}
		giftIDs = append(giftIDs, giftID)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if len(giftIDs) == 0 {
		return nil, nil
	}

	coinPrice := int64(0)
	if priceType == resourcedomain.PriceTypeCoin {
		coinPrice = resource.CoinPrice
	}

	giftPointAmount := int64(0)
	if _, err := tx.ExecContext(ctx, `
		UPDATE wallet_gift_prices gp
		JOIN gift_configs gc ON gc.app_code = gp.app_code AND gc.gift_id = gp.gift_id
		SET gp.charge_asset_type = ?, gp.coin_price = ?, gp.gift_point_amount = ?, gp.updated_at_ms = ?
		WHERE gc.app_code = ? AND gc.resource_id = ? AND gp.status = 'active'`,
		ledger.AssetCoin, coinPrice, giftPointAmount, nowMs, appcode.FromContext(ctx), resource.ResourceID,
	); err != nil {
		return nil, err
	}
	events := make([]walletOutboxEvent, 0, len(giftIDs))
	for _, giftID := range giftIDs {
		events = append(events, resourceOutboxEvent(
			"GiftConfigChanged",
			fmt.Sprintf("gift:%s:resource_price:%d", giftID, nowMs),
			0,
			resource.ResourceID,
			map[string]any{
				"gift_id":           giftID,
				"resource_id":       resource.ResourceID,
				"coin_price":        coinPrice,
				"gift_point_amount": giftPointAmount,
			},
			nowMs,
		))
	}
	return events, nil
}

func (r *Repository) SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ID <= 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	if !resourcedomain.ValidStatus(command.Status) {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	nowMs := time.Now().UnixMilli()
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()
	result, err := tx.ExecContext(ctx,
		`UPDATE resources SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`,
		command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID,
	)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.Resource{}, err
	} else if affected == 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	resource, err := r.getResourceForUpdate(ctx, tx, command.ID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, resource, nowMs),
	}); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

func (r *Repository) DeleteResource(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) {
	resources, err := r.BatchDeleteResources(ctx, resourcedomain.BatchDeleteResourcesCommand{
		AppCode:        command.AppCode,
		ResourceIDs:    []int64{command.ID},
		OperatorUserID: command.OperatorUserID,
	})
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if len(resources) == 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	return resources[0], nil
}

func (r *Repository) BatchDeleteResources(ctx context.Context, command resourcedomain.BatchDeleteResourcesCommand) ([]resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	resourceIDs := normalizeResourceDeleteIDs(command.ResourceIDs)
	if len(resourceIDs) == 0 {
		return nil, xerr.New(xerr.InvalidArgument, "resource_ids are required")
	}

	nowMs := time.Now().UnixMilli()
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	deletedResources := make([]resourcedomain.Resource, 0, len(resourceIDs))
	events := make([]walletOutboxEvent, 0, len(resourceIDs))
	for _, resourceID := range resourceIDs {
		resource, err := r.getResourceForUpdate(ctx, tx, resourceID)
		if err != nil {
			return nil, err
		}
		if resource.Status == resourcedomain.StatusDeleted {
			return nil, xerr.New(xerr.NotFound, "resource not found")
		}
		result, err := tx.ExecContext(ctx,
			`UPDATE resources SET resource_code = ?, status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ? AND status <> ?`,
			deletedResourceCode(resource.ResourceCode, resourceID), resourcedomain.StatusDeleted, command.OperatorUserID, nowMs, appcode.FromContext(ctx), resourceID, resourcedomain.StatusDeleted,
		)
		if err != nil {
			return nil, err
		}
		if affected, err := result.RowsAffected(); err != nil {
			return nil, err
		} else if affected == 0 {
			return nil, xerr.New(xerr.NotFound, "resource not found")
		}
		resource.Status = resourcedomain.StatusDeleted
		resource.UpdatedByUserID = command.OperatorUserID
		resource.UpdatedAtMS = nowMs
		deletedResources = append(deletedResources, resource)
		events = append(events, resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:delete:%d", resourceID, nowMs), 0, resourceID, resource, nowMs))
	}
	if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return deletedResources, nil
}

func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resourceID int64) (resourcedomain.Resource, error) {
	row := tx.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ? FOR UPDATE`, appcode.FromContext(ctx), resourceID)
	resource, err := scanResource(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	return resource, err
}

func scanResource(scanner scanTarget) (resourcedomain.Resource, error) {
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.Resource{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	return resource, nil
}

func resourceSelectSQL() string {
	return `SELECT ` + resourceColumnsWithAlias("") + ` FROM resources`
}

func resourceColumnsWithAlias(alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	return prefix + `app_code, ` + prefix + `resource_id, ` + prefix + `resource_code, ` + prefix + `resource_type, ` +
		prefix + `name, ` + prefix + `status, ` + prefix + `grantable, ` + prefix + `manager_grant_enabled, ` + prefix + `grant_strategy, ` +
		prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, ` + prefix + `price_type, ` + prefix + `coin_price, ` + prefix + `gift_point_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` +
		prefix + `asset_url, ` + prefix + `preview_url, ` + prefix + `animation_url, COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), ` +
		prefix + `sort_order, ` + prefix + `created_by_user_id, ` + prefix + `updated_by_user_id, ` + prefix + `created_at_ms, ` + prefix + `updated_at_ms`
}

func nullableResourceColumnsWithAlias(alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	return `COALESCE(` + prefix + `app_code, ''), COALESCE(` + prefix + `resource_id, 0), COALESCE(` + prefix + `resource_code, ''), ` +
		`COALESCE(` + prefix + `resource_type, ''), COALESCE(` + prefix + `name, ''), COALESCE(` + prefix + `status, ''), ` +
		`COALESCE(` + prefix + `grantable, FALSE), COALESCE(` + prefix + `manager_grant_enabled, FALSE), COALESCE(` + prefix + `grant_strategy, ''), COALESCE(` + prefix + `wallet_asset_type, ''), ` +
		`COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(` + prefix + `price_type, ''), COALESCE(` + prefix + `coin_price, 0), COALESCE(` + prefix + `gift_point_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` +
		`COALESCE(` + prefix + `asset_url, ''), COALESCE(` + prefix + `preview_url, ''), COALESCE(` + prefix + `animation_url, ''), ` +
		`COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), COALESCE(` + prefix + `sort_order, 0), ` +
		`COALESCE(` + prefix + `created_by_user_id, 0), COALESCE(` + prefix + `updated_by_user_id, 0), ` +
		`COALESCE(` + prefix + `created_at_ms, 0), COALESCE(` + prefix + `updated_at_ms, 0)`
}

func resourceWhereSQL(query resourcedomain.ListResourcesQuery) (string, []any) {
	where := `WHERE app_code = ? AND status <> 'deleted'`
	args := []any{query.AppCode}
	if query.ManagerGrantOnly {
		where += ` AND status = 'active' AND grantable = TRUE AND manager_grant_enabled = TRUE`
	} else if query.ActiveOnly {
		where += ` AND status = 'active'`
	} else if query.Status != "" {
		where += ` AND status = ?`
		args = append(args, query.Status)
	}
	if query.ResourceType != "" {
		where += ` AND resource_type = ?`
		args = append(args, query.ResourceType)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (resource_code LIKE ? OR name LIKE ?)`
		args = append(args, like, like)
	}
	return where, args
}

func normalizeResourceDeleteIDs(resourceIDs []int64) []int64 {
	seen := make(map[int64]struct{}, len(resourceIDs))
	normalized := make([]int64, 0, len(resourceIDs))
	for _, resourceID := range resourceIDs {
		if resourceID <= 0 {
			continue
		}
		if _, ok := seen[resourceID]; ok {
			continue
		}
		seen[resourceID] = struct{}{}
		normalized = append(normalized, resourceID)
	}
	return normalized
}

func deletedResourceCode(resourceCode string, resourceID int64) string {
	const maxResourceCodeLength = 96
	suffix := fmt.Sprintf("__deleted_%d", resourceID)
	if len(resourceCode)+len(suffix) <= maxResourceCodeLength {
		return resourceCode + suffix
	}
	keepLength := maxResourceCodeLength - len(suffix)
	if keepLength <= 0 {
		return suffix[len(suffix)-maxResourceCodeLength:]
	}
	return resourceCode[:keepLength] + suffix
}

func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery {
	query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
	if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
		query.ResourceType = "__invalid__"
	}
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand {
	command.ResourceCode = strings.TrimSpace(command.ResourceCode)
	command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
	command.Name = strings.TrimSpace(command.Name)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	command.GrantStrategy = resourcedomain.NormalizeGrantStrategy(command.GrantStrategy)
	command.WalletAssetType = resourcedomain.NormalizeWalletAssetType(command.WalletAssetType)
	command.PriceType = resourcedomain.NormalizePriceType(command.PriceType)
	if command.PriceType == resourcedomain.PriceTypeFree {
		command.CoinPrice = 0
		command.GiftPointAmount = 0
	} else if command.PriceType == resourcedomain.PriceTypeCoin {

		command.GiftPointAmount = 0
	}
	command.AssetURL = strings.TrimSpace(command.AssetURL)
	command.PreviewURL = strings.TrimSpace(command.PreviewURL)
	command.AnimationURL = strings.TrimSpace(command.AnimationURL)
	command.MetadataJSON = normalizeJSONObject(command.MetadataJSON)
	command.UsageScopes = normalizeStringList(command.UsageScopes)
	return command
}

func validateResourceCommand(command resourcedomain.ResourceCommand) error {
	if command.ResourceCode == "" || command.Name == "" || command.OperatorUserID < 0 {
		return xerr.New(xerr.InvalidArgument, "resource command is incomplete")
	}
	if !resourcedomain.ValidResourceType(command.ResourceType) {
		return xerr.New(xerr.InvalidArgument, "resource_type is invalid")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	if !resourcedomain.ValidGrantStrategy(command.GrantStrategy) {
		return xerr.New(xerr.InvalidArgument, "grant_strategy is invalid")
	}
	if err := validateRoomDecorationResource(command); err != nil {
		return err
	}
	if err := validateVIPTrialCardCatalogResource(command); err != nil {
		return err
	}
	if command.ResourceType == resourcedomain.TypeCoin {
		if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 {
			return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN")
		}
	} else if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 {
		return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit")
	}
	if command.PriceType == "" {
		if command.CoinPrice != 0 || command.GiftPointAmount != 0 {
			return xerr.New(xerr.InvalidArgument, "resource price type is required")
		}
		return nil
	}
	if !resourcedomain.ValidPriceType(command.PriceType) {
		return xerr.New(xerr.InvalidArgument, "resource price type is invalid")
	}
	if command.PriceType == resourcedomain.PriceTypeCoin && command.CoinPrice <= 0 {
		return xerr.New(xerr.InvalidArgument, "resource coin price is invalid")
	}
	if command.CoinPrice < 0 {
		return xerr.New(xerr.InvalidArgument, "resource price is invalid")
	}
	return nil
}

func validateVIPTrialCardCatalogResource(command resourcedomain.ResourceCommand) error {
	if command.ResourceType != resourcedomain.TypeVIPTrialCard {
		return nil
	}
	// 体验卡的等级和固定时长属于可发放事实，必须由 Wallet owner 校验，不能只依赖
	// Admin 隐藏价格/素材控件；活动快照和资源组随后都直接消费这份 metadata。
	metadata, err := vipTrialCardResourceMetadata(command.MetadataJSON)
	if err != nil || metadata.Level <= 0 || metadata.DurationMS <= 0 {
		return xerr.New(xerr.InvalidArgument, "vip trial card level and duration are required")
	}
	if command.GrantStrategy != resourcedomain.GrantStrategyNewEntitlement {
		return xerr.New(xerr.InvalidArgument, "vip trial card must use new_entitlement strategy")
	}
	if command.PriceType != resourcedomain.PriceTypeFree || command.CoinPrice != 0 || command.GiftPointAmount != 0 {
		return xerr.New(xerr.InvalidArgument, "vip trial card must be free")
	}
	if command.AssetURL != "" || command.PreviewURL != "" || command.AnimationURL != "" {
		return xerr.New(xerr.InvalidArgument, "vip trial card cannot contain visual assets")
	}
	return nil
}

func validateRoomDecorationResource(command resourcedomain.ResourceCommand) error {
	switch command.ResourceType {
	case resourcedomain.TypeGiftTraySkin:
		// Flutter 的 Fami 托盘背景使用网络 SVGA 播放器；目录 owner 在写入时拒绝空 URL 或其它格式，
		// 避免权益判定成功后客户端仍只能因解码失败回落普通渐变。
		if command.AnimationURL == "" {
			return xerr.New(xerr.InvalidArgument, "vip gift tray animation is required")
		}
		var metadata struct {
			Format string `json:"format"`
		}
		if err := json.Unmarshal([]byte(command.MetadataJSON), &metadata); err != nil || !strings.EqualFold(strings.TrimSpace(metadata.Format), "svga") {
			return xerr.New(xerr.InvalidArgument, "vip gift tray animation format must be svga")
		}
		return nil
	case resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeRoomBorder:
		// Room Cell/appearance 只保存资源快照，不会替运营生成素材；目录层拒绝空壳视觉资源，
		// 同时固化格式，Flutter 才不需要从 URL 后缀猜 PAG、SVGA 或 MP4。
		if command.AssetURL == "" && command.AnimationURL == "" {
			return xerr.New(xerr.InvalidArgument, "vip visual resource asset is required")
		}
		if command.PreviewURL == "" {
			return xerr.New(xerr.InvalidArgument, "vip visual resource preview is required")
		}
		var metadata struct {
			Format          string `json:"format"`
			AnimationFormat string `json:"animation_format"`
		}
		if err := json.Unmarshal([]byte(command.MetadataJSON), &metadata); err != nil {
			return xerr.New(xerr.InvalidArgument, "vip visual resource metadata is invalid")
		}
		format := strings.ToLower(strings.TrimSpace(metadata.Format))
		if _, allowed := vipVisualResourceFormats[format]; !allowed {
			return xerr.New(xerr.InvalidArgument, "vip visual resource format is invalid")
		}
		if animationFormat := strings.ToLower(strings.TrimSpace(metadata.AnimationFormat)); animationFormat != "" && animationFormat != format {
			return xerr.New(xerr.InvalidArgument, "vip visual resource format mismatch")
		}
		return nil
	case resourcedomain.TypeRoomNameStyle:
		var metadata struct {
			TextColors   []string  `json:"text_colors"`
			Stops        []float64 `json:"stops"`
			AngleDegrees *float64  `json:"angle_degrees"`
		}
		if err := json.Unmarshal([]byte(command.MetadataJSON), &metadata); err != nil || len(metadata.TextColors) == 0 || len(metadata.TextColors) > 8 {
			return xerr.New(xerr.InvalidArgument, "room name style colors are invalid")
		}
		for _, color := range metadata.TextColors {
			if !roomNameResourceColorPattern.MatchString(strings.TrimSpace(color)) {
				return xerr.New(xerr.InvalidArgument, "room name style color is invalid")
			}
		}
		if len(metadata.Stops) != 0 && len(metadata.Stops) != len(metadata.TextColors) {
			return xerr.New(xerr.InvalidArgument, "room name style stops are invalid")
		}
		previous := -1.0
		for _, stop := range metadata.Stops {
			if math.IsNaN(stop) || math.IsInf(stop, 0) || stop < 0 || stop > 1 || stop < previous {
				return xerr.New(xerr.InvalidArgument, "room name style stops are invalid")
			}
			previous = stop
		}
		if metadata.AngleDegrees != nil && (math.IsNaN(*metadata.AngleDegrees) || math.IsInf(*metadata.AngleDegrees, 0)) {
			return xerr.New(xerr.InvalidArgument, "room name style angle is invalid")
		}
		return nil
	default:
		return nil
	}
}

func mapResourceWriteError(err error) error {
	if isMySQLDuplicateError(err) {
		return xerr.New(xerr.Conflict, "resource_code already exists")
	}
	return err
}

func resourceJSONPayload(scopes []string, metadata string) (string, string, error) {
	scopes = normalizeStringList(scopes)
	scopeJSON, err := json.Marshal(scopes)
	if err != nil {
		return "", "", err
	}
	return string(scopeJSON), normalizeJSONObject(metadata), nil
}

func normalizeStringList(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.ToLower(strings.TrimSpace(value))
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

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

func resourceFromSnapshotJSON(snapshotJSON string, fallback resourcedomain.Resource) resourcedomain.Resource {
	snapshotJSON = strings.TrimSpace(snapshotJSON)
	if snapshotJSON == "" || snapshotJSON == "{}" {
		return fallback
	}
	var snapshot resourcedomain.Resource
	if err := json.Unmarshal([]byte(snapshotJSON), &snapshot); err != nil || snapshot.ResourceID <= 0 {
		return fallback
	}
	if snapshot.AppCode == "" {
		snapshot.AppCode = fallback.AppCode
	}
	if snapshot.ResourceCode == "" {
		snapshot.ResourceCode = fallback.ResourceCode
	}
	if snapshot.ResourceType == "" {
		snapshot.ResourceType = fallback.ResourceType
	}
	return snapshot
}
