package walletnotice

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

	"hyapp/pkg/appcode"
	"hyapp/pkg/walletmq"
	"hyapp/pkg/xerr"
)

const (
	sourceWalletOutbox       = "wallet_outbox"
	channelTencentIMC2C      = "tencent_im_c2c"
	eventWalletBalance       = walletmq.EventTypeWalletBalanceChanged
	eventVIPActivated        = walletmq.EventTypeVIPActivated
	robotCoinAssetType       = "ROBOT_COIN"
	deliveryStatusDelivering = "delivering"
	deliveryStatusDelivered  = "delivered"
	deliveryStatusRetryable  = "retryable"
	deliveryStatusFailed     = "failed"
)

// MySQLRepository owns wallet notice delivery state and reads wallet_outbox as an append-only source.
type MySQLRepository struct {
	db                *sql.DB
	walletOutboxTable string
}

// WalletBalanceEvent 是 notice worker 从 wallet_outbox 认领的一条私有余额通知事实。
type WalletBalanceEvent struct {
	AppCode        string
	EventID        string
	EventType      string
	TransactionID  string
	CommandID      string
	UserID         int64
	AssetType      string
	AvailableDelta int64
	FrozenDelta    int64
	PayloadJSON    string
	RetryCount     int
	CreatedAtMS    int64
}

// NewMySQLRepository 创建钱包余额通知模块仓储。
// notice-service 自己的连接池由 platform/mysql 持有；模块只使用同一 DB 事务管理投递位点。
func NewMySQLRepository(db *sql.DB, walletDatabase string) (*MySQLRepository, error) {
	if db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql db is not configured")
	}
	walletDatabase = normalizeDatabaseName(walletDatabase)
	if walletDatabase == "" {
		return nil, xerr.New(xerr.InvalidArgument, "wallet_database is invalid")
	}
	return &MySQLRepository{
		db:                db,
		walletOutboxTable: fmt.Sprintf("`%s`.`wallet_outbox`", walletDatabase),
	}, nil
}

// ClaimWalletBalanceEvents 抢占 wallet_outbox 中待投递的余额变更事件。
// wallet_outbox 自身不被 notice-service 修改；多实例互斥和重试状态保存在 notice_delivery_events。
func (r *MySQLRepository) ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "notice repository is not configured")
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return nil, xerr.New(xerr.InvalidArgument, "worker_id is required")
	}
	if limit <= 0 {
		limit = 100
	}
	if limit > 500 {
		limit = 500
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}

	candidates, err := r.listWalletBalanceCandidates(ctx, limit, time.Now().UnixMilli())
	if err != nil {
		return nil, err
	}
	claimed := make([]WalletBalanceEvent, 0, len(candidates))
	for _, candidate := range candidates {
		event, ok, err := r.claimWalletBalanceCandidate(ctx, candidate, workerID, lockTTL)
		if err != nil {
			return claimed, err
		}
		if ok {
			claimed = append(claimed, event)
		}
	}
	return claimed, nil
}

type walletBalanceCandidate struct {
	AppCode   string
	EventID   string
	EventType string
}

