package roomnotice

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

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
)

const (
	sourceRoomOutbox         = "room_outbox"
	channelTencentIMC2C      = "tencent_im_c2c"
	eventRoomUserKicked      = "RoomUserKicked"
	deliveryStatusDelivering = "delivering"
	deliveryStatusDelivered  = "delivered"
	deliveryStatusRetryable  = "retryable"
	deliveryStatusFailed     = "failed"
)

// MySQLRepository 只读取 room_outbox 事实，并把 notice 自己的消费位点写入 notice_delivery_events。
type MySQLRepository struct {
	db              *sql.DB
	roomOutboxTable string
}

// RoomKickEvent 是 notice-service 从 room_outbox 认领的一条踢人私有通知事实。
type RoomKickEvent struct {
	AppCode      string
	EventID      string
	EventType    string
	RoomID       string
	RoomVersion  int64
	ActorUserID  int64
	TargetUserID int64
	OccurredAtMS int64
	CreatedAtMS  int64
	RetryCount   int
}

// NewMySQLRepository 创建房间私有通知仓储；room_outbox 被视作 append-only 事实源。
func NewMySQLRepository(db *sql.DB, roomDatabase string) (*MySQLRepository, error) {
	if db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql db is not configured")
	}
	roomDatabase = normalizeDatabaseName(roomDatabase)
	if roomDatabase == "" {
		return nil, xerr.New(xerr.InvalidArgument, "room_database is invalid")
	}
	return &MySQLRepository{
		db:              db,
		roomOutboxTable: fmt.Sprintf("`%s`.`room_outbox`", roomDatabase),
	}, nil
}

// ClaimRoomKickEvents 抢占待投递给被踢用户的私有 C2C 通知。
// room_outbox 的 pending/delivered 状态属于房间群消息 bridge；这里不能修改它，避免多消费者互相覆盖。
func (r *MySQLRepository) ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, 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.listRoomKickCandidates(ctx, limit, time.Now().UnixMilli())
	if err != nil {
		return nil, err
	}
	claimed := make([]RoomKickEvent, 0, len(candidates))
	for _, candidate := range candidates {
		event, ok, err := r.claimRoomKickCandidate(ctx, candidate, workerID, lockTTL)
		if err != nil {
			return claimed, err
		}
		if ok {
			claimed = append(claimed, event)
		}
	}
	return claimed, nil
}

type roomKickCandidate struct {
	AppCode string
	EventID string
}

func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) {
	appCode := appcode.FromContext(ctx)
	candidates := make([]roomKickCandidate, 0, limit)
	branches := []struct {
		query string
		args  []any
	}{
		{
			query: fmt.Sprintf(`
		SELECT ro.app_code, ro.event_id
		FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
		LEFT JOIN notice_delivery_events nde
		  ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
		WHERE ro.app_code = ? AND ro.event_type = ?
		  AND nde.source_event_id IS NULL
		ORDER BY ro.created_at_ms ASC, ro.event_id ASC
		LIMIT ?`, r.roomOutboxTable),
			args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked},
		},
		{
			query: fmt.Sprintf(`
		SELECT ro.app_code, ro.event_id
		FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
		JOIN notice_delivery_events nde
		  ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
		WHERE ro.app_code = ? AND ro.event_type = ?
		  AND nde.status = ? AND nde.next_retry_at_ms <= ?
		ORDER BY ro.created_at_ms ASC, ro.event_id ASC
		LIMIT ?`, r.roomOutboxTable),
			args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusRetryable, nowMs},
		},
		{
			query: fmt.Sprintf(`
		SELECT ro.app_code, ro.event_id
		FROM %s ro FORCE INDEX (idx_room_outbox_event_created)
		JOIN notice_delivery_events nde
		  ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
		WHERE ro.app_code = ? AND ro.event_type = ?
		  AND nde.status = ? AND nde.lock_until_ms <= ?
		ORDER BY ro.created_at_ms ASC, ro.event_id ASC
		LIMIT ?`, r.roomOutboxTable),
			args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusDelivering, nowMs},
		},
	}
	for _, branch := range branches {
		remaining := limit - len(candidates)
		if remaining <= 0 {
			break
		}
		args := append(append([]any{}, branch.args...), remaining)
		branchCandidates, err := queryRoomKickCandidates(ctx, r.db, branch.query, args...)
		if err != nil {
			return nil, err
		}
		candidates = append(candidates, branchCandidates...)
	}
	return candidates, nil
}

func queryRoomKickCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]roomKickCandidate, error) {
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	candidates := make([]roomKickCandidate, 0)
	for rows.Next() {
		var candidate roomKickCandidate
		if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil {
			return nil, err
		}
		candidates = append(candidates, candidate)
	}
	return candidates, rows.Err()
}

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

	event, err := r.lockRoomKickEvent(ctx, tx, candidate)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// 候选扫描和事务锁定之间可能被清理或事件类型变化，跳过该行即可。
			return RoomKickEvent{}, false, nil
		}
		return RoomKickEvent{}, false, err
	}
	retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
	if err != nil || !claimed {
		return RoomKickEvent{}, false, err
	}
	event.RetryCount = retryCount
	if err := tx.Commit(); err != nil {
		return RoomKickEvent{}, false, err
	}
	return event, true, nil
}

// ClaimRoomKickEnvelope 把 MQ 收到的 room_outbox 信封认领到 notice_delivery_events。
func (r *MySQLRepository) ClaimRoomKickEnvelope(ctx context.Context, workerID string, envelope *roomeventsv1.EventEnvelope, lockTTL time.Duration) (RoomKickEvent, bool, error) {
	if r == nil || r.db == nil {
		return RoomKickEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return RoomKickEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}
	createdAtMS := int64(0)
	if envelope != nil {
		createdAtMS = envelope.GetOccurredAtMs()
	}
	event, err := roomKickEventFromEnvelope(envelope, createdAtMS)
	if err != nil {
		return RoomKickEvent{}, 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 RoomKickEvent{}, false, err
	}
	defer func() { _ = tx.Rollback() }()

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

// LoadRoomOutboxEnvelope loads one immutable owner event for an exact offline
// replay. Requiring the full primary key prevents repair tooling from scanning or
// claiming room-service state that was not selected by an operator.
func (r *MySQLRepository) LoadRoomOutboxEnvelope(ctx context.Context, appCode string, eventID string) (*roomeventsv1.EventEnvelope, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "notice repository is not configured")
	}
	appCode = strings.TrimSpace(appCode)
	eventID = strings.TrimSpace(eventID)
	if appCode == "" || eventID == "" {
		return nil, xerr.New(xerr.InvalidArgument, "app_code and event_id are required")
	}
	appCode = appcode.Normalize(appCode)

	var (
		ownerRoomID   string
		envelopeBytes []byte
	)
	query := fmt.Sprintf(`
		SELECT room_id, envelope
		FROM %s
		WHERE app_code = ? AND event_id = ? AND event_type = ?`, r.roomOutboxTable)
	if err := r.db.QueryRowContext(ctx, query, appCode, eventID, eventRoomUserKicked).Scan(&ownerRoomID, &envelopeBytes); err != nil {
		return nil, err
	}
	var envelope roomeventsv1.EventEnvelope
	if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
		return nil, err
	}
	// Validate both the requested identity and the current consumer contract. A
	// mismatched historical blob must never be delivered under another event's
	// notice_delivery_events key.
	if envelope.GetAppCode() != appCode || envelope.GetEventId() != eventID || envelope.GetRoomId() != ownerRoomID {
		return nil, fmt.Errorf("room outbox envelope identity does not match requested key")
	}
	if _, err := roomKickEventFromEnvelope(&envelope, envelope.GetOccurredAtMs()); err != nil {
		return nil, err
	}
	return &envelope, nil
}

