package savings

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

	"github.com/jackc/pgx/v5"

	"github.com/niels/banking-app/backend/internal/domain"
)

type PocketCategory struct {
	ID        string    `json:"id"`
	UserID    string    `json:"user_id,omitempty"`
	Name      string    `json:"name"`
	Icon      string    `json:"icon"`
	Color     string    `json:"color"`
	Status    string    `json:"status"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type CreateCategoryParams struct {
	UserID string
	Name   string
	Icon   string
	Color  string
}

type RoundUpRule struct {
	ID                    string     `json:"id"`
	SavingsGoalID         string     `json:"savings_goal_id"`
	UserID                string     `json:"user_id"`
	Source                string     `json:"source"`
	RoundToIncrementCents int64      `json:"round_to_increment_cents"`
	Multiplier            string     `json:"multiplier"`
	MinimumRoundUpCents   int64      `json:"minimum_roundup_cents"`
	MaximumRoundUpCents   int64      `json:"maximum_roundup_cents"`
	MonthlyCapCents       *int64     `json:"monthly_cap_cents,omitempty"`
	Status                string     `json:"status"`
	LastAppliedAt         *time.Time `json:"last_applied_at,omitempty"`
	CreatedAt             time.Time  `json:"created_at"`
	UpdatedAt             time.Time  `json:"updated_at"`
}

type UpsertRoundUpRuleParams struct {
	UserID                string
	GoalID                string
	Source                string
	RoundToIncrementCents int64
	Multiplier            string
	MinimumRoundUpCents   int64
	MaximumRoundUpCents   int64
	MonthlyCapCents       *int64
	Status                string
}

type RecurringRule struct {
	ID            string     `json:"id"`
	SavingsGoalID string     `json:"savings_goal_id"`
	UserID        string     `json:"user_id"`
	AmountCents   int64      `json:"amount_cents"`
	Currency      string     `json:"currency"`
	Frequency     string     `json:"frequency"`
	IntervalCount int        `json:"interval_count"`
	DayOfMonth    *int       `json:"day_of_month,omitempty"`
	DayOfWeek     *int       `json:"day_of_week,omitempty"`
	NextRunOn     string     `json:"next_run_on"`
	Description   string     `json:"description,omitempty"`
	Status        string     `json:"status"`
	LastRunAt     *time.Time `json:"last_run_at,omitempty"`
	CreatedAt     time.Time  `json:"created_at"`
	UpdatedAt     time.Time  `json:"updated_at"`
}

type CreateRecurringRuleParams struct {
	UserID        string
	GoalID        string
	AmountCents   int64
	Frequency     string
	IntervalCount int
	DayOfMonth    *int
	DayOfWeek     *int
	NextRunOn     string
	Description   string
	Status        string
}

type PocketMember struct {
	ID            string     `json:"id"`
	SavingsGoalID string     `json:"savings_goal_id"`
	OwnerUserID   string     `json:"owner_user_id"`
	MemberUserID  string     `json:"member_user_id"`
	Role          string     `json:"role"`
	Status        string     `json:"status"`
	InvitedAt     time.Time  `json:"invited_at"`
	AcceptedAt    *time.Time `json:"accepted_at,omitempty"`
	RevokedAt     *time.Time `json:"revoked_at,omitempty"`
	CreatedAt     time.Time  `json:"created_at"`
	UpdatedAt     time.Time  `json:"updated_at"`
}

type AddMemberParams struct {
	UserID       string
	GoalID       string
	MemberUserID string
	Role         string
	Status       string
}

type ProviderLinkParams struct {
	UserID             string
	GoalID             string
	ProviderName       string
	ExternalPocketID   string
	ProviderSyncStatus string
	ProviderSyncError  string
}

func (r *Repository) ListCategories(ctx context.Context, userID string) ([]PocketCategory, error) {
	rows, err := r.db.Query(ctx, `
		SELECT id::text, COALESCE(user_id::text, ''), name, icon, color, status, created_at, updated_at
		FROM savings_pocket_categories
		WHERE status = 'active' AND (user_id IS NULL OR user_id = $1)
		ORDER BY user_id NULLS FIRST, name
	`, userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	categories := []PocketCategory{}
	for rows.Next() {
		category, err := scanCategory(rows)
		if err != nil {
			return nil, err
		}
		categories = append(categories, category)
	}
	return categories, rows.Err()
}

func (r *Repository) CreateCategory(ctx context.Context, params CreateCategoryParams) (PocketCategory, error) {
	name, err := normalizePocketName(params.Name)
	if err != nil {
		return PocketCategory{}, err
	}
	icon, err := normalizePocketIcon(params.Icon)
	if err != nil {
		return PocketCategory{}, err
	}
	color, err := normalizePocketColor(params.Color)
	if err != nil {
		return PocketCategory{}, err
	}

	row := r.db.QueryRow(ctx, `
		INSERT INTO savings_pocket_categories (user_id, name, icon, color)
		VALUES ($1, $2, $3, $4)
		RETURNING id::text, COALESCE(user_id::text, ''), name, icon, color, status, created_at, updated_at
	`, params.UserID, name, icon, color)
	return scanCategory(row)
}

func (r *Repository) ListRoundUpRules(ctx context.Context, userID, goalID string) ([]RoundUpRule, error) {
	if err := r.ensureGoalAccessible(ctx, userID, goalID); err != nil {
		return nil, err
	}
	rows, err := r.db.Query(ctx, `
		SELECT id::text, savings_goal_id::text, user_id::text, source, round_to_increment_cents,
			multiplier::text, minimum_roundup_cents, maximum_roundup_cents, monthly_cap_cents,
			status, last_applied_at, created_at, updated_at
		FROM savings_pocket_roundup_rules
		WHERE savings_goal_id = $1
		ORDER BY created_at DESC
	`, goalID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	rules := []RoundUpRule{}
	for rows.Next() {
		rule, err := scanRoundUpRule(rows)
		if err != nil {
			return nil, err
		}
		rules = append(rules, rule)
	}
	return rules, rows.Err()
}

func (r *Repository) UpsertRoundUpRule(ctx context.Context, params UpsertRoundUpRuleParams) (RoundUpRule, error) {
	if err := r.ensureGoalOwner(ctx, params.UserID, params.GoalID); err != nil {
		return RoundUpRule{}, err
	}
	if err := normalizeRoundUpRuleParams(&params); err != nil {
		return RoundUpRule{}, err
	}
	row := r.db.QueryRow(ctx, `
		INSERT INTO savings_pocket_roundup_rules (
			savings_goal_id, user_id, source, round_to_increment_cents, multiplier,
			minimum_roundup_cents, maximum_roundup_cents, monthly_cap_cents, status
		)
		VALUES ($1, $2, $3, $4, $5::numeric, $6, $7, $8, $9)
		ON CONFLICT (savings_goal_id, source) DO UPDATE
		SET round_to_increment_cents = EXCLUDED.round_to_increment_cents,
			multiplier = EXCLUDED.multiplier,
			minimum_roundup_cents = EXCLUDED.minimum_roundup_cents,
			maximum_roundup_cents = EXCLUDED.maximum_roundup_cents,
			monthly_cap_cents = EXCLUDED.monthly_cap_cents,
			status = EXCLUDED.status
		RETURNING id::text, savings_goal_id::text, user_id::text, source, round_to_increment_cents,
			multiplier::text, minimum_roundup_cents, maximum_roundup_cents, monthly_cap_cents,
			status, last_applied_at, created_at, updated_at
	`, params.GoalID, params.UserID, params.Source, params.RoundToIncrementCents, params.Multiplier,
		params.MinimumRoundUpCents, params.MaximumRoundUpCents, params.MonthlyCapCents, params.Status)
	return scanRoundUpRule(row)
}

func (r *Repository) ListRecurringRules(ctx context.Context, userID, goalID string) ([]RecurringRule, error) {
	if err := r.ensureGoalAccessible(ctx, userID, goalID); err != nil {
		return nil, err
	}
	rows, err := r.db.Query(ctx, `
		SELECT id::text, savings_goal_id::text, user_id::text, amount_cents, currency,
			frequency, interval_count, day_of_month, day_of_week, next_run_on::text,
			description, status, last_run_at, created_at, updated_at
		FROM savings_pocket_recurring_rules
		WHERE savings_goal_id = $1
		ORDER BY status, next_run_on, created_at DESC
	`, goalID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	rules := []RecurringRule{}
	for rows.Next() {
		rule, err := scanRecurringRule(rows)
		if err != nil {
			return nil, err
		}
		rules = append(rules, rule)
	}
	return rules, rows.Err()
}

func (r *Repository) CreateRecurringRule(ctx context.Context, params CreateRecurringRuleParams) (RecurringRule, error) {
	if err := r.ensureGoalOwner(ctx, params.UserID, params.GoalID); err != nil {
		return RecurringRule{}, err
	}
	if err := normalizeRecurringRuleParams(&params); err != nil {
		return RecurringRule{}, err
	}
	var goalCurrency string
	if err := r.db.QueryRow(ctx, `
		SELECT currency FROM savings_goals WHERE id = $1 AND user_id = $2
	`, params.GoalID, params.UserID).Scan(&goalCurrency); err != nil {
		return RecurringRule{}, err
	}
	row := r.db.QueryRow(ctx, `
		INSERT INTO savings_pocket_recurring_rules (
			savings_goal_id, user_id, amount_cents, currency, frequency, interval_count,
			day_of_month, day_of_week, next_run_on, description, status
		)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::date, $10, $11)
		RETURNING id::text, savings_goal_id::text, user_id::text, amount_cents, currency,
			frequency, interval_count, day_of_month, day_of_week, next_run_on::text,
			description, status, last_run_at, created_at, updated_at
	`, params.GoalID, params.UserID, params.AmountCents, goalCurrency, params.Frequency, params.IntervalCount,
		params.DayOfMonth, params.DayOfWeek, params.NextRunOn, params.Description, params.Status)
	return scanRecurringRule(row)
}

func (r *Repository) ListMembers(ctx context.Context, userID, goalID string) ([]PocketMember, error) {
	if err := r.ensureGoalOwner(ctx, userID, goalID); err != nil {
		return nil, err
	}
	rows, err := r.db.Query(ctx, `
		SELECT id::text, savings_goal_id::text, owner_user_id::text, member_user_id::text,
			role, status, invited_at, accepted_at, revoked_at, created_at, updated_at
		FROM savings_pocket_members
		WHERE savings_goal_id = $1
		ORDER BY status, created_at DESC
	`, goalID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	members := []PocketMember{}
	for rows.Next() {
		member, err := scanPocketMember(rows)
		if err != nil {
			return nil, err
		}
		members = append(members, member)
	}
	return members, rows.Err()
}

func (r *Repository) AddMember(ctx context.Context, params AddMemberParams) (PocketMember, error) {
	if err := r.ensureGoalOwner(ctx, params.UserID, params.GoalID); err != nil {
		return PocketMember{}, err
	}
	params.MemberUserID = strings.TrimSpace(params.MemberUserID)
	if err := domain.ValidateUUID("member_user_id", params.MemberUserID); err != nil {
		return PocketMember{}, err
	}
	params.Role = strings.ToLower(strings.TrimSpace(params.Role))
	if params.Role == "" {
		params.Role = "viewer"
	}
	switch params.Role {
	case "co_owner", "contributor", "viewer":
	default:
		return PocketMember{}, fmt.Errorf("%w: invalid member role", domain.ErrValidation)
	}
	params.Status = strings.ToLower(strings.TrimSpace(params.Status))
	if params.Status == "" {
		params.Status = "active"
	}
	if params.Status != "invited" && params.Status != "active" {
		return PocketMember{}, fmt.Errorf("%w: invalid member status", domain.ErrValidation)
	}
	row := r.db.QueryRow(ctx, `
		INSERT INTO savings_pocket_members (
			savings_goal_id, owner_user_id, member_user_id, role, status, accepted_at
		)
		VALUES ($1, $2, $3, $4, $5, CASE WHEN $5 = 'active' THEN now() ELSE NULL END)
		ON CONFLICT (savings_goal_id, member_user_id) DO UPDATE
		SET role = EXCLUDED.role,
			status = EXCLUDED.status,
			accepted_at = CASE WHEN EXCLUDED.status = 'active' THEN COALESCE(savings_pocket_members.accepted_at, now()) ELSE savings_pocket_members.accepted_at END,
			revoked_at = NULL
		RETURNING id::text, savings_goal_id::text, owner_user_id::text, member_user_id::text,
			role, status, invited_at, accepted_at, revoked_at, created_at, updated_at
	`, params.GoalID, params.UserID, params.MemberUserID, params.Role, params.Status)
	member, err := scanPocketMember(row)
	if err != nil {
		return PocketMember{}, err
	}
	if err := r.markShared(ctx, params.UserID, params.GoalID); err != nil {
		return PocketMember{}, err
	}
	return member, nil
}

func (r *Repository) RemoveMember(ctx context.Context, userID, goalID, memberID string) (PocketMember, error) {
	if err := r.ensureGoalOwner(ctx, userID, goalID); err != nil {
		return PocketMember{}, err
	}
	if err := domain.ValidateUUID("member_id", memberID); err != nil {
		return PocketMember{}, err
	}
	row := r.db.QueryRow(ctx, `
		UPDATE savings_pocket_members
		SET status = 'revoked',
			revoked_at = now()
		WHERE id = $1 AND savings_goal_id = $2 AND owner_user_id = $3
		RETURNING id::text, savings_goal_id::text, owner_user_id::text, member_user_id::text,
			role, status, invited_at, accepted_at, revoked_at, created_at, updated_at
	`, memberID, goalID, userID)
	member, err := scanPocketMember(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return PocketMember{}, domain.ErrNotFound
	}
	if err != nil {
		return PocketMember{}, err
	}
	if err := r.refreshSharedVisibility(ctx, userID, goalID); err != nil {
		return PocketMember{}, err
	}
	return member, nil
}

func (r *Repository) LinkProviderPocket(ctx context.Context, params ProviderLinkParams) (domain.SavingsGoal, error) {
	if err := r.ensureGoalOwner(ctx, params.UserID, params.GoalID); err != nil {
		return domain.SavingsGoal{}, err
	}
	params.ProviderName = strings.TrimSpace(params.ProviderName)
	params.ExternalPocketID = strings.TrimSpace(params.ExternalPocketID)
	params.ProviderSyncStatus = strings.ToLower(strings.TrimSpace(params.ProviderSyncStatus))
	params.ProviderSyncError = strings.TrimSpace(params.ProviderSyncError)
	if params.ProviderSyncStatus == "" {
		params.ProviderSyncStatus = "pending"
	}
	switch params.ProviderSyncStatus {
	case "pending", "synced", "failed", "disabled", "not_configured":
	default:
		return domain.SavingsGoal{}, fmt.Errorf("%w: invalid provider_sync_status", domain.ErrValidation)
	}
	if params.ProviderSyncStatus != "not_configured" && params.ProviderName == "" {
		return domain.SavingsGoal{}, fmt.Errorf("%w: provider_name is required", domain.ErrValidation)
	}
	if params.ProviderSyncStatus == "synced" && params.ExternalPocketID == "" {
		return domain.SavingsGoal{}, fmt.Errorf("%w: external_pocket_id is required when synced", domain.ErrValidation)
	}
	if params.ProviderSyncStatus == "not_configured" {
		params.ProviderName = ""
		params.ExternalPocketID = ""
		params.ProviderSyncError = ""
	}
	row := r.db.QueryRow(ctx, `
		UPDATE savings_goals
		SET provider_name = $3,
			external_pocket_id = $4,
			provider_sync_status = $5,
			provider_sync_error = $6,
			provider_synced_at = CASE WHEN $5 = 'synced' THEN now() ELSE provider_synced_at END
		WHERE id = $1 AND user_id = $2
		RETURNING `+goalColumns,
		params.GoalID, params.UserID, params.ProviderName, params.ExternalPocketID,
		params.ProviderSyncStatus, params.ProviderSyncError)
	return scanGoal(row)
}

func (r *Repository) ensureGoalOwner(ctx context.Context, userID, goalID string) error {
	var exists bool
	err := r.db.QueryRow(ctx, `
		SELECT EXISTS (
			SELECT 1 FROM savings_goals WHERE id = $1 AND user_id = $2
		)
	`, goalID, userID).Scan(&exists)
	if err != nil {
		return err
	}
	if !exists {
		return domain.ErrNotFound
	}
	return nil
}

func (r *Repository) ensureGoalAccessible(ctx context.Context, userID, goalID string) error {
	var exists bool
	err := r.db.QueryRow(ctx, `
		SELECT EXISTS (
			SELECT 1
			FROM savings_goals sg
			WHERE sg.id = $1
				AND (
					sg.user_id = $2
					OR EXISTS (
						SELECT 1
						FROM savings_pocket_members spm
						WHERE spm.savings_goal_id = sg.id
							AND spm.member_user_id = $2
							AND spm.status = 'active'
					)
				)
		)
	`, goalID, userID).Scan(&exists)
	if err != nil {
		return err
	}
	if !exists {
		return domain.ErrNotFound
	}
	return nil
}

func (r *Repository) markShared(ctx context.Context, userID, goalID string) error {
	_, err := r.db.Exec(ctx, `
		UPDATE savings_goals SET visibility = 'shared'
		WHERE id = $1 AND user_id = $2
	`, goalID, userID)
	return err
}

func (r *Repository) refreshSharedVisibility(ctx context.Context, userID, goalID string) error {
	_, err := r.db.Exec(ctx, `
		UPDATE savings_goals
		SET visibility = CASE
			WHEN EXISTS (
				SELECT 1
				FROM savings_pocket_members
				WHERE savings_goal_id = $1 AND status = 'active'
			) THEN 'shared'
			ELSE 'private'
		END
		WHERE id = $1 AND user_id = $2
	`, goalID, userID)
	return err
}

func scanCategory(row scanner) (PocketCategory, error) {
	var category PocketCategory
	err := row.Scan(&category.ID, &category.UserID, &category.Name, &category.Icon, &category.Color, &category.Status, &category.CreatedAt, &category.UpdatedAt)
	return category, err
}

func scanRoundUpRule(row scanner) (RoundUpRule, error) {
	var rule RoundUpRule
	var cap sql.NullInt64
	var lastApplied sql.NullTime
	err := row.Scan(
		&rule.ID,
		&rule.SavingsGoalID,
		&rule.UserID,
		&rule.Source,
		&rule.RoundToIncrementCents,
		&rule.Multiplier,
		&rule.MinimumRoundUpCents,
		&rule.MaximumRoundUpCents,
		&cap,
		&rule.Status,
		&lastApplied,
		&rule.CreatedAt,
		&rule.UpdatedAt,
	)
	if cap.Valid {
		rule.MonthlyCapCents = &cap.Int64
	}
	if lastApplied.Valid {
		rule.LastAppliedAt = &lastApplied.Time
	}
	return rule, err
}

func scanRecurringRule(row scanner) (RecurringRule, error) {
	var rule RecurringRule
	var dayOfMonth, dayOfWeek sql.NullInt32
	var lastRun sql.NullTime
	err := row.Scan(
		&rule.ID,
		&rule.SavingsGoalID,
		&rule.UserID,
		&rule.AmountCents,
		&rule.Currency,
		&rule.Frequency,
		&rule.IntervalCount,
		&dayOfMonth,
		&dayOfWeek,
		&rule.NextRunOn,
		&rule.Description,
		&rule.Status,
		&lastRun,
		&rule.CreatedAt,
		&rule.UpdatedAt,
	)
	if dayOfMonth.Valid {
		value := int(dayOfMonth.Int32)
		rule.DayOfMonth = &value
	}
	if dayOfWeek.Valid {
		value := int(dayOfWeek.Int32)
		rule.DayOfWeek = &value
	}
	if lastRun.Valid {
		rule.LastRunAt = &lastRun.Time
	}
	return rule, err
}

func scanPocketMember(row scanner) (PocketMember, error) {
	var member PocketMember
	var acceptedAt, revokedAt sql.NullTime
	err := row.Scan(
		&member.ID,
		&member.SavingsGoalID,
		&member.OwnerUserID,
		&member.MemberUserID,
		&member.Role,
		&member.Status,
		&member.InvitedAt,
		&acceptedAt,
		&revokedAt,
		&member.CreatedAt,
		&member.UpdatedAt,
	)
	if acceptedAt.Valid {
		member.AcceptedAt = &acceptedAt.Time
	}
	if revokedAt.Valid {
		member.RevokedAt = &revokedAt.Time
	}
	return member, err
}

func normalizePocketName(raw string) (string, error) {
	value := strings.TrimSpace(raw)
	if value == "" {
		return "", fmt.Errorf("%w: name is required", domain.ErrValidation)
	}
	if len(value) > 60 {
		return "", fmt.Errorf("%w: name must be 60 characters or fewer", domain.ErrValidation)
	}
	return value, nil
}

func normalizePocketIcon(raw string) (string, error) {
	value := strings.ToLower(strings.TrimSpace(raw))
	if value == "" {
		return "piggy-bank", nil
	}
	if len(value) > 40 {
		return "", fmt.Errorf("%w: icon must be 40 characters or fewer", domain.ErrValidation)
	}
	for _, char := range value {
		if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' && char != '_' {
			return "", fmt.Errorf("%w: icon may only contain lowercase letters, numbers, dash or underscore", domain.ErrValidation)
		}
	}
	return value, nil
}

func normalizePocketColor(raw string) (string, error) {
	value := strings.ToUpper(strings.TrimSpace(raw))
	if value == "" {
		return "#2563EB", nil
	}
	if len(value) != 7 || value[0] != '#' {
		return "", fmt.Errorf("%w: color must be a hex value like #2563EB", domain.ErrValidation)
	}
	for _, char := range value[1:] {
		if (char < '0' || char > '9') && (char < 'A' || char > 'F') {
			return "", fmt.Errorf("%w: color must be a hex value like #2563EB", domain.ErrValidation)
		}
	}
	return value, nil
}

func normalizeRoundUpRuleParams(params *UpsertRoundUpRuleParams) error {
	params.Source = strings.ToLower(strings.TrimSpace(params.Source))
	if params.Source == "" {
		params.Source = "card_payments"
	}
	switch params.Source {
	case "card_payments", "account_transactions", "all_money_out":
	default:
		return fmt.Errorf("%w: invalid roundup source", domain.ErrValidation)
	}
	if params.RoundToIncrementCents <= 0 {
		params.RoundToIncrementCents = 100
	}
	if params.Multiplier == "" {
		params.Multiplier = "1"
	}
	if params.MinimumRoundUpCents <= 0 {
		params.MinimumRoundUpCents = 1
	}
	if params.MaximumRoundUpCents <= 0 {
		params.MaximumRoundUpCents = 500
	}
	if params.MaximumRoundUpCents < params.MinimumRoundUpCents {
		return fmt.Errorf("%w: maximum_roundup_cents must be greater than minimum_roundup_cents", domain.ErrValidation)
	}
	if params.MonthlyCapCents != nil && *params.MonthlyCapCents <= 0 {
		return fmt.Errorf("%w: monthly_cap_cents must be positive", domain.ErrValidation)
	}
	params.Status = strings.ToLower(strings.TrimSpace(params.Status))
	if params.Status == "" {
		params.Status = "active"
	}
	switch params.Status {
	case "active", "paused", "disabled":
		return nil
	default:
		return fmt.Errorf("%w: invalid roundup rule status", domain.ErrValidation)
	}
}

func normalizeRecurringRuleParams(params *CreateRecurringRuleParams) error {
	if err := domain.ValidateAmount(params.AmountCents); err != nil {
		return err
	}
	params.Frequency = strings.ToLower(strings.TrimSpace(params.Frequency))
	if params.IntervalCount <= 0 {
		params.IntervalCount = 1
	}
	if params.IntervalCount > 12 {
		return fmt.Errorf("%w: interval_count must be between 1 and 12", domain.ErrValidation)
	}
	switch params.Frequency {
	case "monthly":
		if params.DayOfMonth == nil || *params.DayOfMonth < 1 || *params.DayOfMonth > 28 || params.DayOfWeek != nil {
			return fmt.Errorf("%w: monthly rules require day_of_month between 1 and 28", domain.ErrValidation)
		}
	case "weekly":
		if params.DayOfWeek == nil || *params.DayOfWeek < 1 || *params.DayOfWeek > 7 || params.DayOfMonth != nil {
			return fmt.Errorf("%w: weekly rules require day_of_week between 1 and 7", domain.ErrValidation)
		}
	default:
		return fmt.Errorf("%w: frequency must be weekly or monthly", domain.ErrValidation)
	}
	if _, err := time.Parse("2006-01-02", strings.TrimSpace(params.NextRunOn)); err != nil {
		return fmt.Errorf("%w: next_run_on must use YYYY-MM-DD", domain.ErrValidation)
	}
	params.Description = strings.TrimSpace(params.Description)
	if len(params.Description) > 180 {
		return fmt.Errorf("%w: description must be 180 characters or fewer", domain.ErrValidation)
	}
	params.Status = strings.ToLower(strings.TrimSpace(params.Status))
	if params.Status == "" {
		params.Status = "active"
	}
	switch params.Status {
	case "active", "paused", "canceled":
		return nil
	default:
		return fmt.Errorf("%w: invalid recurring rule status", domain.ErrValidation)
	}
}
