package services

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

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
	"github.com/lib/pq"

	"mgit.msbls.de/m/paliad/internal/models"
	"mgit.msbls.de/m/paliad/internal/offices"
)

// normaliseTimeOfDay accepts "HH:MM" or "HH:MM:SS" (the two shapes the HTML
// time input and Postgres TIME column produce) and returns "HH:MM:SS" for
// consistent storage. Empty / out-of-range inputs are rejected.
func normaliseTimeOfDay(raw string) (string, error) {
	s := strings.TrimSpace(raw)
	if s == "" {
		return "", fmt.Errorf("required (HH:MM or HH:MM:SS)")
	}
	for _, layout := range []string{"15:04", "15:04:05"} {
		if t, err := time.Parse(layout, s); err == nil {
			return t.Format("15:04:05"), nil
		}
	}
	return "", fmt.Errorf("invalid time %q (want HH:MM)", raw)
}

// Sentinel errors returned by UserService.
var (
	// ErrUserAlreadyOnboarded is returned when POST /api/onboarding is called
	// for a paliad.users row that already exists (409 Conflict on the wire).
	ErrUserAlreadyOnboarded = errors.New("user already onboarded")
	// ErrLastGlobalAdmin guards demoting / deleting the last global_admin so
	// the firm can't lock itself out of its own admin UI.
	ErrLastGlobalAdmin = errors.New("cannot remove the last remaining global admin")
	// ErrGlobalAdminAssignment signals a non-global-admin trying to write
	// global_role through a path that doesn't permit it (e.g. /api/onboarding,
	// /api/me, the create form on /admin/team). Promotion to global_admin is
	// only legal via PATCH /api/admin/users/{id} from an existing global_admin
	// — and the bootstrap path, where the first paliad.users row may flip
	// itself.
	ErrGlobalAdminAssignment = errors.New("global_admin must be granted by an existing global admin")
	// ErrUserNotOnboarded is returned when an endpoint that requires an
	// existing paliad.users row is called by a user who hasn't onboarded yet
	// (404 Not Found on the wire — callers should redirect to /onboarding).
	ErrUserNotOnboarded = errors.New("paliad.users row missing — onboarding required")
)

// UserService reads paliad.users. Writes happen via the Phase D onboarding
// endpoint and are not exposed here yet.
type UserService struct {
	db *sqlx.DB
}

// NewUserService wires the service to the pool.
func NewUserService(db *sqlx.DB) *UserService {
	return &UserService{db: db}
}

const userColumns = `id, email, display_name, office, additional_offices, practice_group,
	job_title, global_role,
	lang, email_preferences,
	reminder_morning_time::text AS reminder_morning_time,
	reminder_evening_time::text AS reminder_evening_time,
	reminder_timezone,
	reminder_warning_offset_days,
	escalation_contact_id,
	forum_pref,
	created_at, updated_at`

// GetByID returns the user row, or (nil, nil) if the user hasn't completed
// onboarding yet. Real errors bubble up.
func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
	var u models.User
	err := s.db.GetContext(ctx, &u,
		`SELECT `+userColumns+` FROM paliad.users WHERE id = $1`, id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("get user: %w", err)
	}
	return &u, nil
}

// CreateUserInput is the payload for the onboarding flow (POST /api/onboarding).
//
// PartnerUnitID is optional — when set, the onboarding flow inserts a
// paliad.partner_unit_members row in the same tx as the user-create and
// emits a 'member_added' audit event with source='onboarding'. When unset,
// the user is onboarded without any partner-unit membership and an admin
// must assign one later via /admin/partner-units.
//
// Profession (t-paliad-148) is the structured firm-tier value that drives
// the approval ladder — partner / of_counsel / associate / senior_pa /
// pa / paralegal. Defaults to 'associate' when empty (the most common
// case for self-service signup). Admins can edit later via /admin/team.
// Distinct from JobTitle which is free-text display only.
type CreateUserInput struct {
	DisplayName   string     `json:"display_name"`
	Office        string     `json:"office"`
	JobTitle      string     `json:"job_title"`
	Profession    string     `json:"profession,omitempty"`
	PartnerUnitID *uuid.UUID `json:"partner_unit_id,omitempty"`
}

