package repository

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

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/warmbly/warmbly/internal/models"
	"github.com/warmbly/warmbly/internal/utils/paging"
)

// ConnectionWrite is the full upsert payload for an integration connection.
// Encrypted secret fields use empty-string / nil "leave unchanged" semantics
// on conflict so partial writes (e.g. rotating an inbound secret) don't wipe
// the rest of the connection.
type ConnectionWrite struct {
	Conn            *models.IntegrationConnection
	ConfigEncrypted []byte // sealed JSON config (api-key / webhook providers)
	AccessTokenEnc  string // base64 ciphertext; "" = leave unchanged
	RefreshTokenEnc string // base64 ciphertext; "" = leave unchanged
	InboundSecret   string // "" = leave unchanged
}

// ConnectionSecrets carries the encrypted secret material for a connection so
// the service can decrypt it with the connecting user's DEK. Never serialized
// to the API.
type ConnectionSecrets struct {
	Conn            models.IntegrationConnection
	ConfigEncrypted []byte
	AccessTokenEnc  string
	RefreshTokenEnc string
}

// DispatchTarget pairs an event subscription with the connection (and its
// secrets) needed to execute the action.
type DispatchTarget struct {
	Subscription models.IntegrationEventSubscription
	Secrets      ConnectionSecrets
}

// IntegrationRepository owns persistence for third-party integrations:
// connections + their encrypted secrets, OAuth handshake state, event-driven
// action subscriptions, sync-run history, and meeting bookings.
type IntegrationRepository interface {
	// Connections
	UpsertConnection(ctx context.Context, w *ConnectionWrite) error
	ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error)
	GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error)
	GetConnectionByID(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error)
	GetConnectionSecrets(ctx context.Context, id uuid.UUID) (*ConnectionSecrets, error)
	GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error)
	DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error
	MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error
	UpdateConnectionTokens(ctx context.Context, id uuid.UUID, accessEnc, refreshEnc string, expiresAt *time.Time, scopes []string) error
	SetConnectionStatus(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, health models.IntegrationHealth, detail string) error

	// OAuth handshake state
	CreateOAuthState(ctx context.Context, st *models.IntegrationOAuthState) error
	TakeOAuthState(ctx context.Context, state string) (*models.IntegrationOAuthState, error)

	// Event subscriptions
	CreateEventSubscription(ctx context.Context, sub *models.IntegrationEventSubscription) error
	ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error)
	DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error
	MatchingDispatchTargets(ctx context.Context, orgID uuid.UUID, eventType string) ([]DispatchTarget, error)

	// Automations (named groups of subscriptions sharing a trigger). Steps are
	// persisted as automation-tagged event-subscription rows, so the dispatcher
	// runs them unchanged.
	CreateAutomation(ctx context.Context, a *models.Automation) error
	ListAutomations(ctx context.Context, orgID uuid.UUID) ([]models.Automation, error)
	ListEnabledAutomationsForEvent(ctx context.Context, orgID uuid.UUID, eventType string) ([]models.Automation, error)
	GetAutomation(ctx context.Context, orgID, id uuid.UUID) (*models.Automation, error)
	// GetAutomationByInboundToken resolves the automation an inbound-webhook URL
	// token points at, across orgs (the token is the credential). nil = no match.
	GetAutomationByInboundToken(ctx context.Context, token string) (*models.Automation, error)
	UpdateAutomation(ctx context.Context, a *models.Automation) error
	// UpdateAutomationLayout merges only the x/y of the named nodes into the
	// graph, atomically and without touching edges, node config, or updated_at —
	// so a position move (continuously written as teammates drag cards) never
	// reads as a content change. Unknown ids are ignored.
	UpdateAutomationLayout(ctx context.Context, orgID, id uuid.UUID, positions []models.AutomationNodePosition) error
	DeleteAutomation(ctx context.Context, orgID, id uuid.UUID) error
	// CampaignsUsingAutomation returns the names of campaigns whose sequence has a
	// "run_automation" step pointing at this automation (so deletion can refuse to
	// orphan those steps). Capped; order is stable for a readable message.
	CampaignsUsingAutomation(ctx context.Context, orgID, automationID uuid.UUID) ([]string, error)
	// BumpAutomationAICreditFailures atomically increments the consecutive
	// out-of-credits counter for an AI node and returns the new value.
	BumpAutomationAICreditFailures(ctx context.Context, id uuid.UUID) (int, error)
	// ResetAutomationAICreditFailures clears the counter (a successful AI node ran).
	ResetAutomationAICreditFailures(ctx context.Context, id uuid.UUID) error
	// DisableAutomationForCredits pauses a flow (enabled=false) and clears the
	// counter after too many consecutive AI-credit failures.
	DisableAutomationForCredits(ctx context.Context, id uuid.UUID) error

	// Field mappings (Warmbly field -> provider field). ListFieldMappings returns
	// every mapping for a connection; ReplaceConnectionFieldMappings swaps the
	// connection-default (unscoped) push map for one object atomically.
	ListFieldMappings(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationFieldMapping, error)
	ReplaceConnectionFieldMappings(ctx context.Context, orgID, connID uuid.UUID, object string, mappings []models.IntegrationFieldMapping) error
	UpdateConnectionConfig(ctx context.Context, orgID, connID uuid.UUID, configCapabilities []byte, syncDirection string) error

	// Sync runs
	CreateSyncRun(ctx context.Context, run *models.IntegrationSyncRun) error
	FinishSyncRun(ctx context.Context, id uuid.UUID, status, detail string, records int) error
	ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error)

	CreateAutomationRun(ctx context.Context, run *models.AutomationRun) error
	FinishAutomationRun(ctx context.Context, id uuid.UUID, status, errorDetail string, nodeResults []models.AutomationNodeResult) error
	ListAutomationRuns(ctx context.Context, orgID, automationID uuid.UUID, limit int) ([]models.AutomationRun, error)

	// Bookings
	UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error
	ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error)
	SearchMeetingBookings(ctx context.Context, orgID uuid.UUID, f models.MeetingBookingFilter) (*models.MeetingBookingPage, error)
	MeetingBookingSummary(ctx context.Context, orgID uuid.UUID) (*models.MeetingBookingSummary, error)
	GetMeetingBooking(ctx context.Context, orgID, id uuid.UUID) (*models.MeetingBooking, error)
	DeleteMeetingBooking(ctx context.Context, orgID, id uuid.UUID) error
}

