package mysql

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

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

const (
	giftWallProjectionName = "user_gift_wall"
	walletGiftDebitedEvent = walletmq.EventTypeWalletGiftDebited

	// projection 状态机只描述“某个 outbox 事件是否已经投影进礼物墙”。
	// 这里不复用 wallet_outbox.status，避免展示读模型反向污染账务事件分发语义。
	projectionStatusProcessing = "processing"
	projectionStatusFailed     = "failed"
	projectionStatusDone       = "done"
	projectionStatusDead       = "dead"
	projectionMaxRetries       = 10
)

type giftWallProjectionCandidate struct {
	AppCode     string
	EventID     string
	CreatedAtMS int64
}

type giftWallProjectionEvent struct {
	AppCode       string
	EventID       string
	TransactionID string
	CommandID     string
	PayloadJSON   string
	CreatedAtMS   int64
}

// ProjectPendingGiftWallEvents 把 WalletGiftDebited outbox 事件异步投影到 user_gift_wall。
// 扣费主事务只写账务事实和 outbox，礼物墙由 worker 后补，因此查询天然允许短暂延迟。
// 单条事件的聚合写入和 done 标记在同一事务内提交；服务重启时未提交事务回滚，已 done 的事件不会重复聚合。
func (r *Repository) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
	if r == nil || r.db == nil {
		return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return 0, xerr.New(xerr.InvalidArgument, "worker_id is required")
	}
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}

	candidates, err := r.listGiftWallProjectionCandidates(ctx, limit)
	if err != nil {
		return 0, err
	}
	processed := 0
	for _, candidate := range candidates {
		ok, err := r.projectGiftWallEvent(ctx, candidate, workerID, lockTTL)
		if err != nil {
			return processed, err
		}
		if ok {
			processed++
		}
	}
	return processed, nil
}

// ProjectGiftWallMessage projects one wallet_outbox MQ fact into user_gift_wall.
// Runtime projection consumes MQ and does not scan wallet_outbox for candidates.
func (r *Repository) ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
	if r == nil || r.db == nil {
		return false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if message.EventType != walletGiftDebitedEvent {
		return false, nil
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return false, xerr.New(xerr.InvalidArgument, "worker_id is required")
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}
	nowMs := time.Now().UnixMilli()
	lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
	event := giftWallProjectionEvent{
		AppCode:       appcode.Normalize(message.AppCode),
		EventID:       strings.TrimSpace(message.EventID),
		TransactionID: strings.TrimSpace(message.TransactionID),
		CommandID:     strings.TrimSpace(message.CommandID),
		PayloadJSON:   strings.TrimSpace(message.PayloadJSON),
		CreatedAtMS:   message.OccurredAtMS,
	}
	if event.PayloadJSON == "" {
		event.PayloadJSON = "{}"
	}
	ctx = appcode.WithContext(ctx, event.AppCode)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return false, err
	}
	defer func() { _ = tx.Rollback() }()
	return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
}

func (r *Repository) listGiftWallProjectionCandidates(ctx context.Context, limit int) ([]giftWallProjectionCandidate, error) {
	nowMs := time.Now().UnixMilli()
	appCode := appcode.FromContext(ctx)
	rows, err := r.db.QueryContext(ctx, `
		SELECT wo.app_code, wo.event_id, wo.created_at_ms
		FROM wallet_outbox wo FORCE INDEX (idx_wallet_outbox_event_created)
		LEFT JOIN wallet_projection_events pe
		  ON pe.projection_name = ? AND pe.app_code = wo.app_code AND pe.event_id = wo.event_id
		WHERE wo.app_code = ?
		  AND wo.event_type = ?
		  AND pe.event_id IS NULL
		ORDER BY wo.created_at_ms ASC, wo.event_id ASC
		LIMIT ?`,
		giftWallProjectionName,
		appCode,
		walletGiftDebitedEvent,
		limit,
	)
	if err != nil {
		return nil, err
	}
	candidates, err := scanGiftWallProjectionCandidates(rows, limit)
	if err != nil {
		return nil, err
	}
	rows, err = r.db.QueryContext(ctx, `
		SELECT wo.app_code, wo.event_id, wo.created_at_ms
		FROM wallet_projection_events pe
		JOIN wallet_outbox wo
		  ON wo.app_code = pe.app_code AND wo.event_id = pe.event_id
		WHERE pe.projection_name = ?
		  AND pe.app_code = ?
		  AND pe.status IN (?, ?)
		  AND pe.locked_until_ms <= ?
		  AND wo.event_type = ?
		ORDER BY wo.created_at_ms ASC, wo.event_id ASC
		LIMIT ?`,
		giftWallProjectionName,
		appCode,
		projectionStatusFailed,
		projectionStatusProcessing,
		nowMs,
		walletGiftDebitedEvent,
		limit,
	)
	if err != nil {
		return nil, err
	}
	retryCandidates, err := scanGiftWallProjectionCandidates(rows, limit)
	if err != nil {
		return nil, err
	}
	candidates = append(candidates, retryCandidates...)
	sort.SliceStable(candidates, func(i, j int) bool {
		if candidates[i].CreatedAtMS == candidates[j].CreatedAtMS {
			return candidates[i].EventID < candidates[j].EventID
		}
		return candidates[i].CreatedAtMS < candidates[j].CreatedAtMS
	})
	if len(candidates) > limit {
		candidates = candidates[:limit]
	}
	return candidates, nil
}