// Create inserts the paliad.users row for the authenticated user. The caller
// owns the (id, email) pair — they come from the verified JWT claims, never
// from the request body, which prevents a user from creating a row for a
// different auth.uid().
//
// JobTitle is free-form text (Partner / Counsel / PA / Trainee / Sekretariat /
// "Counsel Knowledge Lawyer" / …). The DB CHECK only requires non-empty.
//
// global_role is decided server-side: every new row defaults to 'standard',
// EXCEPT the bootstrap path — when paliad.users is otherwise empty, the
// inserter is promoted to 'global_admin' so the firm has at least one admin.
// The pg_advisory_xact_lock serialises concurrent first-logins so only one
// can win the bootstrap; the lock auto-releases on commit/rollback.
//
// Returns ErrUserAlreadyOnboarded if the row exists (callers map to 409).
func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, input CreateUserInput) (*models.User, error) {
	displayName := strings.TrimSpace(input.DisplayName)
	if displayName == "" {
		return nil, fmt.Errorf("display_name is required")
	}
	if !offices.IsValid(input.Office) {
		return nil, fmt.Errorf("invalid office %q", input.Office)
	}
	jobTitle := strings.TrimSpace(input.JobTitle)
	if jobTitle == "" {
		return nil, fmt.Errorf("job_title is required")
	}
	profession := strings.TrimSpace(input.Profession)
	if profession == "" {
		profession = ProfessionAssociate
	}
	if !IsValidProfession(profession) {
		return nil, fmt.Errorf("invalid profession %q", profession)
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	// Refuse a second row for the same auth.uid(). The PRIMARY KEY would also
	// catch this, but we want a typed error (not a raw pq unique-violation).
	var exists bool
	if err := tx.GetContext(ctx, &exists,
		`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, id); err != nil {
		return nil, fmt.Errorf("check existing user: %w", err)
	}
	if exists {
		return nil, ErrUserAlreadyOnboarded
	}

	// Bootstrap gate: the very first paliad.users row is promoted to
	// global_admin so the firm has at least one admin from day one. Under
	// Postgres' default READ COMMITTED isolation two concurrent first-logins
	// could both see count=0; the advisory lock serialises the check + insert
	// so only one bootstrap can win. The lock auto-releases on commit/rollback.
	// The constant is arbitrary but stable — every bootstrap tx takes the
	// same lock.
	if _, err := tx.ExecContext(ctx,
		`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
		return nil, fmt.Errorf("lock for bootstrap: %w", err)
	}
	var existingCount int
	if err := tx.GetContext(ctx, &existingCount,
		`SELECT count(*) FROM paliad.users`); err != nil {
		return nil, fmt.Errorf("count users: %w", err)
	}
	globalRole := "standard"
	if existingCount == 0 {
		globalRole = "global_admin"
	}

	// practice_group is intentionally left NULL — the column is retained for
	// future use but no longer collected at onboarding (m, 2026-04-18: every
	// Paliad user is in patent practice, so the field carried no signal).
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role)
		 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
		id, email, displayName, input.Office, jobTitle, profession, globalRole,
	); err != nil {
		return nil, fmt.Errorf("insert user: %w", err)
	}

	// Optional initial partner-unit membership picked from the onboarding
	// form. RLS on partner_unit_members allows user_id = auth.uid() so this
	// works even after we strip superuser; the audit event records the user
	// as their own actor with source='onboarding' so admins can see how the
	// membership originated.
	if input.PartnerUnitID != nil {
		if err := insertPartnerUnitMembership(ctx, tx, *input.PartnerUnitID, id); err != nil {
			return nil, err
		}
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit create user: %w", err)
	}

	return s.GetByID(ctx, id)
}

