package app

import (
	"context"
	"fmt"
	"strings"
	"time"
)

const (
	appNotificationExpirySweepMaxLimit = 500
	appNotificationExpiryRetryBase     = time.Minute
	appNotificationExpiryRetryMax      = time.Hour
	appNotificationExpiryErrorMaxRunes = 2048
)

// AppNotificationExpiryFailure reports one candidate which raced or could not
// converge during a bounded expiry sweep.
type AppNotificationExpiryFailure struct {
	NotificationID string
	WorkspaceID    string
	Error          error
}

// AppNotificationExpirySweepResult reports converged notifications and
// deferred failures. Realtime projection is delivered from the transactional
// scope fanout outbox written by each successful mutation.
type AppNotificationExpirySweepResult struct {
	Expired  []AppNotificationResult
	Failures []AppNotificationExpiryFailure
}

type dueAppNotification struct {
	ID                    string
	WorkspaceID           string
	ServiceInstallationID string
	Revision              int64
	ExpiresAt             time.Time
	ExpiryAttempts        int
}

// ExpireDueAppNotifications converges a bounded set of active notifications
// whose durable expiry has elapsed. workspaceGroupID scopes workspace-profile
// workers; an empty group processes every workspace for the all profile.
func (store *Store) ExpireDueAppNotifications(ctx context.Context, workspaceGroupID string, now time.Time, limit int) (AppNotificationExpirySweepResult, error) {
	if store == nil || store.db == nil {
		return AppNotificationExpirySweepResult{}, fmt.Errorf("App notification expiry store is not configured")
	}
	now = now.UTC()
	if now.IsZero() {
		now = time.Now().UTC()
	}
	if limit <= 0 {
		limit = 100
	}
	if limit > appNotificationExpirySweepMaxLimit {
		limit = appNotificationExpirySweepMaxLimit
	}
	rows, err := store.db.Query(ctx, `
SELECT notification.id, notification.workspace_id,
       notification.service_installation_id, notification.revision,
       notification.expires_at, notification.expiry_attempts
FROM service_notifications notification
WHERE notification.status = 'active'
  AND notification.expires_at IS NOT NULL
  AND notification.expires_at <= $1
  AND notification.expiry_next_attempt_at IS NOT NULL
  AND notification.expiry_next_attempt_at <= $1
  AND ($2 = '' OR EXISTS (
      SELECT 1
      FROM workspace_placements placement
      WHERE placement.workspace_id = notification.workspace_id
        AND placement.workspace_group_id = $2
        AND placement.state IN ('active', 'draining', 'moving')
  ))
ORDER BY notification.expiry_next_attempt_at ASC, notification.id ASC
LIMIT $3
`, now, strings.TrimSpace(workspaceGroupID), limit)
	if err != nil {
		return AppNotificationExpirySweepResult{}, fmt.Errorf("list due App notifications: %w", err)
	}
	candidates := make([]dueAppNotification, 0, limit)
	for rows.Next() {
		var candidate dueAppNotification
		if err := rows.Scan(&candidate.ID, &candidate.WorkspaceID, &candidate.ServiceInstallationID, &candidate.Revision, &candidate.ExpiresAt, &candidate.ExpiryAttempts); err != nil {
			rows.Close()
			return AppNotificationExpirySweepResult{}, fmt.Errorf("scan due App notification: %w", err)
		}
		candidates = append(candidates, candidate)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return AppNotificationExpirySweepResult{}, fmt.Errorf("iterate due App notifications: %w", err)
	}
	rows.Close()

	result := AppNotificationExpirySweepResult{Expired: make([]AppNotificationResult, 0, len(candidates))}
	for _, candidate := range candidates {
		mutation, err := store.MutateAppNotification(ctx, AppNotificationMutationCommand{
			WorkspaceID: candidate.WorkspaceID, ServiceInstallationID: candidate.ServiceInstallationID,
			Ingress:        AppNotificationIngressInternal,
			ExecutionActor: AppNotificationExecutionActor{Type: "system", ID: "app_notification_expiry"},
			NotificationID: candidate.ID, Kind: AppNotificationMutationExpire,
			ExpectedRevision: candidate.Revision,
			ClientMutationID: "expire:" + candidate.ID + ":" + candidate.ExpiresAt.UTC().Format(time.RFC3339Nano),
			CausedByActor:    AppNotificationActorRef{Type: "system", ID: "app_notification_expiry"},
			CorrelationID:    "expire:" + candidate.ID,
		})
		if err != nil {
			recorded, recordErr := store.deferAppNotificationExpiryRetry(ctx, candidate, now, err)
			if recordErr != nil {
				return result, recordErr
			}
			if recorded {
				result.Failures = append(result.Failures, AppNotificationExpiryFailure{
					NotificationID: candidate.ID, WorkspaceID: candidate.WorkspaceID, Error: err,
				})
			}
			continue
		}
		result.Expired = append(result.Expired, mutation)
	}
	return result, nil
}

func (store *Store) deferAppNotificationExpiryRetry(ctx context.Context, candidate dueAppNotification, now time.Time, failure error) (bool, error) {
	nextAttempt := now.Add(appNotificationExpiryRetryDelay(candidate.ExpiryAttempts + 1))
	result, err := store.db.Exec(ctx, `
UPDATE service_notifications
SET expiry_attempts = expiry_attempts + 1,
    expiry_next_attempt_at = $1,
    expiry_last_error = $2
WHERE workspace_id = $3
  AND id = $4
  AND status = 'active'
  AND revision = $5
`, nextAttempt, truncateAppNotificationExpiryError(failure), candidate.WorkspaceID, candidate.ID, candidate.Revision)
	if err != nil {
		return false, fmt.Errorf("defer App notification expiry retry: %w", err)
	}
	return result.RowsAffected() == 1, nil
}

func appNotificationExpiryRetryDelay(attempt int) time.Duration {
	if attempt <= 1 {
		return appNotificationExpiryRetryBase
	}
	delay := appNotificationExpiryRetryBase
	for index := 1; index < attempt && delay < appNotificationExpiryRetryMax; index++ {
		delay *= 2
		if delay >= appNotificationExpiryRetryMax {
			return appNotificationExpiryRetryMax
		}
	}
	return delay
}

func truncateAppNotificationExpiryError(err error) string {
	if err == nil {
		return ""
	}
	value := []rune(strings.TrimSpace(err.Error()))
	if len(value) > appNotificationExpiryErrorMaxRunes {
		value = value[:appNotificationExpiryErrorMaxRunes]
	}
	return string(value)
}