type integrationRepository struct {
	db *pgxpool.Pool
}

func NewIntegrationRepository(db *pgxpool.Pool) IntegrationRepository {
	return &integrationRepository{db: db}
}

// connectionPublicCols is the non-secret projection. Nullable TEXT columns are
// COALESCE'd so they scan into plain string fields.
const connectionPublicCols = `
	id, organization_id, provider, label, status, auth_method, display_fields,
	connected_by_user_id, COALESCE(external_account_id, ''), COALESCE(external_account_name, ''),
	granted_scopes, token_expires_at, health, health_detail, health_checked_at,
	last_synced_at, last_error, last_error_at, created_at, updated_at,
	COALESCE(config_capabilities, '{}'::jsonb), COALESCE(sync_direction, 'push')`

func (r *integrationRepository) UpsertConnection(ctx context.Context, w *ConnectionWrite) error {
	c := w.Conn
	if c.ID == uuid.Nil {
		c.ID = uuid.New()
	}
	now := time.Now().UTC()
	c.CreatedAt = now
	c.UpdatedAt = now

	display := c.DisplayFields
	if len(display) == 0 {
		display = json.RawMessage("{}")
	}
	if c.AuthMethod == "" {
		c.AuthMethod = string(models.IntegrationAuthAPIKey)
	}
	if c.Health == "" {
		c.Health = string(models.IntegrationHealthUnknown)
	}

	_, err := r.db.Exec(ctx, `
		INSERT INTO integration_connections (
			id, organization_id, provider, label, status, auth_method,
			inbound_secret, config_encrypted, display_fields,
			connected_by_user_id, access_token_encrypted, refresh_token_encrypted,
			token_expires_at, granted_scopes, external_account_id, external_account_name,
			health, health_detail, health_checked_at,
			created_at, updated_at
		) VALUES (
			$1, $2, $3, $4, $5, $6,
			$7, $8, $9,
			$10, $11, $12,
			$13, $14, $15, $16,
			$17, $18, $19,
			$20, $20
		)
		ON CONFLICT (organization_id, provider, label) DO UPDATE SET
			status = EXCLUDED.status,
			auth_method = EXCLUDED.auth_method,
			inbound_secret = COALESCE(EXCLUDED.inbound_secret, integration_connections.inbound_secret),
			config_encrypted = COALESCE(EXCLUDED.config_encrypted, integration_connections.config_encrypted),
			display_fields = EXCLUDED.display_fields,
			connected_by_user_id = COALESCE(EXCLUDED.connected_by_user_id, integration_connections.connected_by_user_id),
			access_token_encrypted = COALESCE(EXCLUDED.access_token_encrypted, integration_connections.access_token_encrypted),
			refresh_token_encrypted = COALESCE(EXCLUDED.refresh_token_encrypted, integration_connections.refresh_token_encrypted),
			token_expires_at = COALESCE(EXCLUDED.token_expires_at, integration_connections.token_expires_at),
			granted_scopes = EXCLUDED.granted_scopes,
			external_account_id = COALESCE(EXCLUDED.external_account_id, integration_connections.external_account_id),
			external_account_name = COALESCE(EXCLUDED.external_account_name, integration_connections.external_account_name),
			health = EXCLUDED.health,
			health_detail = EXCLUDED.health_detail,
			health_checked_at = EXCLUDED.health_checked_at,
			updated_at = EXCLUDED.updated_at
	`,
		c.ID, c.OrganizationID, string(c.Provider), c.Label, string(c.Status), c.AuthMethod,
		nullIfEmptyStr(w.InboundSecret), nullIfEmptyBytes(w.ConfigEncrypted), display,
		c.ConnectedByUserID, nullIfEmptyStr(w.AccessTokenEnc), nullIfEmptyStr(w.RefreshTokenEnc),
		c.TokenExpiresAt, normalizeScopes(c.GrantedScopes), nullIfEmptyStr(c.ExternalAccountID), nullIfEmptyStr(c.ExternalAccountName),
		c.Health, c.HealthDetail, c.HealthCheckedAt,
		now,
	)
	return err
}

func nullIfEmptyStr(s string) any {
	if s == "" {
		return nil
	}
	return s
}

func nullIfEmptyBytes(b []byte) any {
	if len(b) == 0 {
		return nil
	}
	return b
}

func normalizeScopes(s []string) []string {
	if s == nil {
		return []string{}
	}
	return s
}

func (r *integrationRepository) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) {
	rows, err := r.db.Query(ctx, `SELECT `+connectionPublicCols+`
		FROM integration_connections WHERE organization_id = $1 ORDER BY created_at DESC`, orgID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	out := []models.IntegrationConnection{}
	for rows.Next() {
		var c models.IntegrationConnection
		if err := scanConnectionInto(rows, &c); err != nil {
			return nil, err
		}
		out = append(out, c)
	}
	return out, rows.Err()
}

func (r *integrationRepository) GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error) {
	row := r.db.QueryRow(ctx, `SELECT `+connectionPublicCols+`
		FROM integration_connections WHERE organization_id = $1 AND provider = $2 AND label = $3`,
		orgID, string(provider), label)
	var c models.IntegrationConnection
	if err := scanConnectionInto(row, &c); err != nil {
		if isNoRows(err) {
			return nil, nil
		}
		return nil, err
	}
	return &c, nil
}

func (r *integrationRepository) GetConnectionByID(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error) {
	row := r.db.QueryRow(ctx, `SELECT `+connectionPublicCols+`
		FROM integration_connections WHERE organization_id = $1 AND id = $2`, orgID, id)
	var c models.IntegrationConnection
	if err := scanConnectionInto(row, &c); err != nil {
		if isNoRows(err) {
			return nil, nil
		}
		return nil, err
	}
	return &c, nil
}

func (r *integrationRepository) GetConnectionSecrets(ctx context.Context, id uuid.UUID) (*ConnectionSecrets, error) {
	row := r.db.QueryRow(ctx, `
		SELECT `+connectionPublicCols+`,
		       config_encrypted, COALESCE(access_token_encrypted, ''), COALESCE(refresh_token_encrypted, '')
		FROM integration_connections WHERE id = $1`, id)
	var sec ConnectionSecrets
	if err := scanConnectionSecretsInto(row, &sec); err != nil {
		if isNoRows(err) {
			return nil, nil
		}
		return nil, err
	}
	return &sec, nil
}