func scanGiftWallProjectionCandidates(rows *sql.Rows, limit int) ([]giftWallProjectionCandidate, error) {
	defer rows.Close()
	candidates := make([]giftWallProjectionCandidate, 0, limit)
	for rows.Next() {
		var candidate giftWallProjectionCandidate
		if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.CreatedAtMS); err != nil {
			return nil, err
		}
		candidates = append(candidates, candidate)
	}
	return candidates, rows.Err()
}

func (r *Repository) projectGiftWallEvent(ctx context.Context, candidate giftWallProjectionCandidate, workerID string, lockTTL time.Duration) (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 false, err
	}
	defer func() { _ = tx.Rollback() }()

	event, err := r.lockGiftWallOutboxEvent(ctx, tx, candidate)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// 候选扫描和事务锁定之间可能被清理或并发处理；这种竞争不应让 worker 失败。
			return false, nil
		}
		return false, err
	}
	return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
}

func (r *Repository) projectGiftWallLockedEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
	claimed, err := r.claimProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
	if err != nil || !claimed {
		return false, err
	}

	var metadata giftMetadata
	if err := json.Unmarshal([]byte(event.PayloadJSON), &metadata); err != nil {
		// 坏 payload 是单条事件的数据问题，记录失败后跳过本轮，避免一个坏事件卡死整个队列。
		if markErr := r.markProjectionFailedInTx(ctx, tx, event, "invalid gift projection payload: "+err.Error(), nowMs); markErr != nil {
			return false, markErr
		}
		return true, tx.Commit()
	}
	if err := validateGiftWallMetadata(metadata); err != nil {
		if markErr := r.markProjectionFailedInTx(ctx, tx, event, err.Error(), nowMs); markErr != nil {
			return false, markErr
		}
		return true, tx.Commit()
	}
	if err := r.upsertUserGiftWall(ctx, tx, metadata, event.CreatedAtMS); err != nil {
		// 聚合写失败时先释放当前事务锁，再把 projection 标记为 failed；下轮按 retry_count 继续重试。
		_ = tx.Rollback()
		if markErr := r.markProjectionFailed(ctx, event, err, nowMs); markErr != nil {
			return false, markErr
		}
		return false, err
	}
	if err := r.markProjectionDoneInTx(ctx, tx, event, nowMs); err != nil {
		return false, err
	}
	return true, tx.Commit()
}

func (r *Repository) lockGiftWallOutboxEvent(ctx context.Context, tx *sql.Tx, candidate giftWallProjectionCandidate) (giftWallProjectionEvent, error) {
	var event giftWallProjectionEvent
	err := tx.QueryRowContext(ctx, `
		SELECT app_code, event_id, transaction_id, command_id, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
		FROM wallet_outbox
		WHERE app_code = ? AND event_id = ? AND event_type = ?
		FOR UPDATE`,
		appcode.Normalize(candidate.AppCode),
		candidate.EventID,
		walletGiftDebitedEvent,
	).Scan(&event.AppCode, &event.EventID, &event.TransactionID, &event.CommandID, &event.PayloadJSON, &event.CreatedAtMS)
	return event, err
}