func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) {
	eventTypes := walletPrivateNoticeEventTypes()
	appCode := appcode.FromContext(ctx)
	candidates := make([]walletBalanceCandidate, 0)
	branches := []struct {
		query string
		args  []any
	}{
		{
			query: fmt.Sprintf(`
		SELECT wo.app_code, wo.event_id, wo.event_type
		FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
		LEFT JOIN notice_delivery_events nde
		  ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ?
		WHERE wo.app_code = ? AND wo.event_type IN (%s)
		  AND wo.asset_type <> ?
		  AND nde.source_event_id IS NULL
		ORDER BY wo.created_at_ms ASC, wo.event_id ASC
		LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
			args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), robotCoinAssetType),
		},
		{
			query: fmt.Sprintf(`
		SELECT wo.app_code, wo.event_id, wo.event_type
		FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
		JOIN notice_delivery_events nde
		  ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ?
		WHERE wo.app_code = ? AND wo.event_type IN (%s)
		  AND wo.asset_type <> ?
		  AND nde.status = ? AND nde.next_retry_at_ms <= ?
		ORDER BY wo.created_at_ms ASC, wo.event_id ASC
		LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
			args: append(append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), robotCoinAssetType), deliveryStatusRetryable, nowMs),
		},
		{
			query: fmt.Sprintf(`
		SELECT wo.app_code, wo.event_id, wo.event_type
		FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
		JOIN notice_delivery_events nde
		  ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ?
		WHERE wo.app_code = ? AND wo.event_type IN (%s)
		  AND wo.asset_type <> ?
		  AND nde.status = ? AND nde.lock_until_ms <= ?
		ORDER BY wo.created_at_ms ASC, wo.event_id ASC
		LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
			args: append(append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), robotCoinAssetType), deliveryStatusDelivering, nowMs),
		},
	}
	for _, branch := range branches {
		remaining := limit - len(candidates)
		if remaining <= 0 {
			break
		}
		args := append(append([]any{}, branch.args...), remaining)
		branchCandidates, err := queryWalletBalanceCandidates(ctx, r.db, branch.query, args...)
		if err != nil {
			return nil, err
		}
		candidates = append(candidates, branchCandidates...)
	}
	return candidates, nil
}

func queryWalletBalanceCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]walletBalanceCandidate, error) {
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	candidates := make([]walletBalanceCandidate, 0)
	for rows.Next() {
		var candidate walletBalanceCandidate
		if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil {
			return nil, err
		}
		candidates = append(candidates, candidate)
	}
	return candidates, rows.Err()
}

func (r *MySQLRepository) claimWalletBalanceCandidate(ctx context.Context, candidate walletBalanceCandidate, workerID string, lockTTL time.Duration) (WalletBalanceEvent, bool, error) {
	nowMs := time.Now().UnixMilli()
	lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
	ctx = appcode.WithContext(ctx, candidate.AppCode)

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return WalletBalanceEvent{}, false, err
	}
	defer func() { _ = tx.Rollback() }()

	event, err := r.lockWalletBalanceEvent(ctx, tx, candidate)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// 候选扫描到事务锁定之间可能被清理；这种竞争不应让整批失败。
			return WalletBalanceEvent{}, false, nil
		}
		return WalletBalanceEvent{}, false, err
	}
	retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
	if err != nil || !claimed {
		return WalletBalanceEvent{}, false, err
	}
	event.RetryCount = retryCount
	if err := tx.Commit(); err != nil {
		return WalletBalanceEvent{}, false, err
	}
	return event, true, nil
}

// ClaimWalletBalanceMessage records a wallet_outbox MQ message in notice_delivery_events.
func (r *MySQLRepository) ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, error) {
	if r == nil || r.db == nil {
		return WalletBalanceEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return WalletBalanceEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}
	event, err := walletBalanceEventFromMessage(message)
	if err != nil {
		return WalletBalanceEvent{}, false, err
	}
	now := time.Now()
	lockUntilMS := now.Add(lockTTL).UnixMilli()
	nowMs := now.UnixMilli()
	ctx = appcode.WithContext(ctx, event.AppCode)

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return WalletBalanceEvent{}, false, err
	}
	defer func() { _ = tx.Rollback() }()

	retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
	if err != nil || !claimed {
		return WalletBalanceEvent{}, false, err
	}
	event.RetryCount = retryCount
	if err := tx.Commit(); err != nil {
		return WalletBalanceEvent{}, false, err
	}
	return event, true, nil
}

// LoadWalletOutboxMessage loads one immutable owner event for an operator-selected
// delivery replay. The exact (app_code, event_id) key is mandatory so an offline
// repair cannot accidentally turn into an unbounded wallet_outbox scan.
func (r *MySQLRepository) LoadWalletOutboxMessage(ctx context.Context, appCode string, eventID string) (walletmq.WalletOutboxMessage, error) {
	if r == nil || r.db == nil {
		return walletmq.WalletOutboxMessage{}, xerr.New(xerr.Unavailable, "notice repository is not configured")
	}
	appCode = strings.TrimSpace(appCode)
	eventID = strings.TrimSpace(eventID)
	if appCode == "" || eventID == "" {
		return walletmq.WalletOutboxMessage{}, xerr.New(xerr.InvalidArgument, "app_code and event_id are required")
	}
	appCode = appcode.Normalize(appCode)

	message := walletmq.WalletOutboxMessage{MessageType: walletmq.MessageTypeWalletOutboxEvent}
	query := fmt.Sprintf(`
		SELECT app_code, event_id, event_type, transaction_id, command_id, user_id,
		       asset_type, available_delta, frozen_delta,
		       COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
		FROM %s
		WHERE app_code = ? AND event_id = ?
		  AND event_type IN (?, ?) AND asset_type <> ?`, r.walletOutboxTable)
	err := r.db.QueryRowContext(ctx, query, appCode, eventID, eventWalletBalance, eventVIPActivated, robotCoinAssetType).Scan(
		&message.AppCode,
		&message.EventID,
		&message.EventType,
		&message.TransactionID,
		&message.CommandID,
		&message.UserID,
		&message.AssetType,
		&message.AvailableDelta,
		&message.FrozenDelta,
		&message.PayloadJSON,
		&message.OccurredAtMS,
	)
	if err != nil {
		return walletmq.WalletOutboxMessage{}, err
	}
	// Reuse the live MQ decoder boundary before any external side effect. A
	// malformed historical row must stay retryable instead of being sent with a
	// subtly different repair-only payload.
	encoded, err := walletmq.EncodeWalletOutboxMessage(message)
	if err != nil {
		return walletmq.WalletOutboxMessage{}, err
	}
	decoded, err := walletmq.DecodeWalletOutboxMessage(encoded)
	if err != nil {
		return walletmq.WalletOutboxMessage{}, err
	}
	if decoded.AppCode != appCode || decoded.EventID != eventID {
		return walletmq.WalletOutboxMessage{}, fmt.Errorf("wallet outbox message identity does not match requested key")
	}
	return decoded, nil
}

func walletBalanceEventFromMessage(message walletmq.WalletOutboxMessage) (WalletBalanceEvent, error) {
	event := WalletBalanceEvent{
		AppCode:        appcode.Normalize(message.AppCode),
		EventID:        message.EventID,
		EventType:      message.EventType,
		TransactionID:  message.TransactionID,
		CommandID:      message.CommandID,
		UserID:         message.UserID,
		AssetType:      message.AssetType,
		AvailableDelta: message.AvailableDelta,
		FrozenDelta:    message.FrozenDelta,
		PayloadJSON:    message.PayloadJSON,
		CreatedAtMS:    message.OccurredAtMS,
	}
	if event.PayloadJSON == "" {
		event.PayloadJSON = "{}"
	}
	if event.AppCode == "" || event.EventID == "" || event.TransactionID == "" || event.CommandID == "" {
		return WalletBalanceEvent{}, fmt.Errorf("wallet outbox message is incomplete")
	}
	if !isWalletPrivateNoticeEvent(event.EventType) {
		return WalletBalanceEvent{}, fmt.Errorf("unexpected wallet event type %q", event.EventType)
	}
	if event.UserID <= 0 {
		return WalletBalanceEvent{}, fmt.Errorf("wallet notice user_id is invalid")
	}
	return event, nil
}

func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) {
	var event WalletBalanceEvent
	query := fmt.Sprintf(`
		SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
		       available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
		FROM %s
		WHERE app_code = ? AND event_id = ? AND event_type = ?
		FOR UPDATE`, r.walletOutboxTable)
	err := tx.QueryRowContext(ctx, query,
		appcode.Normalize(candidate.AppCode),
		candidate.EventID,
		candidate.EventType,
	).Scan(
		&event.AppCode,
		&event.EventID,
		&event.EventType,
		&event.TransactionID,
		&event.CommandID,
		&event.UserID,
		&event.AssetType,
		&event.AvailableDelta,
		&event.FrozenDelta,
		&event.PayloadJSON,
		&event.CreatedAtMS,
	)
	return event, err
}

func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, event WalletBalanceEvent, workerID string, lockUntilMS int64, nowMs int64) (int, bool, error) {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO notice_delivery_events (
		  source_name, app_code, source_event_id, channel, notice_type, target_user_id,
		  status, retry_count, locked_by, lock_until_ms, next_retry_at_ms,
		  payload_json, last_error, delivered_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, ?, '', 0, ?, ?)`,
		sourceWalletOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
		event.EventType,
		event.UserID,
		deliveryStatusDelivering,
		workerID,
		lockUntilMS,
		event.PayloadJSON,
		nowMs,
		nowMs,
	)
	if err == nil {
		return 0, true, nil
	}
	if !isDuplicateKey(err) {
		return 0, false, err
	}

	result, err := tx.ExecContext(ctx, `
		UPDATE notice_delivery_events
		SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
		WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
		  AND status = ? AND next_retry_at_ms <= ?`,
		deliveryStatusDelivering,
		workerID,
		lockUntilMS,
		event.PayloadJSON,
		nowMs,
		sourceWalletOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
		deliveryStatusRetryable,
		nowMs,
	)
	if err != nil {
		return 0, false, err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return 0, false, err
	}
	if affected == 0 {
		result, err = tx.ExecContext(ctx, `
			UPDATE notice_delivery_events
			SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
			WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
			  AND status = ? AND lock_until_ms <= ?`,
			deliveryStatusDelivering,
			workerID,
			lockUntilMS,
			event.PayloadJSON,
			nowMs,
			sourceWalletOutbox,
			event.AppCode,
			event.EventID,
			channelTencentIMC2C,
			deliveryStatusDelivering,
			nowMs,
		)
		if err != nil {
			return 0, false, err
		}
		affected, err = result.RowsAffected()
		if err != nil || affected == 0 {
			return 0, false, err
		}
	}

	var retryCount int
	err = tx.QueryRowContext(ctx, `
		SELECT retry_count
		FROM notice_delivery_events
		WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
		FOR UPDATE`,
		sourceWalletOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
	).Scan(&retryCount)
	return retryCount, err == nil, err
}

// MarkWalletBalanceDelivered 标记一条私有余额通知已投递到腾讯云 IM。
// payload_json 更新成最终客户端负载，便于后台排查和死信重放使用同一消息形态。
func (r *MySQLRepository) MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, nowMs int64) error {
	_, err := r.db.ExecContext(ctx, `
		UPDATE notice_delivery_events
		SET status = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0,
		    payload_json = ?, last_error = '', delivered_at_ms = ?, updated_at_ms = ?
		WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
		deliveryStatusDelivered,
		string(deliveredPayload),
		nowMs,
		nowMs,
		sourceWalletOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
	)
	return err
}

// MarkWalletBalanceRetryable 释放当前投递锁，按 nextRetryAtMS 进入退避重试。
func (r *MySQLRepository) MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error {
	_, err := r.db.ExecContext(ctx, `
		UPDATE notice_delivery_events
		SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
		    next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
		WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
		deliveryStatusRetryable,
		retryCount,
		nextRetryAtMS,
		truncateError(lastErr),
		nowMs,
		sourceWalletOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
	)
	return err
}