// insertPartnerUnitMembership inserts a paliad.partner_unit_members row plus
// a paliad.partner_unit_events audit row inside the caller's tx. Used by
// onboarding (Create) — admin-driven membership writes go through
// PartnerUnitService.AddMember which has its own emit.
//
// The user is recorded as the actor for the audit event because the
// onboarding form is self-service. unitName is fetched inside the tx so
// the audit row stays readable if the unit is later deleted.
func insertPartnerUnitMembership(ctx context.Context, tx *sqlx.Tx, partnerUnitID, userID uuid.UUID) error {
	res, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at)
		 VALUES ($1, $2, now())
		 ON CONFLICT (partner_unit_id, user_id) DO NOTHING`,
		partnerUnitID, userID)
	if err != nil {
		return fmt.Errorf("insert partner_unit membership: %w", err)
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return nil
	}

	var unitName string
	if err := tx.GetContext(ctx, &unitName,
		`SELECT name FROM paliad.partner_units WHERE id = $1`, partnerUnitID); err != nil {
		return fmt.Errorf("lookup partner_unit name: %w", err)
	}

	payload := map[string]any{
		"user_id": userID,
		"source":  "onboarding",
	}
	pj, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("marshal audit payload: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.partner_unit_events
		   (partner_unit_id, actor_id, event_type, unit_name, payload)
		 VALUES ($1, $2, 'member_added', $3, $4)`,
		partnerUnitID, userID, unitName, pj); err != nil {
		return fmt.Errorf("emit member_added event: %w", err)
	}
	return nil
}

// UpdateProfileInput is the payload for PATCH /api/me. Every field is a
// pointer so callers can omit keys they don't want to touch — the settings
// page sends only the fields the user changed. Email is deliberately absent:
// auth.users.email is the source of truth and the handler rejects any attempt
// to mutate it via this endpoint. global_role is also deliberately absent:
// promotion/demotion is a privileged operation that goes through
// PATCH /api/admin/users/{id}, never through self-service settings.
type UpdateProfileInput struct {
	DisplayName               *string          `json:"display_name,omitempty"`
	Office                    *string          `json:"office,omitempty"`
	JobTitle                  *string          `json:"job_title,omitempty"`
	Lang                      *string          `json:"lang,omitempty"`
	EmailPreferences          *json.RawMessage `json:"email_preferences,omitempty"`
	ReminderMorningTime       *string          `json:"reminder_morning_time,omitempty"`
	ReminderEveningTime       *string          `json:"reminder_evening_time,omitempty"`
	ReminderTimezone          *string          `json:"reminder_timezone,omitempty"`
	ReminderWarningOffsetDays *int             `json:"reminder_warning_offset_days,omitempty"`
	// EscalationContactID overrides the DRINGEND/overdue escalation channel:
	// when non-NULL, the named user replaces the global_admins fallback for
	// this user's deadlines. Empty string clears (back to fallback). nil =
	// don't touch (a UUID and "no override" are different types, so we
	// encode the clear signal as "" rather than juggling JSON null / missing
	// semantics).
	EscalationContactID *string `json:"escalation_contact_id,omitempty"`
	// ForumPref is the persisted Fristenrechner inbox-channel preference
	// (#15). Allowed values: "cms" | "bea" | "posteingang". Empty string
	// clears the preference (NULL in the DB). nil = don't touch.
	ForumPref *string `json:"forum_pref,omitempty"`
}