func (r *integrationRepository) GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error) {
	if secret == "" {
		return nil, nil
	}
	row := r.db.QueryRow(ctx, `SELECT `+connectionPublicCols+`
		FROM integration_connections WHERE provider = $1 AND inbound_secret = $2 LIMIT 1`,
		string(provider), secret)
	var c models.IntegrationConnection
	if err := scanConnectionInto(row, &c); err != nil {
		if isNoRows(err) {
			return nil, nil
		}
		return nil, err
	}
	return &c, nil
}

func (r *integrationRepository) DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error {
	_, err := r.db.Exec(ctx,
		`DELETE FROM integration_connections WHERE organization_id = $1 AND id = $2`, orgID, id)
	return err
}

func (r *integrationRepository) MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error {
	now := time.Now().UTC()
	if len(displayFields) == 0 {
		displayFields = json.RawMessage("{}")
	}
	if errMsg == "" {
		_, err := r.db.Exec(ctx, `
			UPDATE integration_connections
			SET status = $1, display_fields = $2, last_synced_at = $3,
			    health = 'healthy', health_detail = NULL, health_checked_at = $3,
			    last_error = NULL, last_error_at = NULL, updated_at = $3
			WHERE id = $4`, string(status), displayFields, now, id)
		return err
	}
	_, err := r.db.Exec(ctx, `
		UPDATE integration_connections
		SET status = $1, display_fields = $2,
		    health = 'degraded', health_detail = $3, health_checked_at = $4,
		    last_error = $3, last_error_at = $4, updated_at = $4
		WHERE id = $5`, string(status), displayFields, errMsg, now, id)
	return err
}

func (r *integrationRepository) UpdateConnectionTokens(ctx context.Context, id uuid.UUID, accessEnc, refreshEnc string, expiresAt *time.Time, scopes []string) error {
	now := time.Now().UTC()
	_, err := r.db.Exec(ctx, `
		UPDATE integration_connections
		SET access_token_encrypted = COALESCE($1, access_token_encrypted),
		    refresh_token_encrypted = COALESCE($2, refresh_token_encrypted),
		    token_expires_at = $3,
		    granted_scopes = CASE WHEN cardinality($4::text[]) > 0 THEN $4 ELSE granted_scopes END,
		    updated_at = $5
		WHERE id = $6`,
		nullIfEmptyStr(accessEnc), nullIfEmptyStr(refreshEnc), expiresAt, normalizeScopes(scopes), now, id)
	return err
}

func (r *integrationRepository) SetConnectionStatus(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, health models.IntegrationHealth, detail string) error {
	now := time.Now().UTC()
	_, err := r.db.Exec(ctx, `
		UPDATE integration_connections
		SET status = $1, health = $2, health_detail = NULLIF($3, ''), health_checked_at = $4, updated_at = $4
		WHERE id = $5`, string(status), string(health), detail, now, id)
	return err
}

// --- OAuth state ------------------------------------------------------------

