// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql

import (
	"context"
	"database/sql"
	"strings"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/services/room-service/internal/room/outbox"
)

// SaveOutbox 追加待投递 room_outbox 事件；同一 app_code/event_id 重投保持 no-op，既覆盖 Room Cell 事实，也覆盖外部 owner 派生展示。
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
	if len(records) == 0 {
		// 没有事件时保持无副作用，方便领域层统一调用。
		return nil
	}

	// 多条事件用一个事务写入，保证同一命令产生的主链路事件不会部分入库。
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}

	for _, record := range records {
		record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		if record.Envelope != nil {
			record.Envelope.AppCode = record.AppCode
		}
		// envelope 是 protobuf 信封，存储层不解析具体事件 body。
		envelopeBytes, err := proto.Marshal(record.Envelope)
		if err != nil {
			_ = tx.Rollback()
			return err
		}

		if _, err := tx.ExecContext(ctx,
			`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
			 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			 ON DUPLICATE KEY UPDATE event_id = event_id`,
			record.AppCode,
			record.EventID,
			record.EventType,
			record.RoomID,
			record.Status,
			envelopeBytes,
			record.CreatedAtMS,
			record.RetryCount,
			record.NextRetryAtMS,
			nullableString(record.LastError),
			record.CreatedAtMS,
		); err != nil {
			// 任一事件失败就回滚整批，避免 GiftSent/HeatChanged/RankChanged 部分缺失。
			_ = tx.Rollback()
			return err
		}
	}

	// commit 成功后事件才对 outbox worker 可见。
	return tx.Commit()
}

// ListRoomOutboxAppCodes 枚举本表实际存在的租户键，供常驻 worker 逐 App 使用现有前缀索引 claim。
// app_code 是主键首列，DISTINCT 可走有序索引；相比移除 app_code 条件全表 claim，不会让热 outbox 扫描退化。
func (r *Repository) ListRoomOutboxAppCodes(ctx context.Context) ([]string, error) {
	rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT app_code FROM room_outbox ORDER BY app_code`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	appCodes := make([]string, 0, 4)
	for rows.Next() {
		var appCode string
		if err := rows.Scan(&appCode); err != nil {
			return nil, err
		}
		appCodes = append(appCodes, appCode)
	}
	return appCodes, rows.Err()
}

// ListPendingOutbox 返回待投递事件。
func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
	if limit <= 0 {
		// 默认批量上限避免调用方传 0 导致全表扫描。
		limit = 100
	}

	// 按创建时间顺序扫描 pending，尽量保持事件投递顺序。
	nowMS := time.Now().UTC().UnixMilli()
	rows, err := r.db.QueryContext(ctx,
		`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
		 FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
		 WHERE app_code = ? AND status IN (?, ?)
		   AND next_retry_at_ms <= ?
		 ORDER BY created_at_ms ASC
		 LIMIT ?`,
		appcode.FromContext(ctx),
		outbox.StatusPending,
		outbox.StatusRetryable,
		nowMS,
		limit,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	records := make([]outbox.Record, 0, limit)
	for rows.Next() {
		// 每条 outbox 记录恢复 envelope，发布器只消费 protobuf 信封。
		var envelopeBytes []byte
		var record outbox.Record
		var lockUntil sql.Null[int64]
		if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
			return nil, err
		}
		if lockUntil.Valid {
			record.LockUntilMS = lockUntil.V
		}

		var envelope roomeventsv1.EventEnvelope
		if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
			// envelope 损坏说明 outbox 数据不可投递，需要暴露错误而不是跳过。
			return nil, err
		}

		record.Envelope = &envelope
		record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		records = append(records, record)
	}

	if err := rows.Err(); err != nil {
		return nil, err
	}

	return records, nil
}

// ClaimPendingOutbox 抢占一批待投递事件，避免多 worker 同时投递同一批。
func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) {
	if limit <= 0 {
		limit = 100
	}
	if strings.TrimSpace(workerID) == "" {
		workerID = "room-outbox-worker"
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}

	nowMS := time.Now().UTC().UnixMilli()
	appCode := appcode.FromContext(ctx)
	records := make([]outbox.Record, 0, limit)
	// pending 新事件的 next_retry_at_ms 固定为 0，真正决定可见性的是未被抢占的 lock_until_ms；
	// 因此 pending 分支必须走 claim 索引按 created_at_ms 直接取最早记录，避免堆积时 range 扫描历史行。
	claimBranches := []struct {
		query string
		args  []any
	}{
		{
			query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
			 FROM room_outbox FORCE INDEX (idx_room_outbox_claim)
			 WHERE app_code = ? AND status = ? AND lock_until_ms IS NULL AND next_retry_at_ms <= ?
			 ORDER BY created_at_ms ASC, event_id ASC
			 LIMIT ? FOR UPDATE SKIP LOCKED`,
			args: []any{appCode, outbox.StatusPending, nowMS},
		},
		{
			query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
			 FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
			 WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL
			 ORDER BY next_retry_at_ms ASC, created_at_ms ASC, event_id ASC
			 LIMIT ? FOR UPDATE SKIP LOCKED`,
			args: []any{appCode, outbox.StatusRetryable, nowMS},
		},
		{
			query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
			 FROM room_outbox FORCE INDEX (idx_room_outbox_claim)
			 WHERE app_code = ? AND status = ? AND lock_until_ms <= ?
			 ORDER BY created_at_ms ASC, event_id ASC
			 LIMIT ? FOR UPDATE SKIP LOCKED`,
			args: []any{appCode, outbox.StatusDelivering, nowMS},
		},
	}
	for _, branch := range claimBranches {
		remaining := limit - len(records)
		if remaining <= 0 {
			break
		}
		args := append(append([]any{}, branch.args...), remaining)
		branchRecords, err := r.queryRoomOutboxRecords(ctx, tx, branch.query, args...)
		if err != nil {
			_ = tx.Rollback()
			return nil, err
		}
		records = append(records, branchRecords...)
	}

	for _, record := range records {
		if _, err := tx.ExecContext(ctx,
			`UPDATE room_outbox
			 SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
			 WHERE app_code = ? AND event_id = ?`,
			outbox.StatusDelivering,
			workerID,
			lockUntilMS,
			nowMS,
			appCode,
			record.EventID,
		); err != nil {
			_ = tx.Rollback()
			return nil, err
		}
	}

	if err := tx.Commit(); err != nil {
		return nil, err
	}

	for index := range records {
		records[index].Status = outbox.StatusDelivering
		records[index].WorkerID = workerID
		records[index].LockUntilMS = lockUntilMS
	}

	return records, nil
}