// UpdateProfile mutates the paliad.users row for the authenticated user.
// Returns the fresh row.
//
// global_role is intentionally NOT writable here — see UpdateProfileInput.
func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input UpdateProfileInput) (*models.User, error) {
	sets := []string{}
	args := []any{}
	i := 1

	if input.DisplayName != nil {
		dn := strings.TrimSpace(*input.DisplayName)
		if dn == "" {
			return nil, fmt.Errorf("display_name cannot be empty")
		}
		sets = append(sets, fmt.Sprintf("display_name = $%d", i))
		args = append(args, dn)
		i++
	}
	if input.Office != nil {
		if !offices.IsValid(*input.Office) {
			return nil, fmt.Errorf("invalid office %q", *input.Office)
		}
		sets = append(sets, fmt.Sprintf("office = $%d", i))
		args = append(args, *input.Office)
		i++
	}
	if input.JobTitle != nil {
		jt := strings.TrimSpace(*input.JobTitle)
		if jt == "" {
			return nil, fmt.Errorf("job_title cannot be empty")
		}
		sets = append(sets, fmt.Sprintf("job_title = $%d", i))
		args = append(args, jt)
		i++
	}
	if input.Lang != nil {
		lang := strings.ToLower(strings.TrimSpace(*input.Lang))
		if lang != "de" && lang != "en" {
			return nil, fmt.Errorf("invalid lang %q (expected de or en)", *input.Lang)
		}
		sets = append(sets, fmt.Sprintf("lang = $%d", i))
		args = append(args, lang)
		i++
	}
	if input.EmailPreferences != nil {
		raw := *input.EmailPreferences
		// Reject anything that isn't a JSON object — the column is JSONB but
		// the app model is "bag of feature flags", not arbitrary scalars.
		var obj map[string]any
		if err := json.Unmarshal(raw, &obj); err != nil {
			return nil, fmt.Errorf("email_preferences must be a JSON object")
		}
		sets = append(sets, fmt.Sprintf("email_preferences = $%d", i))
		args = append(args, []byte(raw))
		i++
	}
	if input.ReminderMorningTime != nil {
		t, err := normaliseTimeOfDay(*input.ReminderMorningTime)
		if err != nil {
			return nil, fmt.Errorf("reminder_morning_time: %w", err)
		}
		sets = append(sets, fmt.Sprintf("reminder_morning_time = $%d", i))
		args = append(args, t)
		i++
	}
	if input.ReminderEveningTime != nil {
		t, err := normaliseTimeOfDay(*input.ReminderEveningTime)
		if err != nil {
			return nil, fmt.Errorf("reminder_evening_time: %w", err)
		}
		sets = append(sets, fmt.Sprintf("reminder_evening_time = $%d", i))
		args = append(args, t)
		i++
	}
	if input.ReminderTimezone != nil {
		tz := strings.TrimSpace(*input.ReminderTimezone)
		if _, err := time.LoadLocation(tz); err != nil {
			return nil, fmt.Errorf("invalid reminder_timezone %q", tz)
		}
		sets = append(sets, fmt.Sprintf("reminder_timezone = $%d", i))
		args = append(args, tz)
		i++
	}
	if input.ReminderWarningOffsetDays != nil {
		days := *input.ReminderWarningOffsetDays
		if days < 1 || days > 30 {
			return nil, fmt.Errorf("reminder_warning_offset_days must be between 1 and 30")
		}
		sets = append(sets, fmt.Sprintf("reminder_warning_offset_days = $%d", i))
		args = append(args, days)
		i++
	}
	if input.EscalationContactID != nil {
		raw := strings.TrimSpace(*input.EscalationContactID)
		var val any
		if raw == "" {
			val = nil
		} else {
			target, err := uuid.Parse(raw)
			if err != nil {
				return nil, fmt.Errorf("invalid escalation_contact_id: %w", err)
			}
			// Self-pointer is silly: routing your own escalations back to
			// yourself doesn't escalate anywhere. The DB CHECK constraint
			// users_escalation_contact_self_check (migration 025) enforces
			// this too, but we want a typed message instead of a raw
			// pq violation.
			if target == id {
				return nil, fmt.Errorf("cannot set yourself as escalation contact")
			}
			var exists bool
			if err := s.db.GetContext(ctx, &exists,
				`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, target); err != nil {
				return nil, fmt.Errorf("verify escalation contact: %w", err)
			}
			if !exists {
				return nil, fmt.Errorf("escalation contact user not found")
			}
			val = target
		}
		sets = append(sets, fmt.Sprintf("escalation_contact_id = $%d", i))
		args = append(args, val)
		i++
	}
	if input.ForumPref != nil {
		raw := strings.TrimSpace(*input.ForumPref)
		var val any
		if raw == "" {
			val = nil
		} else {
			if !isValidForumPref(raw) {
				return nil, fmt.Errorf("invalid forum_pref %q (expected cms, bea, or posteingang)", raw)
			}
			val = raw
		}
		sets = append(sets, fmt.Sprintf("forum_pref = $%d", i))
		args = append(args, val)
		i++
	}

	if len(sets) == 0 {
		// No-op PATCH is legal — just return the current row.
		return s.GetByID(ctx, id)
	}

	sets = append(sets, "updated_at = now()")
	args = append(args, id)
	query := fmt.Sprintf(
		`UPDATE paliad.users SET %s WHERE id = $%d`,
		strings.Join(sets, ", "), i,
	)
	res, err := s.db.ExecContext(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("update user: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return nil, fmt.Errorf("update user: rows affected: %w", err)
	}
	if n == 0 {
		return nil, ErrUserNotOnboarded
	}
	return s.GetByID(ctx, id)
}

// List returns all users (used by collaborator-picker in Phase D).
func (s *UserService) List(ctx context.Context) ([]models.User, error) {
	var users []models.User
	if err := s.db.SelectContext(ctx, &users,
		`SELECT `+userColumns+`
		   FROM paliad.users
		  ORDER BY display_name, email`); err != nil {
		return nil, fmt.Errorf("list users: %w", err)
	}
	return users, nil
}

// IsAdmin reports whether the given user has the global_admin permission.
// Implements auth.AdminLookup so the requireAdmin middleware can stay in
// package auth without importing services. Returns (false, nil) for an
// unknown / unonboarded user — a missing paliad.users row is not an admin.
func (s *UserService) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
	u, err := s.GetByID(ctx, id)
	if err != nil {
		return false, err
	}
	return u != nil && u.GlobalRole == "global_admin", nil
}

// AdminCreateInput is the payload an admin uses to onboard a colleague who
// already exists in auth.users. Email is required (must already be in
// auth.users with an allowed domain — both checks happen in AdminCreateUser).
//
// Partner-unit membership is intentionally NOT settable here; admins assign
// memberships separately via /admin/partner-units after the row exists.
type AdminCreateInput struct {
	Email       string `json:"email"`
	DisplayName string `json:"display_name"`
	Office      string `json:"office"`
	JobTitle    string `json:"job_title,omitempty"` // defaults to 'Associate'
	// Profession is the structured firm-tier value driving the approval
	// ladder (t-paliad-148). Defaults to 'associate' when empty. Distinct
	// from JobTitle which is a free-text display label. Use the empty
	// string to indicate "no firm tier" (external collaborator); the
	// admin form's "Extern" option submits "" here.
	Profession string `json:"profession,omitempty"`
	Lang       string `json:"lang,omitempty"` // defaults to 'de'
}

// AdminCreateUser inserts a paliad.users row for an auth.users entry that has
// not yet onboarded. Used by the admin team-management page to bulk-onboard
// real colleagues without forcing each one through the self-service flow.
//
// Returns ErrUserAlreadyOnboarded if a paliad.users row already exists for
// the given email's auth.users id. Returns a wrapped ErrInvalidInput when the
// email isn't in auth.users at all (so the handler can map to 404).
//
// global_role is always 'standard' on this path. Promotion to global_admin
// is a separate AdminUpdateUser call so it can't be smuggled into create.
func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInput) (*models.User, error) {
	email := strings.ToLower(strings.TrimSpace(input.Email))
	if email == "" {
		return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
	}
	displayName := strings.TrimSpace(input.DisplayName)
	if displayName == "" {
		return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
	}
	if !offices.IsValid(input.Office) {
		return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
	}
	jobTitle := strings.TrimSpace(input.JobTitle)
	if jobTitle == "" {
		jobTitle = "Associate"
	}
	// Profession may be empty to flag an external collaborator; only
	// non-empty values must validate against the enum.
	profession := strings.TrimSpace(input.Profession)
	if profession == "" {
		profession = ProfessionAssociate
	}
	if !IsValidProfession(profession) {
		return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, profession)
	}
	lang := strings.ToLower(strings.TrimSpace(input.Lang))
	if lang == "" {
		lang = "de"
	}
	if lang != "de" && lang != "en" {
		return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang)
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	// Look up the auth.users.id for the requested email. Admins bulk-onboard
	// colleagues who have already signed in — if the email isn't in auth.users
	// the right path is to invite them, not create a half-attached profile.
	var authID uuid.UUID
	if err := tx.GetContext(ctx, &authID,
		`SELECT id FROM auth.users WHERE lower(email) = $1`, email); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, fmt.Errorf("%w: email %q is not in auth.users — invite first", ErrInvalidInput, email)
		}
		return nil, fmt.Errorf("lookup auth.users: %w", err)
	}

	// Refuse a second paliad.users row for the same auth.uid().
	var exists bool
	if err := tx.GetContext(ctx, &exists,
		`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, authID); err != nil {
		return nil, fmt.Errorf("check existing user: %w", err)
	}
	if exists {
		return nil, ErrUserAlreadyOnboarded
	}

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role, lang)
		 VALUES ($1, $2, $3, $4, $5, $6, 'standard', $7)`,
		authID, email, displayName, input.Office, jobTitle, profession, lang,
	); err != nil {
		return nil, fmt.Errorf("insert user: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit admin create user: %w", err)
	}
	return s.GetByID(ctx, authID)
}

// AdminUpdateInput is the payload for AdminUpdateUser. Same shape as
// UpdateProfileInput but additionally allows the additional_offices array
// (which the self-service settings page does not expose).
type AdminUpdateInput struct {
	DisplayName         *string          `json:"display_name,omitempty"`
	Office              *string          `json:"office,omitempty"`
	JobTitle            *string          `json:"job_title,omitempty"`
	// Profession (t-paliad-148). Empty string clears the column to NULL
	// (external collaborator). Any non-empty value must be one of the
	// recognised firm-tier values.
	Profession          *string          `json:"profession,omitempty"`
	GlobalRole          *string          `json:"global_role,omitempty"`
	AdditionalOffices   *[]string        `json:"additional_offices,omitempty"`
	Lang                *string          `json:"lang,omitempty"`
	EmailPreferences    *json.RawMessage `json:"email_preferences,omitempty"`
	ReminderMorningTime *string          `json:"reminder_morning_time,omitempty"`
	ReminderEveningTime *string          `json:"reminder_evening_time,omitempty"`
	ReminderTimezone    *string          `json:"reminder_timezone,omitempty"`
}

// AdminUpdateUser mutates any paliad.users row. Same validation rules as
// UpdateProfile, plus: AdminUpdate may write additional_offices and
// global_role (the privileged fields that self-service must not touch).
// Returns ErrUserNotOnboarded when the target row is missing. Returns
// ErrLastGlobalAdmin when the call would demote the last global_admin.
//
// Note: this method assumes the caller already passed
// auth.RequireAdmin — the handler enforces "only existing global admins
// may call this endpoint". The last-admin guard runs unconditionally here
// regardless, as a belt-and-braces safety net.
//
// JobTitle of "" (empty after trim) clears job_title to NULL — admins
// without a real job title legitimately store NULL.
func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input AdminUpdateInput) (*models.User, error) {
	sets := []string{}
	args := []any{}
	i := 1

	if input.DisplayName != nil {
		dn := strings.TrimSpace(*input.DisplayName)
		if dn == "" {
			return nil, fmt.Errorf("%w: display_name cannot be empty", ErrInvalidInput)
		}
		sets = append(sets, fmt.Sprintf("display_name = $%d", i))
		args = append(args, dn)
		i++
	}
	if input.Office != nil {
		if !offices.IsValid(*input.Office) {
			return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office)
		}
		sets = append(sets, fmt.Sprintf("office = $%d", i))
		args = append(args, *input.Office)
		i++
	}
	if input.JobTitle != nil {
		jt := strings.TrimSpace(*input.JobTitle)
		var val any
		if jt == "" {
			val = nil
		} else {
			val = jt
		}
		sets = append(sets, fmt.Sprintf("job_title = $%d", i))
		args = append(args, val)
		i++
	}
	if input.Profession != nil {
		// Empty string clears the column to NULL (external).
		prof := strings.TrimSpace(*input.Profession)
		var val any
		if prof == "" {
			val = nil
		} else {
			if !IsValidProfession(prof) {
				return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, prof)
			}
			val = prof
		}
		sets = append(sets, fmt.Sprintf("profession = $%d", i))
		args = append(args, val)
		i++
	}
	if input.GlobalRole != nil {
		gr := strings.TrimSpace(*input.GlobalRole)
		if gr != "standard" && gr != "global_admin" {
			return nil, fmt.Errorf("%w: invalid global_role %q (expected 'standard' or 'global_admin')", ErrInvalidInput, gr)
		}
		// Last-admin guard: refuse to demote the only remaining global_admin
		// so the firm can't lock itself out of /admin/team.
		if gr == "standard" {
			var current string
			if err := s.db.GetContext(ctx, &current,
				`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
				if errors.Is(err, sql.ErrNoRows) {
					return nil, ErrUserNotOnboarded
				}
				return nil, fmt.Errorf("lookup global_role: %w", err)
			}
			if current == "global_admin" {
				var others int
				if err := s.db.GetContext(ctx, &others,
					`SELECT count(*) FROM paliad.users
					  WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
					return nil, fmt.Errorf("count admins: %w", err)
				}
				if others == 0 {
					return nil, ErrLastGlobalAdmin
				}
			}
		}
		sets = append(sets, fmt.Sprintf("global_role = $%d", i))
		args = append(args, gr)
		i++
	}
	if input.AdditionalOffices != nil {
		// Validate each key against the canonical office list. A typo here
		// would silently break the /team filter pills for that user.
		clean := make([]string, 0, len(*input.AdditionalOffices))
		seen := map[string]bool{}
		for _, k := range *input.AdditionalOffices {
			k = strings.TrimSpace(k)
			if k == "" || seen[k] {
				continue
			}
			if !offices.IsValid(k) {
				return nil, fmt.Errorf("%w: invalid additional office %q", ErrInvalidInput, k)
			}
			seen[k] = true
			clean = append(clean, k)
		}
		sets = append(sets, fmt.Sprintf("additional_offices = $%d", i))
		args = append(args, pq.StringArray(clean))
		i++
	}
	if input.Lang != nil {
		lang := strings.ToLower(strings.TrimSpace(*input.Lang))
		if lang != "de" && lang != "en" {
			return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, *input.Lang)
		}
		sets = append(sets, fmt.Sprintf("lang = $%d", i))
		args = append(args, lang)
		i++
	}
	if input.EmailPreferences != nil {
		raw := *input.EmailPreferences
		var obj map[string]any
		if err := json.Unmarshal(raw, &obj); err != nil {
			return nil, fmt.Errorf("%w: email_preferences must be a JSON object", ErrInvalidInput)
		}
		sets = append(sets, fmt.Sprintf("email_preferences = $%d", i))
		args = append(args, []byte(raw))
		i++
	}
	if input.ReminderMorningTime != nil {
		t, err := normaliseTimeOfDay(*input.ReminderMorningTime)
		if err != nil {
			return nil, fmt.Errorf("%w: reminder_morning_time: %v", ErrInvalidInput, err)
		}
		sets = append(sets, fmt.Sprintf("reminder_morning_time = $%d", i))
		args = append(args, t)
		i++
	}
	if input.ReminderEveningTime != nil {
		t, err := normaliseTimeOfDay(*input.ReminderEveningTime)
		if err != nil {
			return nil, fmt.Errorf("%w: reminder_evening_time: %v", ErrInvalidInput, err)
		}
		sets = append(sets, fmt.Sprintf("reminder_evening_time = $%d", i))
		args = append(args, t)
		i++
	}
	if input.ReminderTimezone != nil {
		tz := strings.TrimSpace(*input.ReminderTimezone)
		if _, err := time.LoadLocation(tz); err != nil {
			return nil, fmt.Errorf("%w: invalid reminder_timezone %q", ErrInvalidInput, tz)
		}
		sets = append(sets, fmt.Sprintf("reminder_timezone = $%d", i))
		args = append(args, tz)
		i++
	}

	if len(sets) == 0 {
		return s.GetByID(ctx, id)
	}

	sets = append(sets, "updated_at = now()")
	args = append(args, id)
	query := fmt.Sprintf(
		`UPDATE paliad.users SET %s WHERE id = $%d`,
		strings.Join(sets, ", "), i,
	)
	res, err := s.db.ExecContext(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("update user: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return nil, fmt.Errorf("update user: rows affected: %w", err)
	}
	if n == 0 {
		return nil, ErrUserNotOnboarded
	}
	return s.GetByID(ctx, id)
}

// AdminDeleteUser removes the paliad.users row for the given user and any
// project_teams / partner_unit_members rows pointing at the same auth.users.id.
// auth.users itself is left intact: Supabase identity is not the admin's to
// destroy, and the user can re-onboard later if they need to.
//
// Why explicit DELETEs instead of leaning on FK CASCADE: the cascade is from
// auth.users → paliad.*; leaving auth.users alone means the cascade never
// fires. We do the membership cleanup manually so the admin gets a single
// transactional "user is gone from this product" outcome.
//
// Returns ErrUserNotOnboarded when the row is missing (already gone).
// Refuses to delete the last remaining admin so the firm doesn't lock itself
// out of its own admin UI.
func (s *UserService) AdminDeleteUser(ctx context.Context, id uuid.UUID) error {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	var globalRole string
	if err := tx.GetContext(ctx, &globalRole,
		`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return ErrUserNotOnboarded
		}
		return fmt.Errorf("lookup user: %w", err)
	}
	if globalRole == "global_admin" {
		var others int
		if err := tx.GetContext(ctx, &others,
			`SELECT count(*) FROM paliad.users WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
			return fmt.Errorf("count admins: %w", err)
		}
		if others == 0 {
			return ErrLastGlobalAdmin
		}
	}

	if _, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.project_teams WHERE user_id = $1`, id); err != nil {
		return fmt.Errorf("delete project_teams: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.partner_unit_members WHERE user_id = $1`, id); err != nil {
		return fmt.Errorf("delete partner_unit_members: %w", err)
	}
	// A partner unit this user led keeps existing — the lead seat just goes empty.
	if _, err := tx.ExecContext(ctx,
		`UPDATE paliad.partner_units SET lead_user_id = NULL WHERE lead_user_id = $1`, id); err != nil {
		return fmt.Errorf("clear partner unit leads: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.users WHERE id = $1`, id); err != nil {
		return fmt.Errorf("delete user: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit delete user: %w", err)
	}
	return nil
}

// ForumPref* are the allowed values of paliad.users.forum_pref (#15).
// CMS = UPC; beA / Posteingang = national-DE (Posteingang is the slower
// channel for the same forums). Empty string clears the preference.
const (
	ForumPrefCMS         = "cms"
	ForumPrefBeA         = "bea"
	ForumPrefPosteingang = "posteingang"
)

// isValidForumPref returns true when v is one of the allowed channel
// slugs. The DB CHECK constraint mirrors this list; we validate in the
// service layer so callers see a typed error instead of a raw pq
// constraint violation.
func isValidForumPref(v string) bool {
	switch v {
	case ForumPrefCMS, ForumPrefBeA, ForumPrefPosteingang:
		return true
	}
	return false
}

// UnonboardedAuthUser is one row in auth.users that has no matching
// paliad.users entry — i.e. the user logged in once via Supabase but never
// completed onboarding. Surfaced to admins so they can bulk-add real
// colleagues without chasing each one to fill in the form themselves.
type UnonboardedAuthUser struct {
	ID        uuid.UUID `db:"id"         json:"id"`
	Email     string    `db:"email"      json:"email"`
	CreatedAt time.Time `db:"created_at" json:"created_at"`
}

// ListUnonboardedAuthUsers returns auth.users rows with no paliad.users row.
// Sorted oldest-first so the longest-pending colleagues bubble to the top of
// the admin's "direct add" dropdown.
func (s *UserService) ListUnonboardedAuthUsers(ctx context.Context) ([]UnonboardedAuthUser, error) {
	rows := []UnonboardedAuthUser{}
	err := s.db.SelectContext(ctx, &rows,
		`SELECT a.id, lower(a.email) AS email, a.created_at
		   FROM auth.users a
		   LEFT JOIN paliad.users p ON p.id = a.id
		  WHERE p.id IS NULL
		    AND a.email IS NOT NULL
		    AND a.email <> ''
		  ORDER BY a.created_at ASC`)
	if err != nil {
		return nil, fmt.Errorf("list unonboarded: %w", err)
	}
	return rows, nil
}