func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, candidate roomKickCandidate) (RoomKickEvent, error) {
	var (
		event         RoomKickEvent
		envelopeBytes []byte
	)
	query := fmt.Sprintf(`
		SELECT app_code, event_id, event_type, room_id, envelope, created_at_ms
		FROM %s
		WHERE app_code = ? AND event_id = ? AND event_type = ?
		FOR UPDATE`, r.roomOutboxTable)
	err := tx.QueryRowContext(ctx, query,
		appcode.Normalize(candidate.AppCode),
		candidate.EventID,
		eventRoomUserKicked,
	).Scan(
		&event.AppCode,
		&event.EventID,
		&event.EventType,
		&event.RoomID,
		&envelopeBytes,
		&event.CreatedAtMS,
	)
	if err != nil {
		return RoomKickEvent{}, err
	}

	var envelope roomeventsv1.EventEnvelope
	if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
		return RoomKickEvent{}, err
	}
	return roomKickEventFromEnvelope(&envelope, event.CreatedAtMS)
}

func roomKickEventFromEnvelope(envelope *roomeventsv1.EventEnvelope, createdAtMS int64) (RoomKickEvent, error) {
	if envelope == nil {
		return RoomKickEvent{}, fmt.Errorf("room event envelope is required")
	}
	if envelope.GetEventType() != eventRoomUserKicked {
		return RoomKickEvent{}, fmt.Errorf("unexpected room event type %q", envelope.GetEventType())
	}
	var body roomeventsv1.RoomUserKicked
	if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
		return RoomKickEvent{}, err
	}
	event := RoomKickEvent{
		AppCode:      appcode.Normalize(envelope.GetAppCode()),
		EventID:      envelope.GetEventId(),
		EventType:    envelope.GetEventType(),
		RoomID:       envelope.GetRoomId(),
		RoomVersion:  envelope.GetRoomVersion(),
		ActorUserID:  body.GetActorUserId(),
		TargetUserID: body.GetTargetUserId(),
		OccurredAtMS: envelope.GetOccurredAtMs(),
		CreatedAtMS:  createdAtMS,
	}
	if event.AppCode == "" || event.EventID == "" || event.RoomID == "" {
		return RoomKickEvent{}, fmt.Errorf("room kick event envelope is incomplete")
	}
	if event.TargetUserID <= 0 {
		return RoomKickEvent{}, fmt.Errorf("room kick event target_user_id is invalid")
	}
	return event, nil
}

func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, event RoomKickEvent, workerID string, lockUntilMS int64, nowMs int64) (int, bool, error) {
	payload, err := roomKickNoticePayload(event)
	if err != nil {
		return 0, false, err
	}
	_, 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, ?, ?)`,
		sourceRoomOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
		event.EventType,
		event.TargetUserID,
		deliveryStatusDelivering,
		workerID,
		lockUntilMS,
		string(payload),
		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,
		string(payload),
		nowMs,
		sourceRoomOutbox,
		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,
			string(payload),
			nowMs,
			sourceRoomOutbox,
			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`,
		sourceRoomOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
	).Scan(&retryCount)
	return retryCount, err == nil, err
}

// MarkRoomKickDelivered 标记被踢私有通知已送达腾讯云 IM C2C 通道。
func (r *MySQLRepository) MarkRoomKickDelivered(ctx context.Context, event RoomKickEvent, deliveredPayload []byte, 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,
		sourceRoomOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
	)
	return err
}

// MarkRoomKickRetryable 释放当前锁并进入退避重试；源 room_outbox 不受影响。
func (r *MySQLRepository) MarkRoomKickRetryable(ctx context.Context, event RoomKickEvent, 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,
		sourceRoomOutbox,
		event.AppCode,
		event.EventID,
		channelTencentIMC2C,
	)
	return err
}

// MarkRoomKickFailed 把无法投递的私有通知移入 notice 死信，等待后台人工重放。
func (r *MySQLRepository) MarkRoomKickFailed(ctx context.Context, event RoomKickEvent, 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,
		sourceRoomOutbox,
		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 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]
}
