package policyconfig

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"strconv"
	"strings"
	"time"

	mysqlDriver "github.com/go-sql-driver/mysql"
	"hyapp-admin-server/internal/appctx"
)

const (
	policyStatusActive   = "active"
	policyStatusDisabled = "disabled"

	publishStatusDraft     = "draft"
	publishStatusPublished = "published"
	publishStatusFailed    = "failed"
)

type Service struct {
	adminDB  *sql.DB
	walletDB *sql.DB
	userDB   *sql.DB
}

type listTemplatesQuery struct {
	Keyword  string
	Status   string
	Page     int
	PageSize int
}

type listInstancesQuery struct {
	Keyword  string
	Status   string
	Page     int
	PageSize int
}

type policyInstanceRow struct {
	InstanceID      uint64
	AppCode         string
	InstanceCode    string
	TemplateCode    string
	TemplateVersion string
	RegionScope     string
	Status          string
	EffectiveFromMS int64
	EffectiveToMS   int64
}

type compiledPolicy struct {
	PointsPerUSD   int64
	MinimumPoints  int64
	WithdrawFeeBPS int64
	RuleJSON       json.RawMessage
}

func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
	return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
}

func (s *Service) ListTemplates(ctx context.Context, query listTemplatesQuery) ([]templateDTO, int64, error) {
	if s == nil || s.adminDB == nil {
		return nil, 0, errors.New("admin database is not configured")
	}
	where, args := templateWhere(query)
	var total int64
	if err := s.adminDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM admin_policy_templates t JOIN admin_policy_template_versions v ON v.template_code = t.template_code AND v.template_version = t.template_version `+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	page, pageSize := normalizePage(query.Page, query.PageSize)
	args = append(args, pageSize, (page-1)*pageSize)
	rows, err := s.adminDB.QueryContext(ctx, `
		SELECT t.template_id, v.version_id, t.template_code, v.template_version, t.name, v.status,
		       COALESCE(CAST(v.rule_json AS CHAR), '{}'), t.description, v.created_at_ms, v.updated_at_ms
		FROM admin_policy_templates t
		JOIN admin_policy_template_versions v ON v.template_code = t.template_code AND v.template_version = t.template_version `+where+`
		ORDER BY t.updated_at_ms DESC, v.updated_at_ms DESC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]templateDTO, 0)
	for rows.Next() {
		item, err := scanTemplate(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

func (s *Service) CreateTemplate(ctx context.Context, actorID uint, req templateRequest) (templateDTO, error) {
	if s == nil || s.adminDB == nil {
		return templateDTO{}, errors.New("admin database is not configured")
	}
	templateCode := normalizeCode(req.TemplateCode)
	version := normalizeVersion(req.TemplateVersion)
	name := strings.TrimSpace(req.Name)
	status := normalizeStatus(req.Status)
	if templateCode == "" || version == "" || name == "" {
		return templateDTO{}, errors.New("template_code, template_version and name are required")
	}
	ruleJSON, err := normalizeRuleJSON(req.RuleJSON)
	if err != nil {
		return templateDTO{}, err
	}
	nowMS := time.Now().UTC().UnixMilli()
	tx, err := s.adminDB.BeginTx(ctx, nil)
	if err != nil {
		return templateDTO{}, err
	}
	defer func() { _ = tx.Rollback() }()
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO admin_policy_templates (
		  template_code, template_version, name, status, rule_json, description,
		  created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		  name = VALUES(name), status = VALUES(status), rule_json = VALUES(rule_json),
		  description = VALUES(description), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`,
		templateCode, version, name, status, string(ruleJSON), strings.TrimSpace(req.Description), actorID, actorID, nowMS, nowMS,
	); err != nil {
		return templateDTO{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO admin_policy_template_versions (
		  template_code, template_version, status, rule_json,
		  created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		  status = VALUES(status), rule_json = VALUES(rule_json),
		  updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`,
		templateCode, version, status, string(ruleJSON), actorID, actorID, nowMS, nowMS,
	); err != nil {
		return templateDTO{}, err
	}
	if err := tx.Commit(); err != nil {
		return templateDTO{}, err
	}
	return s.getTemplate(ctx, templateCode, version)
}

func (s *Service) ListInstances(ctx context.Context, appCode string, query listInstancesQuery) ([]instanceDTO, int64, error) {
	if s == nil || s.adminDB == nil {
		return nil, 0, errors.New("admin database is not configured")
	}
	appCode = appctx.Normalize(appCode)
	where, args := instanceWhere(appCode, query)
	var total int64
	if err := s.adminDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM admin_policy_instances `+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	page, pageSize := normalizePage(query.Page, query.PageSize)
	args = append(args, pageSize, (page-1)*pageSize)
	rows, err := s.adminDB.QueryContext(ctx, `
		SELECT instance_id, app_code, instance_code, template_code, template_version, region_scope, status,
		       effective_from_ms, effective_to_ms, publish_status, publish_error, last_published_at_ms,
		       created_at_ms, updated_at_ms
		FROM admin_policy_instances `+where+`
		ORDER BY updated_at_ms DESC, instance_id DESC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]instanceDTO, 0)
	for rows.Next() {
		item, err := scanInstanceDTO(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

func (s *Service) CreateInstance(ctx context.Context, appCode string, actorID uint, req instanceRequest) (instanceDTO, error) {
	if s == nil || s.adminDB == nil {
		return instanceDTO{}, errors.New("admin database is not configured")
	}
	appCode = appctx.Normalize(appCode)
	instanceCode := normalizeCode(req.InstanceCode)
	templateCode := normalizeCode(req.TemplateCode)
	version := normalizeVersion(req.TemplateVersion)
	if instanceCode == "" || templateCode == "" || version == "" {
		return instanceDTO{}, errors.New("instance_code, template_code and template_version are required")
	}
	if _, err := s.getTemplate(ctx, templateCode, version); err != nil {
		return instanceDTO{}, err
	}
	status := normalizeStatus(req.Status)
	regionScope := normalizeRegionScope(req.RegionScope)
	if err := validateEffectiveRange(req.EffectiveFromMS, req.EffectiveToMS); err != nil {
		return instanceDTO{}, err
	}
	nowMS := time.Now().UTC().UnixMilli()
	effectiveFromMS := req.EffectiveFromMS
	if effectiveFromMS <= 0 {
		effectiveFromMS = nowMS
	}
	result, err := s.adminDB.ExecContext(ctx, `
		INSERT INTO admin_policy_instances (
		  app_code, instance_code, template_code, template_version, region_scope, status,
		  effective_from_ms, effective_to_ms, publish_status, created_by_admin_id, updated_by_admin_id,
		  created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appCode, instanceCode, templateCode, version, regionScope, status,
		effectiveFromMS, req.EffectiveToMS, publishStatusDraft, actorID, actorID, nowMS, nowMS,
	)
	if err != nil {
		return instanceDTO{}, err
	}
	id, err := result.LastInsertId()
	if err != nil {
		return instanceDTO{}, err
	}
	return s.getInstanceDTO(ctx, appCode, uint64(id))
}

func (s *Service) UpdateInstanceStatus(ctx context.Context, appCode string, actorID uint, instanceID uint64, status string) (instanceDTO, error) {
	if s == nil || s.adminDB == nil {
		return instanceDTO{}, errors.New("admin database is not configured")
	}
	appCode = appctx.Normalize(appCode)
	status = normalizeStatus(status)
	nowMS := time.Now().UTC().UnixMilli()
	instance, err := s.getInstanceRow(ctx, appCode, instanceID)
	if err != nil {
		return instanceDTO{}, err
	}
	result, err := s.adminDB.ExecContext(ctx, `
		UPDATE admin_policy_instances
		SET status = ?, updated_by_admin_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND instance_id = ?`,
		status, actorID, nowMS, appCode, instanceID,
	)
	if err != nil {
		return instanceDTO{}, err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return instanceDTO{}, err
	}
	if affected == 0 {
		return instanceDTO{}, errors.New("policy instance not found")
	}
	instance.Status = status
	if err := s.syncWalletRuntimeStatus(ctx, instance, status, nowMS); err != nil {
		return instanceDTO{}, err
	}
	return s.getInstanceDTO(ctx, appCode, instanceID)
}

func (s *Service) PublishInstance(ctx context.Context, appCode string, actorID uint, instanceID uint64) (publishDTO, error) {
	if s == nil || s.adminDB == nil || s.walletDB == nil || s.userDB == nil {
		return publishDTO{}, errors.New("policy publish database is not configured")
	}
	appCode = appctx.Normalize(appCode)
	instance, err := s.getInstanceRow(ctx, appCode, instanceID)
	if err != nil {
		return publishDTO{}, err
	}
	if instance.Status != policyStatusActive {
		return publishDTO{}, errors.New("only active policy instance can be published")
	}
	ruleJSON, err := s.getTemplateRuleJSON(ctx, instance.TemplateCode, instance.TemplateVersion)
	if err != nil {
		return publishDTO{}, err
	}
	compiled, err := compilePolicy(ruleJSON)
	if err != nil {
		return publishDTO{}, err
	}
	regionIDs, err := s.resolveRegionScope(ctx, instance.AppCode, instance.RegionScope)
	if err != nil {
		return publishDTO{}, err
	}
	nowMS := time.Now().UTC().UnixMilli()
	// 每日任务资产由 task definition 显式保存；通用政策只发布 wallet 目标，不能恢复 App 级任务默认值。
	targetCount := len(regionIDs)
	jobID, err := s.createPublishJob(ctx, instance, actorID, targetCount, nowMS)
	if err != nil {
		return publishDTO{}, err
	}
	if err := s.publishWalletRuntime(ctx, instance, compiled, regionIDs, actorID, nowMS); err != nil {
		_ = s.finishPublishJob(ctx, jobID, publishStatusFailed, targetCount, 0, targetCount, err.Error(), nowMS)
		_ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS)
		return publishDTO{}, err
	}
	if err := s.insertPublishItems(ctx, jobID, instance.AppCode, regionIDs, nowMS); err != nil {
		_ = s.finishPublishJob(ctx, jobID, publishStatusFailed, targetCount, targetCount, 0, err.Error(), nowMS)
		_ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS)
		return publishDTO{}, err
	}
	if err := s.finishPublishJob(ctx, jobID, "succeeded", targetCount, targetCount, 0, "", nowMS); err != nil {
		return publishDTO{}, err
	}
	if _, err := s.adminDB.ExecContext(ctx, `
		UPDATE admin_policy_instances
		SET publish_status = ?, publish_error = '', last_published_at_ms = ?, updated_by_admin_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND instance_id = ?`,
		publishStatusPublished, nowMS, actorID, nowMS, instance.AppCode, instance.InstanceID,
	); err != nil {
		return publishDTO{}, err
	}
	return publishDTO{
		JobID:             jobID,
		InstanceID:        instance.InstanceID,
		AppCode:           instance.AppCode,
		InstanceCode:      instance.InstanceCode,
		TemplateCode:      instance.TemplateCode,
		TemplateVersion:   instance.TemplateVersion,
		Status:            publishStatusPublished,
		TargetCount:       targetCount,
		PublishedRegionID: regionIDs,
		PublishedAtMS:     nowMS,
	}, nil
}

func (s *Service) getTemplate(ctx context.Context, templateCode string, version string) (templateDTO, error) {
	row := s.adminDB.QueryRowContext(ctx, `
		SELECT t.template_id, v.version_id, t.template_code, v.template_version, t.name, v.status,
		       COALESCE(CAST(v.rule_json AS CHAR), '{}'), t.description, v.created_at_ms, v.updated_at_ms
		FROM admin_policy_templates t
		JOIN admin_policy_template_versions v ON v.template_code = t.template_code AND v.template_version = t.template_version
		WHERE t.template_code = ? AND v.template_version = ?`,
		templateCode, version,
	)
	item, err := scanTemplate(row)
	if errors.Is(err, sql.ErrNoRows) {
		return templateDTO{}, errors.New("POINT wallet policy version not found")
	}
	return item, err
}

func (s *Service) getTemplateRuleJSON(ctx context.Context, templateCode string, version string) (json.RawMessage, error) {
	var raw string
	if err := s.adminDB.QueryRowContext(ctx, `
		SELECT COALESCE(CAST(rule_json AS CHAR), '{}')
		FROM admin_policy_template_versions
		WHERE template_code = ? AND template_version = ? AND status = ?`,
		templateCode, version, policyStatusActive,
	).Scan(&raw); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, errors.New("active POINT wallet policy version not found")
		}
		return nil, err
	}
	return normalizeRuleJSON(json.RawMessage(raw))
}

func (s *Service) getInstanceDTO(ctx context.Context, appCode string, instanceID uint64) (instanceDTO, error) {
	row := s.adminDB.QueryRowContext(ctx, `
		SELECT instance_id, app_code, instance_code, template_code, template_version, region_scope, status,
		       effective_from_ms, effective_to_ms, publish_status, publish_error, last_published_at_ms,
		       created_at_ms, updated_at_ms
		FROM admin_policy_instances
		WHERE app_code = ? AND instance_id = ?`,
		appCode, instanceID,
	)
	item, err := scanInstanceDTO(row)
	if errors.Is(err, sql.ErrNoRows) {
		return instanceDTO{}, errors.New("policy instance not found")
	}
	return item, err
}

func (s *Service) getInstanceRow(ctx context.Context, appCode string, instanceID uint64) (policyInstanceRow, error) {
	row := s.adminDB.QueryRowContext(ctx, `
		SELECT instance_id, app_code, instance_code, template_code, template_version, region_scope, status,
		       effective_from_ms, effective_to_ms
		FROM admin_policy_instances
		WHERE app_code = ? AND instance_id = ?`,
		appCode, instanceID,
	)
	var item policyInstanceRow
	err := row.Scan(&item.InstanceID, &item.AppCode, &item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionScope, &item.Status, &item.EffectiveFromMS, &item.EffectiveToMS)
	if errors.Is(err, sql.ErrNoRows) {
		return policyInstanceRow{}, errors.New("policy instance not found")
	}
	return item, err
}

func (s *Service) resolveRegionScope(ctx context.Context, appCode string, scope string) ([]int64, error) {
	scope = normalizeRegionScope(scope)
	if strings.HasPrefix(scope, "region_id:") {
		value := strings.TrimPrefix(scope, "region_id:")
		regionID, err := strconv.ParseInt(value, 10, 64)
		if err != nil || regionID <= 0 {
			return nil, errors.New("region_scope is invalid")
		}
		return []int64{regionID}, nil
	}
	rows, err := s.userDB.QueryContext(ctx, `
		SELECT region_id
		FROM regions
		WHERE app_code = ? AND status = 'active'
		ORDER BY sort_order DESC, region_id ASC`, appCode)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	regionIDs := []int64{0}
	for rows.Next() {
		var regionID int64
		if err := rows.Scan(&regionID); err != nil {
			return nil, err
		}
		if regionID > 0 {
			regionIDs = append(regionIDs, regionID)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return regionIDs, nil
}

func (s *Service) createPublishJob(ctx context.Context, item policyInstanceRow, actorID uint, targetCount int, nowMS int64) (uint64, error) {
	result, err := s.adminDB.ExecContext(ctx, `
		INSERT INTO admin_policy_publish_jobs (
		  instance_id, app_code, instance_code, template_code, template_version, status,
		  target_count, operator_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`,
		item.InstanceID, item.AppCode, item.InstanceCode, item.TemplateCode, item.TemplateVersion, targetCount, actorID, nowMS, nowMS,
	)
	if err != nil {
		return 0, err
	}
	id, err := result.LastInsertId()
	return uint64(id), err
}

func (s *Service) publishWalletRuntime(ctx context.Context, item policyInstanceRow, compiled compiledPolicy, regionIDs []int64, actorID uint, nowMS int64) error {
	if err := ensureWalletPolicyRuntimeTable(ctx, s.walletDB); err != nil {
		return err
	}
	tx, err := s.walletDB.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	if _, err := tx.ExecContext(ctx, `DELETE FROM wallet_policy_instances WHERE app_code = ? AND instance_code = ?`, item.AppCode, item.InstanceCode); err != nil {
		return err
	}
	for _, regionID := range regionIDs {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO wallet_policy_instances (
			  app_code, instance_code, template_code, template_version, region_id, status,
			  effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm,
			  points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id,
			  published_at_ms, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'POINT', ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?)`,
			item.AppCode, item.InstanceCode, item.TemplateCode, item.TemplateVersion, regionID, item.Status,
			item.EffectiveFromMS, item.EffectiveToMS, int64(0), int64(0),
			compiled.PointsPerUSD, compiled.WithdrawFeeBPS, string(compiled.RuleJSON), actorID, nowMS, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *Service) syncWalletRuntimeStatus(ctx context.Context, item policyInstanceRow, status string, nowMS int64) error {
	if s.walletDB == nil {
		return nil
	}
	if err := ensureWalletPolicyRuntimeTable(ctx, s.walletDB); err != nil {
		return err
	}
	// 运行表是 owner service 的最终读取面；后台停用实例必须同步停用旧快照，不能只改 admin 状态。
	_, err := s.walletDB.ExecContext(ctx, `
		UPDATE wallet_policy_instances
		SET status = ?, updated_at_ms = ?
		WHERE app_code = ? AND instance_code = ?`,
		status, nowMS, item.AppCode, item.InstanceCode,
	)
	return err
}

func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
	_, err := db.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS wallet_policy_instances (
		  app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
		  instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码',
		  template_code VARCHAR(96) NOT NULL COMMENT '模板编码快照',
		  template_version VARCHAR(64) NOT NULL COMMENT '模板版本快照',
		  region_id BIGINT NOT NULL DEFAULT 0 COMMENT '适用地区，0 表示 App 兜底',
		  status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
		  effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'UTC epoch ms',
		  effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
		  host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '兼容既有表结构；新礼物不读取',
		  host_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构；新礼物不读取',
		  agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构；新礼物不读取',
		  points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
		  withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
		  rule_json JSON NOT NULL COMMENT '完整政策快照',
		  published_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
		  published_at_ms BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY (app_code, instance_code, region_id),
		  KEY idx_wallet_policy_active (app_code, region_id, status, effective_from_ms, effective_to_ms)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧政策实例表'`)
	if err != nil {
		return err
	}
	if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构；新礼物不读取' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
		return err
	}
	return nil
}

func isDuplicateColumnError(err error) bool {
	var mysqlErr *mysqlDriver.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
}

func (s *Service) insertPublishItems(ctx context.Context, jobID uint64, appCode string, regionIDs []int64, nowMS int64) error {
	tx, err := s.adminDB.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	for _, regionID := range regionIDs {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO admin_policy_publish_items (
			  job_id, owner_service, app_code, region_id, status, created_at_ms, updated_at_ms
			) VALUES (?, 'wallet', ?, ?, 'succeeded', ?, ?)`,
			jobID, appCode, regionID, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *Service) finishPublishJob(ctx context.Context, jobID uint64, status string, targetCount int, successCount int, failureCount int, errMsg string, nowMS int64) error {
	_, err := s.adminDB.ExecContext(ctx, `
		UPDATE admin_policy_publish_jobs
		SET status = ?, target_count = ?, success_count = ?, failure_count = ?, error_message = ?, updated_at_ms = ?
		WHERE job_id = ?`,
		status, targetCount, successCount, failureCount, truncate(errMsg, 512), nowMS, jobID,
	)
	return err
}

func (s *Service) markInstancePublishFailed(ctx context.Context, appCode string, instanceID uint64, errMsg string, nowMS int64) error {
	_, err := s.adminDB.ExecContext(ctx, `
		UPDATE admin_policy_instances
		SET publish_status = ?, publish_error = ?, updated_at_ms = ?
		WHERE app_code = ? AND instance_id = ?`,
		publishStatusFailed, truncate(errMsg, 512), nowMS, appCode, instanceID,
	)
	return err
}

func compilePolicy(raw json.RawMessage) (compiledPolicy, error) {
	normalized, err := normalizeRuleJSON(raw)
	if err != nil {
		return compiledPolicy{}, errors.New("rule_json is invalid")
	}
	var decoded map[string]any
	if err := json.Unmarshal(normalized, &decoded); err != nil {
		return compiledPolicy{}, errors.New("rule_json is invalid")
	}
	pointsPerUSD, err := requiredInt64FromJSON(decoded, "points_per_usd")
	if err != nil || pointsPerUSD <= 0 {
		return compiledPolicy{}, errors.New("rule_json points_per_usd must be a positive integer")
	}
	host, _ := decoded["host"].(map[string]any)
	minimumPoints, err := requiredInt64FromJSON(host, "minimum_withdraw_points")
	if err != nil || minimumPoints <= 0 {
		return compiledPolicy{}, errors.New("rule_json host minimum_withdraw_points must be a positive integer")
	}
	withdrawFeeBPS, err := requiredInt64FromJSON(host, "withdraw_fee_bps")
	if err != nil || withdrawFeeBPS < 0 || withdrawFeeBPS > 10_000 {
		return compiledPolicy{}, errors.New("rule_json host withdraw_fee_bps must be an integer between 0 and 10000")
	}
	return compiledPolicy{
		PointsPerUSD:   pointsPerUSD,
		MinimumPoints:  minimumPoints,
		WithdrawFeeBPS: withdrawFeeBPS,
		RuleJSON:       normalized,
	}, nil
}

func scanTemplate(row interface{ Scan(dest ...any) error }) (templateDTO, error) {
	var item templateDTO
	var rule string
	if err := row.Scan(&item.TemplateID, &item.VersionID, &item.TemplateCode, &item.TemplateVersion, &item.Name, &item.Status, &rule, &item.Description, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
		return templateDTO{}, err
	}
	normalized, err := normalizeRuleJSON(json.RawMessage(rule))
	if err != nil {
		return templateDTO{}, err
	}
	item.RuleJSON = normalized
	return item, nil
}

func scanInstanceDTO(row interface{ Scan(dest ...any) error }) (instanceDTO, error) {
	var item instanceDTO
	err := row.Scan(&item.InstanceID, &item.AppCode, &item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionScope, &item.Status, &item.EffectiveFromMS, &item.EffectiveToMS, &item.PublishStatus, &item.PublishError, &item.LastPublishedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS)
	return item, err
}

func templateWhere(query listTemplatesQuery) (string, []any) {
	conditions := []string{"1 = 1"}
	args := make([]any, 0)
	if query.Status != "" {
		conditions = append(conditions, "v.status = ?")
		args = append(args, normalizeStatus(query.Status))
	}
	if query.Keyword != "" {
		conditions = append(conditions, "(t.template_code LIKE ? OR t.name LIKE ?)")
		like := "%" + query.Keyword + "%"
		args = append(args, like, like)
	}
	return "WHERE " + strings.Join(conditions, " AND "), args
}

func instanceWhere(appCode string, query listInstancesQuery) (string, []any) {
	conditions := []string{"app_code = ?"}
	args := []any{appCode}
	if query.Status != "" {
		conditions = append(conditions, "status = ?")
		args = append(args, normalizeStatus(query.Status))
	}
	if query.Keyword != "" {
		conditions = append(conditions, "(instance_code LIKE ? OR template_code LIKE ?)")
		like := "%" + query.Keyword + "%"
		args = append(args, like, like)
	}
	return "WHERE " + strings.Join(conditions, " AND "), args
}

func normalizePage(page int, pageSize int) (int, int) {
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 {
		pageSize = 10
	}
	if pageSize > 100 {
		pageSize = 100
	}
	return page, pageSize
}

func normalizeCode(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	value = strings.ReplaceAll(value, " ", "_")
	return value
}

func normalizeVersion(value string) string {
	return strings.TrimSpace(value)
}

func normalizeStatus(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", "enabled", policyStatusActive:
		return policyStatusActive
	case "inactive", policyStatusDisabled:
		return policyStatusDisabled
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func normalizeRegionScope(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return "all_active_regions"
	}
	return value
}

func validateEffectiveRange(fromMS int64, toMS int64) error {
	if fromMS < 0 || toMS < 0 {
		return errors.New("effective time is invalid")
	}
	if toMS > 0 && fromMS > 0 && toMS <= fromMS {
		return errors.New("effective_to_ms must be greater than effective_from_ms")
	}
	return nil
}

func normalizeRuleJSON(raw json.RawMessage) (json.RawMessage, error) {
	if len(raw) == 0 {
		return nil, errors.New("rule_json is required")
	}
	var decoded map[string]any
	if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil {
		return nil, errors.New("rule_json must be a JSON object")
	}
	// wallet owner 当前只消费 POINT/USD、最低提现 POINT 和提现手续费。保存、列表回显和发布都使用
	// 同一白名单，确保旧模板或任意扩展 JSON 不能重新制造“后台保存成功但业务不生效”的幽灵配置。
	normalizedRule := make(map[string]any, 2)
	if pointsPerUSD, exists := decoded["points_per_usd"]; exists {
		normalizedRule["points_per_usd"] = pointsPerUSD
	}
	if sourceHost, ok := decoded["host"].(map[string]any); ok {
		host := make(map[string]any, 2)
		if minimumPoints, exists := sourceHost["minimum_withdraw_points"]; exists {
			host["minimum_withdraw_points"] = minimumPoints
		}
		if withdrawFeeBPS, exists := sourceHost["withdraw_fee_bps"]; exists {
			host["withdraw_fee_bps"] = withdrawFeeBPS
		}
		if len(host) > 0 {
			normalizedRule["host"] = host
		}
	}
	normalized, err := json.Marshal(normalizedRule)
	if err != nil {
		return nil, err
	}
	return json.RawMessage(normalized), nil
}

func requiredInt64FromJSON(object map[string]any, key string) (int64, error) {
	value, exists := object[key]
	if !exists {
		return 0, errors.New("required JSON value is missing")
	}
	switch typed := value.(type) {
	case float64:
		parsed := int64(typed)
		if typed != float64(parsed) {
			return 0, errors.New("JSON value is not an integer")
		}
		return parsed, nil
	case json.Number:
		return typed.Int64()
	case string:
		return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
	default:
		return 0, errors.New("JSON value is not an integer")
	}
}

func truncate(value string, limit int) string {
	value = strings.TrimSpace(value)
	if len(value) <= limit {
		return value
	}
	return value[:limit]
}