func (r *Repository) queryRoomOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]outbox.Record, error) {
	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	records := make([]outbox.Record, 0)
	for rows.Next() {
		var envelopeBytes []byte
		var record outbox.Record
		var lockUntilValue sql.Null[int64]
		if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
			return nil, err
		}
		if lockUntilValue.Valid {
			record.LockUntilMS = lockUntilValue.V
		}

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

		record.Envelope = &envelope
		record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		records = append(records, record)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return records, nil
}

// MarkOutboxDelivered 标记事件投递成功。
func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error {
	// 成功后清空 last_error，后续扫描不再返回该事件。
	_, err := r.db.ExecContext(ctx,
		`UPDATE room_outbox
		 SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = NULL, updated_at_ms = ?
		 WHERE app_code = ? AND event_id = ?`,
		outbox.StatusDelivered,
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)

	return err
}

// MarkOutboxRetryable 标记事件投递失败并写入下一次重试时间。
func (r *Repository) MarkOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
	// 失败转入 retryable，worker 只有到 next_retry_at_ms 后才会重新抢占。
	_, err := r.db.ExecContext(ctx,
		`UPDATE room_outbox
		 SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
		 WHERE app_code = ? AND event_id = ?`,
		outbox.StatusRetryable,
		nextRetryAtMS,
		lastErr,
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)

	return err
}

// DeleteDeliveredOutboxBefore 删除一小批 delivered 且 updated_at_ms 早于 cutoffMS 的历史事件，返回本批删除行数。
// 只允许清理 delivered：pending/retryable/delivering 仍在投递生命周期内，failed 留给人工补偿排查。
// 调用方负责用足够大的保留窗口保护 replay-stat-tz 等直读 room_outbox 历史的重放/补数工具。
func (r *Repository) DeleteDeliveredOutboxBefore(ctx context.Context, cutoffMS int64, limit int) (int64, error) {
	if limit <= 0 {
		// 默认批量上限避免调用方传 0 导致单事务删除失控。
		limit = 500
	}

	// 单表 DELETE 不接受 index hint，先按 retention 索引选出最旧一批主键再删，避免优化器
	// 选到同前缀的 pending/claim 索引后对全部命中行 filesort。
	appCode := appcode.FromContext(ctx)
	rows, err := r.db.QueryContext(ctx,
		`SELECT event_id FROM room_outbox FORCE INDEX (idx_room_outbox_retention)
		 WHERE app_code = ? AND status = ? AND updated_at_ms < ?
		 ORDER BY updated_at_ms ASC, event_id ASC
		 LIMIT ?`,
		appCode,
		outbox.StatusDelivered,
		cutoffMS,
		limit,
	)
	if err != nil {
		return 0, err
	}
	defer rows.Close()

	eventIDs := make([]string, 0, limit)
	for rows.Next() {
		var eventID string
		if err := rows.Scan(&eventID); err != nil {
			return 0, err
		}
		eventIDs = append(eventIDs, eventID)
	}
	if err := rows.Err(); err != nil {
		return 0, err
	}
	if len(eventIDs) == 0 {
		return 0, nil
	}

	// delivered 是终态不会回流，select 到 delete 之间无需事务；status 条件兜底并发下的重复认领。
	args := make([]any, 0, len(eventIDs)+2)
	args = append(args, appCode, outbox.StatusDelivered)
	for _, eventID := range eventIDs {
		args = append(args, eventID)
	}
	placeholders := strings.TrimSuffix(strings.Repeat("?,", len(eventIDs)), ",")
	result, err := r.db.ExecContext(ctx,
		`DELETE FROM room_outbox WHERE app_code = ? AND status = ? AND event_id IN (`+placeholders+`)`,
		args...,
	)
	if err != nil {
		return 0, err
	}

	return result.RowsAffected()
}

// MarkOutboxDead 把超过最大重试次数的事件转入 failed。
func (r *Repository) MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE room_outbox
		 SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ?
		 WHERE app_code = ? AND event_id = ?`,
		outbox.StatusFailed,
		lastErr,
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)

	return err
}
