package mysql

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

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

// UserOutboxRecord is a durable user-service fact waiting for MQ fanout.
type UserOutboxRecord struct {
	AppCode       string
	EventID       string
	EventType     string
	AggregateType string
	AggregateID   int64
	PayloadJSON   string
	CreatedAtMS   int64
	RetryCount    int
	LastError     string
	WorkerID      string
	LockUntilMS   int64
}

// ListUserOutboxAppCodes enumerates real tenant keys from the owner table.
// PRIMARY begins with app_code, and the worker caches this discovery for five
// minutes; normal claims continue to use status/lock indexes within one App.
func (r *Repository) ListUserOutboxAppCodes(ctx context.Context) ([]string, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT app_code FROM user_outbox FORCE INDEX (PRIMARY) 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()
}

// ClaimPendingUserOutbox locks pending user facts so one worker can publish them.
func (r *Repository) ClaimPendingUserOutbox(ctx context.Context, workerID string, batchSize int) ([]UserOutboxRecord, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if batchSize <= 0 {
		batchSize = 100
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		workerID = "user-outbox-worker"
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()
	nowMS := time.Now().UTC().UnixMilli()
	lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
	appCode := appcode.FromContext(ctx)
	records := make([]UserOutboxRecord, 0, batchSize)
	// Recovery work must claim before fresh pending facts. A continuously growing
	// pending backlog can otherwise fill every batch and permanently strand both
	// due retries and running rows whose worker lease expired. Keep the states in
	// separate SKIP LOCKED queries so each branch retains its status-leading index.
	claimBranches := []struct {
		query string
		args  []any
	}{
		{
			query: `
		SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
		FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
		WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED
	`,
			args: []any{appCode, nowMS},
		},
		{
			query: `
		SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
		FROM user_outbox FORCE INDEX (idx_user_outbox_lock)
		WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED
	`,
			args: []any{appCode, nowMS},
		},
		{
			query: `
		SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
		FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
		WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED
	`,
			args: []any{appCode, nowMS},
		},
	}
	for _, branch := range claimBranches {
		remaining := batchSize - len(records)
		if remaining <= 0 {
			break
		}
		args := append(append([]any{}, branch.args...), remaining)
		branchRecords, err := queryUserOutboxRecords(ctx, tx, branch.query, args...)
		if err != nil {
			return nil, err
		}
		records = append(records, branchRecords...)
	}
	for _, record := range records {
		if _, err := tx.ExecContext(ctx, `
			UPDATE user_outbox
			SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND event_id = ?
		`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	for index := range records {
		records[index].WorkerID = workerID
		records[index].LockUntilMS = lockUntilMS
	}
	return records, nil
}

func queryUserOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]UserOutboxRecord, error) {
	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	records := make([]UserOutboxRecord, 0)
	for rows.Next() {
		var record UserOutboxRecord
		if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.AggregateType, &record.AggregateID, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
			return nil, err
		}
		records = append(records, record)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return records, nil
}

// MarkUserOutboxDelivered marks a user fact as published after RocketMQ accepts it.
func (r *Repository) MarkUserOutboxDelivered(ctx context.Context, eventID string) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE user_outbox
		SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?
	`, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
	return err
}

// MarkUserOutboxRetryable releases a failed publish with a bounded retry delay.
func (r *Repository) MarkUserOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE user_outbox
		SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
		    next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ? AND status = 'running'
	`, nextRetryAtMS, trimUserOutboxError(lastErr), time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
	return err
}

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