func (r *integrationRepository) CreateOAuthState(ctx context.Context, st *models.IntegrationOAuthState) error {
	if st.ID == uuid.Nil {
		st.ID = uuid.New()
	}
	st.CreatedAt = time.Now().UTC()
	_, err := r.db.Exec(ctx, `
		INSERT INTO integration_oauth_states (
			id, organization_id, user_id, provider, state, code_verifier,
			label, requested_scopes, expires_at, created_at
		) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
		st.ID, st.OrganizationID, st.UserID, string(st.Provider), st.State, st.CodeVerifier,
		st.Label, normalizeScopes(st.RequestedScopes), st.ExpiresAt, st.CreatedAt)
	return err
}

// TakeOAuthState atomically consumes a state: it returns the row only if it is
// unused and unexpired, marking it used in the same statement so a replayed
// callback can't be exchanged twice.
func (r *integrationRepository) TakeOAuthState(ctx context.Context, state string) (*models.IntegrationOAuthState, error) {
	row := r.db.QueryRow(ctx, `
		UPDATE integration_oauth_states
		SET used_at = NOW()
		WHERE state = $1 AND used_at IS NULL AND expires_at > NOW()
		RETURNING id, organization_id, user_id, provider, state, code_verifier,
		          label, requested_scopes, used_at, expires_at, created_at`, state)
	var st models.IntegrationOAuthState
	var provider string
	err := row.Scan(&st.ID, &st.OrganizationID, &st.UserID, &provider, &st.State, &st.CodeVerifier,
		&st.Label, &st.RequestedScopes, &st.UsedAt, &st.ExpiresAt, &st.CreatedAt)
	if isNoRows(err) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	st.Provider = models.IntegrationProvider(provider)
	return &st, nil
}

// --- Event subscriptions ----------------------------------------------------

func (r *integrationRepository) CreateEventSubscription(ctx context.Context, sub *models.IntegrationEventSubscription) error {
	if sub.ID == uuid.Nil {
		sub.ID = uuid.New()
	}
	now := time.Now().UTC()
	sub.CreatedAt = now
	sub.UpdatedAt = now
	cfg := sub.Config
	if len(cfg) == 0 {
		cfg = json.RawMessage("{}")
	}
	useCase := sub.UseCase
	if useCase == "" {
		useCase = "custom"
	}
	// Plain insert (no ON CONFLICT): the framework allows several differently
	// filtered automations per (connection, event, action), so each is a row.
	_, err := r.db.Exec(ctx, `
		INSERT INTO integration_event_subscriptions (
			id, connection_id, organization_id, event_type, action, config, enabled, use_case, automation_id, created_at, updated_at
		) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`,
		sub.ID, sub.ConnectionID, sub.OrganizationID, sub.EventType, string(sub.Action), cfg, sub.Enabled, useCase, sub.AutomationID, now)
	sub.UseCase = useCase
	return err
}

func (r *integrationRepository) ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error) {
	rows, err := r.db.Query(ctx, `
		SELECT id, connection_id, organization_id, event_type, action, config, enabled, created_at, updated_at, use_case
		FROM integration_event_subscriptions
		WHERE organization_id = $1 AND connection_id = $2 ORDER BY created_at DESC`, orgID, connID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.IntegrationEventSubscription{}
	for rows.Next() {
		s, err := scanEventSubscription(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, *s)
	}
	return out, rows.Err()
}

func (r *integrationRepository) DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error {
	_, err := r.db.Exec(ctx,
		`DELETE FROM integration_event_subscriptions WHERE organization_id = $1 AND id = $2`, orgID, id)
	return err
}

// --- Automations ------------------------------------------------------------

// insertAutomationSteps writes one event-subscription row per step, tagged with
// the automation id + its trigger event + enabled flag (so the dispatcher runs
// them). Caller has already merged the automation filter into each step Config.
// automationCols is the shared projection for an automation row.
const automationCols = `id, organization_id, name, enabled, trigger_event, filter, graph, created_at, updated_at, COALESCE(inbound_token, '')`

func scanAutomation(row pgx.Row, a *models.Automation) error {
	var graph []byte
	if err := row.Scan(&a.ID, &a.OrganizationID, &a.Name, &a.Enabled, &a.TriggerEvent, &a.Filter, &graph, &a.CreatedAt, &a.UpdatedAt, &a.InboundToken); err != nil {
		return err
	}
	a.Graph = models.AutomationGraph{Nodes: []models.AutomationNode{}, Edges: []models.AutomationEdge{}}
	if len(graph) > 0 {
		_ = json.Unmarshal(graph, &a.Graph)
	}
	if a.Graph.Nodes == nil {
		a.Graph.Nodes = []models.AutomationNode{}
	}
	if a.Graph.Edges == nil {
		a.Graph.Edges = []models.AutomationEdge{}
	}
	return nil
}

func (r *integrationRepository) CreateAutomation(ctx context.Context, a *models.Automation) error {
	if a.ID == uuid.Nil {
		a.ID = uuid.New()
	}
	now := time.Now().UTC()
	a.CreatedAt = now
	a.UpdatedAt = now
	filter := a.Filter
	if len(filter) == 0 {
		filter = json.RawMessage("{}")
	}
	graph, _ := json.Marshal(a.Graph)
	_, err := r.db.Exec(ctx, `
		INSERT INTO automations (id, organization_id, name, enabled, trigger_event, filter, graph, created_at, updated_at, inbound_token)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, NULLIF($9, ''))`,
		a.ID, a.OrganizationID, a.Name, a.Enabled, a.TriggerEvent, filter, graph, now, a.InboundToken)
	return err
}

func (r *integrationRepository) UpdateAutomation(ctx context.Context, a *models.Automation) error {
	now := time.Now().UTC()
	a.UpdatedAt = now
	filter := a.Filter
	if len(filter) == 0 {
		filter = json.RawMessage("{}")
	}
	graph, _ := json.Marshal(a.Graph)
	tag, err := r.db.Exec(ctx, `
		UPDATE automations SET name = $3, enabled = $4, trigger_event = $5, filter = $6, graph = $7, updated_at = $8, inbound_token = NULLIF($9, '')
		WHERE id = $1 AND organization_id = $2`,
		a.ID, a.OrganizationID, a.Name, a.Enabled, a.TriggerEvent, filter, graph, now, a.InboundToken)
	if err != nil {
		return err
	}
	if tag.RowsAffected() == 0 {
		return errors.New("automation not found")
	}
	return nil
}

// UpdateAutomationLayout rewrites only node coordinates. It is a single atomic
// UPDATE that walks the existing graph->'nodes' array and, for each node whose
// id appears in the supplied position map, replaces its x/y in place — leaving
// edges, action config, order, and updated_at untouched. Doing it in one
// statement (rather than read-modify-write) avoids clobbering a concurrent full
// graph save with stale logic.
func (r *integrationRepository) UpdateAutomationLayout(ctx context.Context, orgID, id uuid.UUID, positions []models.AutomationNodePosition) error {
	pos := make(map[string]map[string]float64, len(positions))
	for _, p := range positions {
		if p.ID == "" {
			continue
		}
		pos[p.ID] = map[string]float64{"x": p.X, "y": p.Y}
	}
	if len(pos) == 0 {
		return nil
	}
	posJSON, err := json.Marshal(pos)
	if err != nil {
		return err
	}
	tag, err := r.db.Exec(ctx, `
		UPDATE automations
		SET graph = jsonb_set(graph, '{nodes}', COALESCE((
			SELECT jsonb_agg(
				CASE WHEN $3::jsonb ? (e.value->>'id')
					THEN e.value || jsonb_build_object(
						'x', ($3::jsonb -> (e.value->>'id') ->> 'x')::float8,
						'y', ($3::jsonb -> (e.value->>'id') ->> 'y')::float8)
					ELSE e.value END
				ORDER BY e.ord)
			-- Guard the scalar/null case: an automation saved with an empty graph
			-- stores "nodes": null, and jsonb_array_elements errors on a non-array.
			FROM jsonb_array_elements(
				CASE WHEN jsonb_typeof(graph->'nodes') = 'array' THEN graph->'nodes' ELSE '[]'::jsonb END
			) WITH ORDINALITY AS e(value, ord)
		), '[]'::jsonb))
		WHERE id = $1 AND organization_id = $2`,
		id, orgID, posJSON)
	if err != nil {
		return err
	}
	if tag.RowsAffected() == 0 {
		return errors.New("automation not found")
	}
	return nil
}

func (r *integrationRepository) DeleteAutomation(ctx context.Context, orgID, id uuid.UUID) error {
	_, err := r.db.Exec(ctx, `DELETE FROM automations WHERE id = $1 AND organization_id = $2`, id, orgID)
	return err
}

func (r *integrationRepository) CampaignsUsingAutomation(ctx context.Context, orgID, automationID uuid.UUID) ([]string, error) {
	rows, err := r.db.Query(ctx, `
		SELECT DISTINCT c.name
		FROM sequences s
		JOIN campaigns c ON c.id = s.campaign_id
		WHERE c.organization_id = $1
		  AND s.action->>'type' = 'run_automation'
		  AND s.action->>'automation_id' = $2::text
		ORDER BY c.name
		LIMIT 20`, orgID, automationID.String())
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var names []string
	for rows.Next() {
		var n string
		if err := rows.Scan(&n); err != nil {
			return nil, err
		}
		names = append(names, n)
	}
	return names, rows.Err()
}

// BumpAutomationAICreditFailures increments and returns the consecutive
// out-of-credits counter, so the caller can auto-pause the flow at the threshold.
func (r *integrationRepository) BumpAutomationAICreditFailures(ctx context.Context, id uuid.UUID) (int, error) {
	var n int
	err := r.db.QueryRow(ctx, `
		UPDATE automations SET ai_credit_failures = ai_credit_failures + 1
		WHERE id = $1 RETURNING ai_credit_failures`, id).Scan(&n)
	return n, err
}

// ResetAutomationAICreditFailures zeros the counter (skipped when already zero).
func (r *integrationRepository) ResetAutomationAICreditFailures(ctx context.Context, id uuid.UUID) error {
	_, err := r.db.Exec(ctx, `
		UPDATE automations SET ai_credit_failures = 0
		WHERE id = $1 AND ai_credit_failures <> 0`, id)
	return err
}

// DisableAutomationForCredits pauses the flow and clears the counter.
func (r *integrationRepository) DisableAutomationForCredits(ctx context.Context, id uuid.UUID) error {
	_, err := r.db.Exec(ctx, `
		UPDATE automations SET enabled = false, ai_credit_failures = 0, updated_at = NOW()
		WHERE id = $1`, id)
	return err
}

func (r *integrationRepository) GetAutomation(ctx context.Context, orgID, id uuid.UUID) (*models.Automation, error) {
	var a models.Automation
	row := r.db.QueryRow(ctx, `SELECT `+automationCols+` FROM automations WHERE id = $1 AND organization_id = $2`, id, orgID)
	if err := scanAutomation(row, &a); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &a, nil
}

func (r *integrationRepository) GetAutomationByInboundToken(ctx context.Context, token string) (*models.Automation, error) {
	if strings.TrimSpace(token) == "" {
		return nil, nil
	}
	var a models.Automation
	row := r.db.QueryRow(ctx, `SELECT `+automationCols+` FROM automations WHERE inbound_token = $1`, token)
	if err := scanAutomation(row, &a); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &a, nil
}

func (r *integrationRepository) ListAutomations(ctx context.Context, orgID uuid.UUID) ([]models.Automation, error) {
	rows, err := r.db.Query(ctx, `SELECT `+automationCols+` FROM automations WHERE organization_id = $1 ORDER BY created_at DESC`, orgID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.Automation{}
	for rows.Next() {
		var a models.Automation
		if err := scanAutomation(rows, &a); err != nil {
			return nil, err
		}
		out = append(out, a)
	}
	return out, rows.Err()
}

// ListEnabledAutomationsForEvent returns the enabled automations whose trigger
// matches the fired event, for the graph executor to walk. The 'campaign.action'
// trigger is excluded: those are manual / campaign-launched automations that
// only ever run via RunAutomationByID, never by a real event fan-out.
func (r *integrationRepository) ListEnabledAutomationsForEvent(ctx context.Context, orgID uuid.UUID, eventType string) ([]models.Automation, error) {
	rows, err := r.db.Query(ctx,
		`SELECT `+automationCols+` FROM automations WHERE organization_id = $1 AND trigger_event = $2 AND enabled AND trigger_event <> 'campaign.action'`,
		orgID, eventType)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.Automation{}
	for rows.Next() {
		var a models.Automation
		if err := scanAutomation(rows, &a); err != nil {
			return nil, err
		}
		out = append(out, a)
	}
	return out, rows.Err()
}

// --- Field mappings ---------------------------------------------------------

const fieldMappingCols = `id, connection_id, organization_id, subscription_id, direction, object_name, warmbly_field, external_field, transform, static_value, is_default, created_at`

func (r *integrationRepository) ListFieldMappings(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationFieldMapping, error) {
	rows, err := r.db.Query(ctx, `SELECT `+fieldMappingCols+`
		FROM integration_field_mappings WHERE organization_id = $1 AND connection_id = $2
		ORDER BY created_at`, orgID, connID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.IntegrationFieldMapping{}
	for rows.Next() {
		var m models.IntegrationFieldMapping
		if err := rows.Scan(&m.ID, &m.ConnectionID, &m.OrganizationID, &m.SubscriptionID, &m.Direction,
			&m.ObjectName, &m.WarmblyField, &m.ExternalField, &m.Transform, &m.StaticValue, &m.IsDefault, &m.CreatedAt); err != nil {
			return nil, err
		}
		out = append(out, m)
	}
	return out, rows.Err()
}

// ReplaceConnectionFieldMappings atomically swaps the connection-default
// (subscription_id NULL, direction push) mappings for one object with the
// provided set. Per-automation scoped mappings are left untouched.
func (r *integrationRepository) ReplaceConnectionFieldMappings(ctx context.Context, orgID, connID uuid.UUID, object string, mappings []models.IntegrationFieldMapping) error {
	tx, err := r.db.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)

	if _, err := tx.Exec(ctx, `
		DELETE FROM integration_field_mappings
		WHERE organization_id = $1 AND connection_id = $2 AND object_name = $3
		  AND subscription_id IS NULL AND direction = 'push'`, orgID, connID, object); err != nil {
		return err
	}
	// Dedupe by destination field (last wins) so a repeated external_field can't
	// trip the per-destination unique index mid-transaction.
	seen := make(map[string]struct{}, len(mappings))
	for i := len(mappings) - 1; i >= 0; i-- {
		if _, dup := seen[mappings[i].ExternalField]; dup {
			mappings = append(mappings[:i], mappings[i+1:]...)
			continue
		}
		seen[mappings[i].ExternalField] = struct{}{}
	}
	for _, m := range mappings {
		transform := m.Transform
		if transform == "" {
			transform = "none"
		}
		if _, err := tx.Exec(ctx, `
			INSERT INTO integration_field_mappings
				(id, connection_id, organization_id, subscription_id, direction, object_name,
				 warmbly_field, external_field, transform, static_value, is_default, created_at)
			VALUES (gen_random_uuid(), $1, $2, NULL, 'push', $3, $4, $5, $6, $7, true, now())`,
			connID, orgID, object, m.WarmblyField, m.ExternalField, transform, m.StaticValue); err != nil {
			return err
		}
	}
	return tx.Commit(ctx)
}

func (r *integrationRepository) UpdateConnectionConfig(ctx context.Context, orgID, connID uuid.UUID, configCapabilities []byte, syncDirection string) error {
	if len(configCapabilities) == 0 {
		configCapabilities = []byte("{}")
	}
	if syncDirection == "" {
		syncDirection = "push"
	}
	_, err := r.db.Exec(ctx, `
		UPDATE integration_connections
		SET config_capabilities = $1, sync_direction = $2, updated_at = now()
		WHERE organization_id = $3 AND id = $4`, configCapabilities, syncDirection, orgID, connID)
	return err
}

// MatchingDispatchTargets returns enabled subscriptions for an org+event whose
// connection is usable, each hydrated with the connection's encrypted secrets.
// Dispatch volume is per-event and low, so the secrets fetch is done per row.
func (r *integrationRepository) MatchingDispatchTargets(ctx context.Context, orgID uuid.UUID, eventType string) ([]DispatchTarget, error) {
	rows, err := r.db.Query(ctx, `
		SELECT s.id, s.connection_id, s.organization_id, s.event_type, s.action, s.config, s.enabled, s.created_at, s.updated_at, s.use_case
		FROM integration_event_subscriptions s
		JOIN integration_connections c ON c.id = s.connection_id
		WHERE s.organization_id = $1 AND s.event_type = $2 AND s.enabled
		  AND s.automation_id IS NULL
		  AND c.status IN ('connected', 'degraded')`, orgID, eventType)
	if err != nil {
		return nil, err
	}
	var subs []models.IntegrationEventSubscription
	for rows.Next() {
		s, err := scanEventSubscription(rows)
		if err != nil {
			rows.Close()
			return nil, err
		}
		subs = append(subs, *s)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return nil, err
	}

	out := make([]DispatchTarget, 0, len(subs))
	for _, sub := range subs {
		sec, err := r.GetConnectionSecrets(ctx, sub.ConnectionID)
		if err != nil {
			return nil, err
		}
		if sec == nil {
			continue
		}
		out = append(out, DispatchTarget{Subscription: sub, Secrets: *sec})
	}
	return out, nil
}

// --- Sync runs --------------------------------------------------------------

func (r *integrationRepository) CreateSyncRun(ctx context.Context, run *models.IntegrationSyncRun) error {
	if run.ID == uuid.Nil {
		run.ID = uuid.New()
	}
	run.StartedAt = time.Now().UTC()
	if run.Status == "" {
		run.Status = "running"
	}
	_, err := r.db.Exec(ctx, `
		INSERT INTO integration_sync_runs (id, connection_id, organization_id, kind, status, detail, records_processed, started_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
		run.ID, run.ConnectionID, run.OrganizationID, run.Kind, run.Status, run.Detail, run.RecordsProcessed, run.StartedAt)
	return err
}