// MarkWalletBalanceFailed 把 poison event 移入死信状态，不再自动抢占。
func (r *MySQLRepository) MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error {
	_, err := r.db.ExecContext(ctx, `
		UPDATE notice_delivery_events
		SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
		    next_retry_at_ms = 0, last_error = ?, updated_at_ms = ?
		WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
		deliveryStatusFailed,
		retryCount,
		truncateError(lastErr),
		nowMs,
		sourceWalletOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
	)
	return err
}

func normalizeDatabaseName(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return ""
	}
	for _, r := range value {
		if r == '_' || unicode.IsDigit(r) || unicode.IsLetter(r) {
			continue
		}
		return ""
	}
	return value
}

func walletPrivateNoticeEventTypes() []string {
	return []string{eventWalletBalance, eventVIPActivated}
}

func sqlPlaceholders(count int) string {
	if count <= 0 {
		return "''"
	}
	parts := make([]string, count)
	for i := range parts {
		parts[i] = "?"
	}
	return strings.Join(parts, ",")
}

func stringSliceToAny(values []string) []any {
	out := make([]any, 0, len(values))
	for _, value := range values {
		out = append(out, value)
	}
	return out
}

func isDuplicateKey(err error) bool {
	if err == nil {
		return false
	}
	return strings.Contains(err.Error(), "Error 1062") || strings.Contains(strings.ToLower(err.Error()), "duplicate")
}

func truncateError(value string) string {
	value = strings.TrimSpace(value)
	if len(value) <= 1000 {
		return value
	}
	return value[:1000]
}