func (r *Repository) claimProjectionEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
	// 新事件通过 INSERT 获取处理权；并发 worker 命中同一事件时只有一个实例会插入成功。
	_, err := tx.ExecContext(ctx, `
		INSERT INTO wallet_projection_events (
		  projection_name, app_code, event_id, status, retry_count, locked_by,
		  locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 0, ?, ?, '', 0, ?, ?)`,
		giftWallProjectionName,
		event.AppCode,
		event.EventID,
		projectionStatusProcessing,
		workerID,
		lockUntilMS,
		nowMs,
		nowMs,
	)
	if err == nil {
		return true, nil
	}
	if !isMySQLDuplicateError(err) {
		return false, err
	}

	// 已存在的 failed/过期 processing 事件允许重新认领；done/dead 永远不被重复投影。
	result, err := tx.ExecContext(ctx, `
		UPDATE wallet_projection_events
		SET status = ?, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
		WHERE projection_name = ? AND app_code = ? AND event_id = ?
		  AND status IN (?, ?) AND locked_until_ms <= ?`,
		projectionStatusProcessing,
		workerID,
		lockUntilMS,
		nowMs,
		giftWallProjectionName,
		event.AppCode,
		event.EventID,
		projectionStatusFailed,
		projectionStatusProcessing,
		nowMs,
	)
	if err != nil {
		return false, err
	}
	affected, err := result.RowsAffected()
	return affected > 0, err
}

func (r *Repository) markProjectionDoneInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, nowMs int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE wallet_projection_events
		SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', processed_at_ms = ?, updated_at_ms = ?
		WHERE projection_name = ? AND app_code = ? AND event_id = ?`,
		projectionStatusDone,
		nowMs,
		nowMs,
		giftWallProjectionName,
		event.AppCode,
		event.EventID,
	)
	return err
}

func (r *Repository) markProjectionFailedInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, reason string, nowMs int64) error {
	reason = trimProjectionError(reason)
	// 失败次数达到上限后转 dead，保留 last_error 给后台排查，不再无限重试污染 MySQL。
	_, err := tx.ExecContext(ctx, `
		UPDATE wallet_projection_events
		SET status = IF(retry_count + 1 >= ?, ?, ?),
		    retry_count = retry_count + 1,
		    locked_by = '',
		    locked_until_ms = 0,
		    last_error = ?,
		    updated_at_ms = ?
		WHERE projection_name = ? AND app_code = ? AND event_id = ?`,
		projectionMaxRetries,
		projectionStatusDead,
		projectionStatusFailed,
		reason,
		nowMs,
		giftWallProjectionName,
		event.AppCode,
		event.EventID,
	)
	return err
}

func (r *Repository) markProjectionFailed(ctx context.Context, event giftWallProjectionEvent, cause error, nowMs int64) error {
	reason := trimProjectionError(cause.Error())
	_, err := r.db.ExecContext(ctx, `
		INSERT INTO wallet_projection_events (
		  projection_name, app_code, event_id, status, retry_count, locked_by,
		  locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 1, '', 0, ?, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
		  status = IF(retry_count + 1 >= ?, ?, ?),
		  retry_count = retry_count + 1,
		  locked_by = '',
		  locked_until_ms = 0,
		  last_error = VALUES(last_error),
		  updated_at_ms = VALUES(updated_at_ms)`,
		giftWallProjectionName,
		event.AppCode,
		event.EventID,
		projectionStatusFailed,
		reason,
		nowMs,
		nowMs,
		projectionMaxRetries,
		projectionStatusDead,
		projectionStatusFailed,
	)
	return err
}

func validateGiftWallMetadata(metadata giftMetadata) error {
	// 礼物墙以收礼人、礼物 ID 和数量为最小可投影事实；缺任何一个都不能安全聚合。
	if metadata.TargetUserID <= 0 || strings.TrimSpace(metadata.GiftID) == "" || metadata.GiftCount <= 0 {
		return xerr.New(xerr.InvalidArgument, "gift projection metadata is incomplete")
	}
	return nil
}

func trimProjectionError(value string) string {
	value = strings.TrimSpace(value)
	if len(value) > 1024 {
		return value[:1024]
	}
	return value
}