func (r *integrationRepository) FinishSyncRun(ctx context.Context, id uuid.UUID, status, detail string, records int) error {
	_, err := r.db.Exec(ctx, `
		UPDATE integration_sync_runs
		SET status = $1, detail = $2, records_processed = $3, finished_at = NOW()
		WHERE id = $4`, status, detail, records, id)
	return err
}

func (r *integrationRepository) ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error) {
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	rows, err := r.db.Query(ctx, `
		SELECT id, connection_id, organization_id, kind, status, detail, records_processed, started_at, finished_at
		FROM integration_sync_runs
		WHERE organization_id = $1 AND connection_id = $2 ORDER BY started_at DESC LIMIT $3`, orgID, connID, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.IntegrationSyncRun{}
	for rows.Next() {
		var run models.IntegrationSyncRun
		if err := rows.Scan(&run.ID, &run.ConnectionID, &run.OrganizationID, &run.Kind, &run.Status,
			&run.Detail, &run.RecordsProcessed, &run.StartedAt, &run.FinishedAt); err != nil {
			return nil, err
		}
		out = append(out, run)
	}
	return out, rows.Err()
}

func (r *integrationRepository) CreateAutomationRun(ctx context.Context, run *models.AutomationRun) error {
	if run.ID == uuid.Nil {
		run.ID = uuid.New()
	}
	run.StartedAt = time.Now().UTC()
	if run.Status == "" {
		run.Status = "running"
	}
	results, _ := json.Marshal(run.NodeResults)
	if len(results) == 0 {
		results = []byte("[]")
	}
	_, err := r.db.Exec(ctx, `
		INSERT INTO automation_runs (id, automation_id, organization_id, trigger_event, status, node_results, started_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7)`,
		run.ID, run.AutomationID, run.OrganizationID, run.TriggerEvent, run.Status, results, run.StartedAt)
	return err
}

func (r *integrationRepository) FinishAutomationRun(ctx context.Context, id uuid.UUID, status, errorDetail string, nodeResults []models.AutomationNodeResult) error {
	results, _ := json.Marshal(nodeResults)
	if len(results) == 0 {
		results = []byte("[]")
	}
	_, err := r.db.Exec(ctx, `
		UPDATE automation_runs
		SET status = $1, error_detail = $2, node_results = $3, finished_at = NOW()
		WHERE id = $4`, status, errorDetail, results, id)
	return err
}

func (r *integrationRepository) ListAutomationRuns(ctx context.Context, orgID, automationID uuid.UUID, limit int) ([]models.AutomationRun, error) {
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	rows, err := r.db.Query(ctx, `
		SELECT id, automation_id, organization_id, trigger_event, status, node_results, error_detail, started_at, finished_at
		FROM automation_runs
		WHERE organization_id = $1 AND automation_id = $2 ORDER BY started_at DESC LIMIT $3`, orgID, automationID, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.AutomationRun{}
	for rows.Next() {
		var run models.AutomationRun
		var results []byte
		if err := rows.Scan(&run.ID, &run.AutomationID, &run.OrganizationID, &run.TriggerEvent, &run.Status,
			&results, &run.ErrorDetail, &run.StartedAt, &run.FinishedAt); err != nil {
			return nil, err
		}
		if len(results) > 0 {
			_ = json.Unmarshal(results, &run.NodeResults)
		}
		out = append(out, run)
	}
	return out, rows.Err()
}

// --- scanning helpers -------------------------------------------------------

type scanner interface {
	Scan(dest ...any) error
}

func isNoRows(err error) bool {
	return errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows)
}

func scanConnectionInto(row scanner, c *models.IntegrationConnection) error {
	var provider, status string
	if err := row.Scan(
		&c.ID, &c.OrganizationID, &provider, &c.Label, &status, &c.AuthMethod, &c.DisplayFields,
		&c.ConnectedByUserID, &c.ExternalAccountID, &c.ExternalAccountName,
		&c.GrantedScopes, &c.TokenExpiresAt, &c.Health, &c.HealthDetail, &c.HealthCheckedAt,
		&c.LastSyncedAt, &c.LastError, &c.LastErrorAt, &c.CreatedAt, &c.UpdatedAt,
		&c.ConfigCapabilities, &c.SyncDirection,
	); err != nil {
		return err
	}
	c.Provider = models.IntegrationProvider(provider)
	c.Status = models.IntegrationStatus(status)
	if len(c.DisplayFields) == 0 {
		c.DisplayFields = json.RawMessage("{}")
	}
	if len(c.ConfigCapabilities) == 0 {
		c.ConfigCapabilities = json.RawMessage("{}")
	}
	return nil
}

func scanConnectionSecretsInto(row scanner, sec *ConnectionSecrets) error {
	var provider, status string
	if err := row.Scan(
		&sec.Conn.ID, &sec.Conn.OrganizationID, &provider, &sec.Conn.Label, &status, &sec.Conn.AuthMethod, &sec.Conn.DisplayFields,
		&sec.Conn.ConnectedByUserID, &sec.Conn.ExternalAccountID, &sec.Conn.ExternalAccountName,
		&sec.Conn.GrantedScopes, &sec.Conn.TokenExpiresAt, &sec.Conn.Health, &sec.Conn.HealthDetail, &sec.Conn.HealthCheckedAt,
		&sec.Conn.LastSyncedAt, &sec.Conn.LastError, &sec.Conn.LastErrorAt, &sec.Conn.CreatedAt, &sec.Conn.UpdatedAt,
		&sec.Conn.ConfigCapabilities, &sec.Conn.SyncDirection,
		&sec.ConfigEncrypted, &sec.AccessTokenEnc, &sec.RefreshTokenEnc,
	); err != nil {
		return err
	}
	sec.Conn.Provider = models.IntegrationProvider(provider)
	sec.Conn.Status = models.IntegrationStatus(status)
	if len(sec.Conn.DisplayFields) == 0 {
		sec.Conn.DisplayFields = json.RawMessage("{}")
	}
	if len(sec.Conn.ConfigCapabilities) == 0 {
		sec.Conn.ConfigCapabilities = json.RawMessage("{}")
	}
	return nil
}

func scanEventSubscription(row scanner) (*models.IntegrationEventSubscription, error) {
	var s models.IntegrationEventSubscription
	var action string
	var cfg []byte
	if err := row.Scan(&s.ID, &s.ConnectionID, &s.OrganizationID, &s.EventType, &action, &cfg, &s.Enabled, &s.CreatedAt, &s.UpdatedAt, &s.UseCase); err != nil {
		return nil, err
	}
	s.Action = models.IntegrationAction(action)
	if len(cfg) == 0 {
		cfg = []byte("{}")
	}
	s.Config = cfg
	return &s, nil
}

// --- Meeting bookings -------------------------------------------------------

// meetingCols is the shared column projection (with a contacts join for the
// display name) so List/Search scan identically.
const meetingCols = `
	mb.id, mb.organization_id, mb.source, mb.external_event_id, mb.status,
	mb.invitee_email, mb.invitee_name, mb.event_name, mb.event_type,
	mb.scheduled_for, mb.end_time, mb.join_url, mb.location,
	mb.cancel_url, mb.reschedule_url, mb.canceled_reason,
	mb.contact_id, mb.campaign_id, mb.created_at, mb.updated_at,
	COALESCE(NULLIF(TRIM(CONCAT_WS(' ', c.first_name, c.last_name)), ''), '') AS contact_name`

func scanMeetingBooking(row pgx.Row, b *models.MeetingBooking) error {
	return row.Scan(
		&b.ID, &b.OrganizationID, &b.Source, &b.ExternalEventID, &b.Status,
		&b.InviteeEmail, &b.InviteeName, &b.EventName, &b.EventType,
		&b.ScheduledFor, &b.EndTime, &b.JoinURL, &b.Location,
		&b.CancelURL, &b.RescheduleURL, &b.CanceledReason,
		&b.ContactID, &b.CampaignID, &b.CreatedAt, &b.UpdatedAt,
		&b.ContactName,
	)
}

func (r *integrationRepository) UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error {
	if b.ID == uuid.Nil {
		b.ID = uuid.New()
	}
	if b.Status == "" {
		b.Status = models.MeetingBooked
	}
	raw := b.RawPayload
	if len(raw) == 0 {
		raw = json.RawMessage("{}")
	}
	_, err := r.db.Exec(ctx, `
		INSERT INTO meeting_bookings (
			id, organization_id, source, external_event_id, status,
			invitee_email, invitee_name, event_name, event_type,
			scheduled_for, end_time, join_url, location,
			cancel_url, reschedule_url, canceled_reason,
			contact_id, campaign_id, raw_payload, created_at, updated_at
		) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, NOW(), NOW())
		ON CONFLICT (organization_id, source, external_event_id) DO UPDATE SET
			-- never let a re-delivered "booked" un-cancel a canceled meeting.
			status = CASE
				WHEN meeting_bookings.status = 'canceled' AND EXCLUDED.status = 'booked'
				THEN meeting_bookings.status ELSE EXCLUDED.status END,
			invitee_email = EXCLUDED.invitee_email,
			invitee_name = EXCLUDED.invitee_name,
			event_name = EXCLUDED.event_name,
			event_type = EXCLUDED.event_type,
			scheduled_for = EXCLUDED.scheduled_for,
			end_time = EXCLUDED.end_time,
			join_url = EXCLUDED.join_url,
			location = EXCLUDED.location,
			cancel_url = EXCLUDED.cancel_url,
			reschedule_url = EXCLUDED.reschedule_url,
			canceled_reason = EXCLUDED.canceled_reason,
			contact_id = COALESCE(EXCLUDED.contact_id, meeting_bookings.contact_id),
			campaign_id = COALESCE(EXCLUDED.campaign_id, meeting_bookings.campaign_id),
			raw_payload = EXCLUDED.raw_payload,
			updated_at = NOW()`,
		b.ID, b.OrganizationID, b.Source, b.ExternalEventID, string(b.Status),
		b.InviteeEmail, b.InviteeName, b.EventName, b.EventType,
		b.ScheduledFor, b.EndTime, b.JoinURL, b.Location,
		b.CancelURL, b.RescheduleURL, b.CanceledReason,
		b.ContactID, b.CampaignID, raw)
	return err
}

func (r *integrationRepository) ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error) {
	if limit <= 0 {
		limit = 50
	}
	rows, err := r.db.Query(ctx, `
		SELECT `+meetingCols+`
		FROM meeting_bookings mb
		LEFT JOIN contacts c ON c.id = mb.contact_id
		WHERE mb.organization_id = $1 ORDER BY mb.created_at DESC LIMIT $2`, orgID, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	out := []models.MeetingBooking{}
	for rows.Next() {
		var b models.MeetingBooking
		if err := scanMeetingBooking(rows, &b); err != nil {
			return nil, err
		}
		out = append(out, b)
	}
	return out, rows.Err()
}

// SearchMeetingBookings powers the Meetings page: timeframe (upcoming/past),
// status, and free-text filters with offset pagination and an honest total.
func (r *integrationRepository) SearchMeetingBookings(ctx context.Context, orgID uuid.UUID, f models.MeetingBookingFilter) (*models.MeetingBookingPage, error) {
	limit := f.Limit
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	offset := f.Offset
	if offset < 0 {
		offset = 0
	}

	where := []string{"mb.organization_id = $1"}
	args := []any{orgID}
	add := func(clause string, val any) {
		args = append(args, val)
		where = append(where, fmt.Sprintf(clause, len(args)))
	}

	switch f.Timeframe {
	case "upcoming":
		where = append(where, "mb.scheduled_for >= NOW()", "mb.status <> 'canceled'")
	case "past":
		where = append(where, "(mb.scheduled_for < NOW() OR mb.status = 'canceled')")
	}
	if f.Status != "" {
		add("mb.status = $%d", f.Status)
	}
	if s := strings.TrimSpace(f.Search); s != "" {
		args = append(args, "%"+s+"%")
		idx := len(args) // one arg backs all three ILIKE placeholders
		where = append(where, fmt.Sprintf("(mb.invitee_name ILIKE $%d OR mb.invitee_email ILIKE $%d OR mb.event_name ILIKE $%d)", idx, idx, idx))
	}

	whereSQL := strings.Join(where, " AND ")

	var total int
	if err := r.db.QueryRow(ctx,
		`SELECT COUNT(*) FROM meeting_bookings mb WHERE `+whereSQL, args...,
	).Scan(&total); err != nil {
		return nil, err
	}

	order := "mb.scheduled_for DESC NULLS LAST, mb.created_at DESC"
	if f.Timeframe == "upcoming" {
		order = "mb.scheduled_for ASC NULLS LAST, mb.created_at DESC"
	}

	query := fmt.Sprintf(`
		SELECT %s
		FROM meeting_bookings mb
		LEFT JOIN contacts c ON c.id = mb.contact_id
		WHERE %s
		ORDER BY %s
		LIMIT $%d OFFSET $%d`, meetingCols, whereSQL, order, len(args)+1, len(args)+2)
	args = append(args, limit, offset)

	rows, err := r.db.Query(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	data := []models.MeetingBooking{}
	for rows.Next() {
		var b models.MeetingBooking
		if err := scanMeetingBooking(rows, &b); err != nil {
			return nil, err
		}
		data = append(data, b)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	hasMore := offset+len(data) < total
	total64 := int64(total)
	page := &models.MeetingBookingPage{
		Data:       data,
		Pagination: models.Pagination{Total: &total64, HasMore: hasMore},
	}
	if hasMore {
		page.Pagination.NextCursor = paging.EncodeOffset(offset + limit)
	}
	return page, nil
}

// GetMeetingBooking fetches one booking (with contact name) scoped to the org.
func (r *integrationRepository) GetMeetingBooking(ctx context.Context, orgID, id uuid.UUID) (*models.MeetingBooking, error) {
	var b models.MeetingBooking
	row := r.db.QueryRow(ctx, `
		SELECT `+meetingCols+`
		FROM meeting_bookings mb
		LEFT JOIN contacts c ON c.id = mb.contact_id
		WHERE mb.organization_id = $1 AND mb.id = $2`, orgID, id)
	if err := scanMeetingBooking(row, &b); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &b, nil
}

// DeleteMeetingBooking removes a booking (used to delete a manually-created
// meeting). Org-scoped so a member can't delete another org's row.
func (r *integrationRepository) DeleteMeetingBooking(ctx context.Context, orgID, id uuid.UUID) error {
	_, err := r.db.Exec(ctx,
		`DELETE FROM meeting_bookings WHERE organization_id = $1 AND id = $2`, orgID, id)
	return err
}

// MeetingBookingSummary returns the counts the sidebar + page header show.
func (r *integrationRepository) MeetingBookingSummary(ctx context.Context, orgID uuid.UUID) (*models.MeetingBookingSummary, error) {
	var s models.MeetingBookingSummary
	err := r.db.QueryRow(ctx, `
		SELECT
			COUNT(*) FILTER (WHERE scheduled_for >= NOW() AND status <> 'canceled') AS upcoming,
			COUNT(*) FILTER (WHERE scheduled_for >= date_trunc('day', NOW())
				AND scheduled_for < date_trunc('day', NOW()) + interval '1 day'
				AND status <> 'canceled') AS today,
			COUNT(*) AS total,
			COUNT(*) FILTER (WHERE status = 'canceled') AS canceled
		FROM meeting_bookings WHERE organization_id = $1`, orgID).
		Scan(&s.Upcoming, &s.Today, &s.Total, &s.Canceled)
	if err != nil {
		return nil, err
	}
	return &s, nil
}
