package services

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"sync"
	"time"

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

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

// CalDAVService — bidirectional CalDAV sync for paliad.appointments.
//
// Per-user goroutine model:
//   - One goroutine per user with enabled = true and a usable cipher
//   - Tick every 60s: push local Appointments, then pull remote events
//   - Spawned on Start() (server boot) and on each PUT /api/caldav-config
//   - Torn down on DELETE /api/caldav-config
//
// Conflict resolution: last-write-wins on the modified timestamp. When the
// remote etag has changed, the remote payload overwrites the local row;
// when local mutated more recently, the next push overwrites the remote.
// Akte-attached conflicts append a row to akten_events (via
// AppointmentService.LogConflict) so the audit trail records the change.
//
// Audit §1.3 fix: passwords are read from paliad.user_caldav_config in
// AES-GCM-encrypted form and decrypted only inside this service. They
// never leave the process boundary; the API never returns them; the model
// has json:"-" on PasswordEncrypted.
type CalDAVService struct {
	db           *sqlx.DB
	cipher       *CalDAVCipher
	appointments *AppointmentService
	bindings     *CalendarBindingService
	targets      *AppointmentTargetService

	mu       sync.Mutex
	cancels  map[uuid.UUID]context.CancelFunc // userID -> goroutine cancel

	discoveryMu    sync.Mutex
	discoveryCache map[uuid.UUID]discoveryCacheEntry
	rootCtx  context.Context
	rootStop context.CancelFunc
	wg       sync.WaitGroup
}

// NewCalDAVService wires the service. cipher may be nil — in that case all
// operations return ErrCalDAVNoKey and the goroutines are never spawned.
func NewCalDAVService(db *sqlx.DB, cipher *CalDAVCipher, appointments *AppointmentService, bindings *CalendarBindingService, targets *AppointmentTargetService) *CalDAVService {
	return &CalDAVService{
		db:           db,
		cipher:       cipher,
		appointments: appointments,
		bindings:     bindings,
		targets:      targets,
		cancels:      map[uuid.UUID]context.CancelFunc{},
	}
}

// Enabled reports whether the service has the encryption key needed to
// perform any CalDAV work.
func (s *CalDAVService) Enabled() bool { return s.cipher != nil }

// Start spawns one sync goroutine per user that owns at least one
// enabled binding. No-op when CALDAV_ENCRYPTION_KEY is unset.
//
// Post-Slice-2a, "enabled" is determined by paliad.user_calendar_bindings,
// not paliad.user_caldav_config — the config row holds credentials,
// the binding rows decide which calendars get events. A user with a
// disabled config row still needs no goroutine (no credentials), so
// we intersect: enabled binding AND enabled config.
func (s *CalDAVService) Start(ctx context.Context) error {
	if s.cipher == nil {
		slog.Info("CalDAV: disabled (CALDAV_ENCRYPTION_KEY unset)")
		return nil
	}
	s.rootCtx, s.rootStop = context.WithCancel(ctx)

	bindings, err := s.bindings.ListAllEnabled(ctx)
	if err != nil {
		return fmt.Errorf("list enabled caldav bindings: %w", err)
	}
	// Deduplicate by user_id — one goroutine per user, not per binding.
	seen := map[uuid.UUID]bool{}
	for _, b := range bindings {
		if seen[b.UserID] {
			continue
		}
		// Only spawn when the config row is also enabled (creds present).
		cfg, err := s.loadDecryptedConfig(ctx, b.UserID)
		if err != nil || cfg == nil || !cfg.Enabled {
			continue
		}
		seen[b.UserID] = true
		s.spawnLoop(b.UserID)
	}
	slog.Info("CalDAV: started", "users", len(seen), "bindings", len(bindings))
	return nil
}

// Stop cancels all per-user goroutines and waits for them to exit.
func (s *CalDAVService) Stop() {
	if s.rootStop != nil {
		s.rootStop()
	}
	s.wg.Wait()
}

// --- Config CRUD ---

// PublicConfig is the password-free view of UserCalDAVConfig returned by
// API endpoints. Mirrors the model without the encrypted blob.
type PublicConfig struct {
	URL           string     `json:"url"`
	Username      string     `json:"username"`
	CalendarPath  string     `json:"calendar_path"`
	Enabled       bool       `json:"enabled"`
	LastSyncAt    *time.Time `json:"last_sync_at,omitempty"`
	LastSyncError *string    `json:"last_sync_error,omitempty"`
	UpdatedAt     time.Time  `json:"updated_at"`
}

// SaveConfigInput is the upsert payload for PUT /api/caldav-config. Password
// is required on first save; an empty password on update means "keep the
// existing one".
type SaveConfigInput struct {
	URL          string `json:"url"`
	Username     string `json:"username"`
	Password     string `json:"password,omitempty"`
	CalendarPath string `json:"calendar_path"`
	Enabled      bool   `json:"enabled"`
}

// GetConfig returns the user's current CalDAV configuration or (nil, nil)
// if none exists.
func (s *CalDAVService) GetConfig(ctx context.Context, userID uuid.UUID) (*PublicConfig, error) {
	var c models.UserCalDAVConfig
	err := s.db.GetContext(ctx, &c,
		`SELECT user_id, url, username, password_encrypted, calendar_path,
		        enabled, last_sync_at, last_sync_error, created_at, updated_at,
		        supports_mkcalendar, mkcalendar_probed_at
		   FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("get caldav config: %w", err)
	}
	return &PublicConfig{
		URL:           c.URL,
		Username:      c.Username,
		CalendarPath:  c.CalendarPath,
		Enabled:       c.Enabled,
		LastSyncAt:    c.LastSyncAt,
		LastSyncError: c.LastSyncError,
		UpdatedAt:     c.UpdatedAt,
	}, nil
}

// SaveConfig upserts the user's CalDAV configuration. The password is
// encrypted before storage; the goroutine for this user is restarted so
// the next sync uses the new credentials.
func (s *CalDAVService) SaveConfig(ctx context.Context, userID uuid.UUID, in SaveConfigInput) (*PublicConfig, error) {
	if s.cipher == nil {
		return nil, ErrCalDAVNoKey
	}

	existing, err := s.GetConfig(ctx, userID)
	if err != nil {
		return nil, err
	}

	password := in.Password
	if password == "" {
		if existing == nil {
			return nil, fmt.Errorf("%w: password is required on first save", ErrInvalidInput)
		}
		// Keep existing encrypted blob.
	}

	var encrypted []byte
	if password != "" {
		encrypted, err = s.cipher.Encrypt(password)
		if err != nil {
			return nil, fmt.Errorf("encrypt password: %w", err)
		}
	}

	now := time.Now().UTC()
	if existing == nil {
		_, err = s.db.ExecContext(ctx,
			`INSERT INTO paliad.user_caldav_config
			   (user_id, url, username, password_encrypted, calendar_path,
			    enabled, created_at, updated_at)
			 VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,
			userID, in.URL, in.Username, encrypted, in.CalendarPath, in.Enabled, now)
	} else if password != "" {
		_, err = s.db.ExecContext(ctx,
			`UPDATE paliad.user_caldav_config
			    SET url = $1, username = $2, password_encrypted = $3,
			        calendar_path = $4, enabled = $5, updated_at = $6
			  WHERE user_id = $7`,
			in.URL, in.Username, encrypted, in.CalendarPath, in.Enabled, now, userID)
	} else {
		_, err = s.db.ExecContext(ctx,
			`UPDATE paliad.user_caldav_config
			    SET url = $1, username = $2, calendar_path = $3,
			        enabled = $4, updated_at = $5
			  WHERE user_id = $6`,
			in.URL, in.Username, in.CalendarPath, in.Enabled, now, userID)
	}
	if err != nil {
		return nil, fmt.Errorf("upsert caldav config: %w", err)
	}

	// First-time configure: auto-create an all_visible binding pointing
	// at the (legacy) calendar_path so Phase F's "set up CalDAV → events
	// just appear" UX survives Slice 2a. The Slice 1 backfill handled
	// existing users; this branch covers new users between Slice 2a
	// landing and the Slice 2b picker UI being available.
	if existing == nil {
		path := in.CalendarPath
		if path == "" {
			path = in.URL
		}
		if _, err := s.db.ExecContext(ctx,
			`INSERT INTO paliad.user_calendar_bindings
			   (user_id, calendar_path, display_name, scope_kind, scope_id,
			    include_personal, enabled, created_at, updated_at)
			 VALUES ($1, $2, '', 'all_visible', NULL, false, $3, $4, $4)
			 ON CONFLICT (user_id, calendar_path) DO NOTHING`,
			userID, path, in.Enabled, now); err != nil {
			slog.Warn("CalDAV: auto-create default binding failed", "user_id", userID, "error", err)
		}
	}

	// Restart the per-user goroutine so the next tick uses the new config.
	s.stopLoop(userID)
	if in.Enabled {
		s.spawnLoop(userID)
	}
	// A credential / URL change invalidates the cached discovery result —
	// the next picker open should re-PROPFIND against the new server.
	s.InvalidateDiscoveryCache(userID)

	return s.GetConfig(ctx, userID)
}

// DeleteConfig purges the user's CalDAV credentials and stops the sync
// goroutine. Audit §1.3 requirement: encrypted blob is overwritten with
// a single-row DELETE so it's gone from primary storage.
func (s *CalDAVService) DeleteConfig(ctx context.Context, userID uuid.UUID) error {
	s.stopLoop(userID)
	if _, err := s.db.ExecContext(ctx,
		`DELETE FROM paliad.user_caldav_config WHERE user_id = $1`, userID); err != nil {
		return fmt.Errorf("delete caldav config: %w", err)
	}
	s.InvalidateDiscoveryCache(userID)
	return nil
}

// TestConnection performs an ad-hoc PROPFIND with the supplied credentials
// and reports whether they work. Doesn't persist anything. Returns the
// raw error so the UI can surface it.
func (s *CalDAVService) TestConnection(ctx context.Context, in SaveConfigInput) error {
	if in.URL == "" || in.Username == "" {
		return fmt.Errorf("%w: url and username are required", ErrInvalidInput)
	}
	password := in.Password
	if password == "" {
		// Allow "test stored password" by re-decrypting current config.
		uid, ok := userIDFromCtx(ctx)
		if !ok {
			return fmt.Errorf("%w: password required when not logged in", ErrInvalidInput)
		}
		stored, err := s.decryptedPassword(ctx, uid)
		if err != nil {
			return err
		}
		password = stored
	}
	calendarPath := in.CalendarPath
	if calendarPath == "" {
		calendarPath = in.URL
	}
	cli := newCalDAVClient(in.URL, in.Username, password)
	return cli.PropfindRoot(ctx, calendarPath)
}

// SyncLog returns the last `n` sync attempts for the user.
func (s *CalDAVService) SyncLog(ctx context.Context, userID uuid.UUID, n int) ([]models.CalDAVSyncLogEntry, error) {
	if n <= 0 || n > 50 {
		n = 5
	}
	var rows []models.CalDAVSyncLogEntry
	if err := s.db.SelectContext(ctx, &rows,
		`SELECT id, user_id, occurred_at, direction, items_pushed,
		        items_pulled, error, duration_ms
		   FROM paliad.caldav_sync_log
		  WHERE user_id = $1
		  ORDER BY occurred_at DESC
		  LIMIT $2`, userID, n); err != nil {
		return nil, fmt.Errorf("select sync log: %w", err)
	}
	return rows, nil
}

// --- Discovery (Slice 2b) ---

// DiscoveredCalendars is the response shape of /api/caldav-discover.
// SupportsMKCalendar is nil-on-unprobed and TRUE/FALSE after the
// Slice 2c probe runs (lazily on the first discovery call).
type DiscoveredCalendars struct {
	Calendars          []DiscoveredCalendarOut `json:"calendars"`
	CalendarHome       string                  `json:"calendar_home,omitempty"`
	SupportsMKCalendar *bool                   `json:"supports_mkcalendar,omitempty"`
}

// DiscoveredCalendarOut is the JSON shape returned to the picker.
type DiscoveredCalendarOut struct {
	Href                string   `json:"href"`
	DisplayName         string   `json:"display_name"`
	SupportedComponents []string `json:"supported_components,omitempty"`
}

type discoveryCacheEntry struct {
	value     DiscoveredCalendars
	expiresAt time.Time
}

// DiscoverCalendars walks the calendar-home-set chain and returns the
// user's calendars. Cached 5 minutes per user (Q4 of the Slice 2 brief)
// so repeated picker opens don't hammer the CalDAV server.
func (s *CalDAVService) DiscoverCalendars(ctx context.Context, userID uuid.UUID) (DiscoveredCalendars, error) {
	if s.cipher == nil {
		return DiscoveredCalendars{}, ErrCalDAVNoKey
	}
	s.discoveryMu.Lock()
	if entry, ok := s.discoveryCache[userID]; ok && time.Now().Before(entry.expiresAt) {
		s.discoveryMu.Unlock()
		return entry.value, nil
	}
	s.discoveryMu.Unlock()

	cfg, err := s.loadDecryptedConfig(ctx, userID)
	if err != nil {
		return DiscoveredCalendars{}, err
	}
	if cfg == nil {
		return DiscoveredCalendars{}, fmt.Errorf("%w: no CalDAV config", ErrInvalidInput)
	}
	cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
	cals, home, err := cli.DiscoverCalendars(ctx, cfg.URL)
	if err != nil {
		return DiscoveredCalendars{}, err
	}
	out := DiscoveredCalendars{
		Calendars:    make([]DiscoveredCalendarOut, 0, len(cals)),
		CalendarHome: home,
	}
	for _, c := range cals {
		out.Calendars = append(out.Calendars, DiscoveredCalendarOut{
			Href:                c.Href,
			DisplayName:         c.DisplayName,
			SupportedComponents: c.SupportedComponents,
		})
	}

	// Slice 2c — lazy MKCALENDAR capability probe. If we've never
	// probed this user (supports_mkcalendar IS NULL) and we just
	// learned the calendar-home-set URL, run the probe and stash
	// the result. Probe failure is non-fatal — the field just stays
	// NULL and the next discovery will retry.
	if home != "" {
		probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
		if perr == nil {
			out.SupportsMKCalendar = probed
		}
	}

	s.discoveryMu.Lock()
	if s.discoveryCache == nil {
		s.discoveryCache = map[uuid.UUID]discoveryCacheEntry{}
	}
	s.discoveryCache[userID] = discoveryCacheEntry{
		value:     out,
		expiresAt: time.Now().Add(5 * time.Minute),
	}
	s.discoveryMu.Unlock()
	return out, nil
}

// InvalidateDiscoveryCache clears the cached discovery result for the
// user. Called from SaveConfig so a credential change forces a fresh
// PROPFIND on the next picker open. Also clears the persisted
// supports_mkcalendar capability since credentials may point at a
// different server.
func (s *CalDAVService) InvalidateDiscoveryCache(userID uuid.UUID) {
	s.discoveryMu.Lock()
	delete(s.discoveryCache, userID)
	s.discoveryMu.Unlock()
	_, _ = s.db.ExecContext(context.Background(),
		`UPDATE paliad.user_caldav_config
		    SET supports_mkcalendar = NULL, mkcalendar_probed_at = NULL
		  WHERE user_id = $1`, userID)
}

// ensureMKCalendarProbed runs the probe iff the user_caldav_config
// row's supports_mkcalendar is NULL. Returns the cached value when set.
// Probe failure leaves the column NULL (next call retries) and returns
// (nil, err) — caller treats that as "unknown" and omits the field
// from the JSON response.
func (s *CalDAVService) ensureMKCalendarProbed(ctx context.Context, userID uuid.UUID, homePath string, cli *calDAVClient) (*bool, error) {
	var current *bool
	if err := s.db.GetContext(ctx, &current,
		`SELECT supports_mkcalendar
		   FROM paliad.user_caldav_config
		  WHERE user_id = $1`, userID); err != nil {
		return nil, fmt.Errorf("read mkcalendar capability: %w", err)
	}
	if current != nil {
		return current, nil
	}
	ok, err := cli.ProbeMKCalendar(ctx, homePath)
	if err != nil {
		// Don't persist a negative on a transient error — leave NULL so
		// the next round retries; just surface "unknown" to caller.
		return nil, err
	}
	if _, err := s.db.ExecContext(ctx,
		`UPDATE paliad.user_caldav_config
		    SET supports_mkcalendar = $1, mkcalendar_probed_at = NOW(),
		        updated_at = NOW()
		  WHERE user_id = $2`, ok, userID); err != nil {
		slog.Warn("CalDAV: persist mkcalendar capability failed", "user_id", userID, "error", err)
	}
	return &ok, nil
}

// CreateCalendarInput is the payload for POST /api/caldav-mkcalendar.
type CreateCalendarInput struct {
	DisplayName     string     `json:"display_name"`
	ScopeKind       string     `json:"scope_kind"`
	ScopeID         *uuid.UUID `json:"scope_id,omitempty"`
	IncludePersonal bool       `json:"include_personal"`
}

// CreateCalendarResult is the response shape for POST
// /api/caldav-mkcalendar — the new remote calendar path plus the
// binding that was created in the same transaction.
type CreateCalendarResult struct {
	CalendarPath string                       `json:"calendar_path"`
	Binding      *models.UserCalendarBinding  `json:"binding"`
	InitialPushed int                         `json:"initial_pushed"`
}

// MakeCalendar issues MKCALENDAR against the user's calendar-home-set,
// then creates a paliad.user_calendar_bindings row pointing at the new
// path. On MKCALENDAR success but binding-create failure we leave the
// remote calendar in place; on MKCALENDAR failure we never touch the
// binding table. Slice 2c API.
//
// Errors:
//   - ErrCalDAVNoKey when cipher is missing.
//   - ErrInvalidInput on missing display name / disallowed scope.
//   - ErrMKCalendarUnsupported (or persisted supports_mkcalendar=false)
//     → the caller maps this to 501.
//   - ErrCalendarNameTaken → 409.
func (s *CalDAVService) MakeCalendar(ctx context.Context, userID uuid.UUID, in CreateCalendarInput) (*CreateCalendarResult, error) {
	if s.cipher == nil {
		return nil, ErrCalDAVNoKey
	}
	if strings.TrimSpace(in.DisplayName) == "" {
		return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
	}
	cfg, err := s.loadDecryptedConfig(ctx, userID)
	if err != nil {
		return nil, err
	}
	if cfg == nil {
		return nil, fmt.Errorf("%w: no CalDAV config", ErrInvalidInput)
	}
	cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)

	// Discover home + probe capability if we haven't yet.
	_, home, err := cli.DiscoverCalendars(ctx, cfg.URL)
	if err != nil {
		return nil, fmt.Errorf("discover home: %w", err)
	}
	probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
	if perr != nil {
		return nil, fmt.Errorf("probe mkcalendar: %w", perr)
	}
	if probed == nil || !*probed {
		return nil, ErrMKCalendarUnsupported
	}

	// Try the slugified name; retry with -N suffix on 405 (name taken).
	slug := slugifyCalendarName(in.DisplayName)
	if slug == "" {
		slug = "paliad-" + randomToken(3)
	}
	path, err := s.mkcalendarWithRetry(ctx, cli, home, slug, in.DisplayName)
	if err != nil {
		return nil, err
	}

	// Create the matching binding row.
	binding, err := s.bindings.Create(ctx, userID, CreateBindingInput{
		CalendarPath:    path,
		DisplayName:     in.DisplayName,
		ScopeKind:       in.ScopeKind,
		ScopeID:         in.ScopeID,
		IncludePersonal: in.IncludePersonal,
		Enabled:         true,
	})
	if err != nil {
		// MKCALENDAR already succeeded — the remote calendar exists.
		// Don't try to clean it up: the user can re-bind via the
		// custom-URL path the next time. Returning the error surfaces
		// the validation failure to the caller.
		return nil, err
	}

	// Synchronous first push so the user sees events immediately
	// (matches POST /api/caldav-bindings Q5 semantics).
	pushed, pushErr := s.PushBindingNow(ctx, userID, binding)
	if pushErr != nil {
		// Binding row exists; push failed — surface both bits.
		return &CreateCalendarResult{
			CalendarPath: path,
			Binding:      binding,
			InitialPushed: pushed,
		}, pushErr
	}
	s.EnsureLoop(userID)
	return &CreateCalendarResult{
		CalendarPath: path,
		Binding:      binding,
		InitialPushed: pushed,
	}, nil
}

// mkcalendarWithRetry issues MKCALENDAR with up to 3 disambiguating
// suffixes when the server returns 405 (name taken). Gives up after
// 3 tries with the original error so the UI can ask the user to type
// their own name.
func (s *CalDAVService) mkcalendarWithRetry(ctx context.Context, cli *calDAVClient, home, slug, displayName string) (string, error) {
	tryName := slug
	for attempt := 0; attempt < 3; attempt++ {
		path, err := cli.MakeCalendar(ctx, home, tryName, displayName)
		if err == nil {
			return path, nil
		}
		if !errors.Is(err, ErrCalendarNameTaken) {
			return "", err
		}
		tryName = slug + "-" + randomToken(2)
	}
	return "", ErrCalendarNameTaken
}

// slugifyCalendarName turns "Acme v Bosch" → "acme-v-bosch". Trims to
// 32 chars to keep the URL readable. Empty input → empty output (the
// caller falls back to a random token).
func slugifyCalendarName(name string) string {
	const maxLen = 32
	var b strings.Builder
	prevDash := true
	for _, r := range name {
		switch {
		case r >= 'A' && r <= 'Z':
			b.WriteRune(r - 'A' + 'a')
			prevDash = false
		case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
			b.WriteRune(r)
			prevDash = false
		case prevDash:
			// skip
		default:
			b.WriteRune('-')
			prevDash = true
		}
		if b.Len() >= maxLen {
			break
		}
	}
	return strings.Trim(b.String(), "-")
}

// --- Binding lifecycle hooks (Slice 2b) ---

// PushBindingNow runs one push pass for a single binding synchronously.
// Called by POST /api/caldav-bindings so the user sees "Initial sync
// running…" with events already pushed by the time the modal closes.
//
// Best effort: per-event PUT errors are logged but don't fail the call.
// Returns (itemsPushed, err) where err is only set when the surrounding
// config / credentials are broken (no creds, no cipher, decrypt failure).
func (s *CalDAVService) PushBindingNow(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
	if s.cipher == nil {
		return 0, ErrCalDAVNoKey
	}
	cfg, err := s.loadDecryptedConfig(ctx, userID)
	if err != nil {
		return 0, err
	}
	if cfg == nil {
		return 0, fmt.Errorf("%w: no CalDAV config — configure server first", ErrInvalidInput)
	}
	if !cfg.Enabled {
		return 0, nil
	}
	cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
	pushed, err := s.pushBinding(ctx, cli, userID, b)
	if err != nil {
		// Record the error against the binding so the UI can surface it
		// without waiting for the next tick.
		es := err.Error()
		_ = s.bindings.SetSyncStatus(ctx, b.ID, &es)
		return pushed, err
	}
	_ = s.bindings.SetSyncStatus(ctx, b.ID, nil)
	return pushed, nil
}

// RemoveBinding implements the §2.6 cleanup-on-delete flow from the
// Slice 2 brief: list every target attached to the binding, issue a
// remote DELETE per .ics (best-effort), then drop the binding row.
// Target rows cascade away with the binding row via ON DELETE CASCADE.
//
// On a partial failure (some remote DELETEs fail), the binding is
// flipped to enabled=false instead of being dropped so the next sync
// tick can retry the remaining cleanup. The caller (HTTP handler)
// surfaces that as 202 Accepted.
func (s *CalDAVService) RemoveBinding(ctx context.Context, userID, bindingID uuid.UUID) (bool, error) {
	b, err := s.bindings.Get(ctx, userID, bindingID)
	if err != nil {
		return false, err
	}

	// Best effort: even when cipher / config is missing, drop the row.
	// Without creds we can't reach the remote calendar; leaving the
	// binding row in place would block re-add at the same path.
	deletedFully := true
	if s.cipher != nil {
		if cfg, cerr := s.loadDecryptedConfig(ctx, userID); cerr == nil && cfg != nil && cfg.Enabled {
			cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
			targets, terr := s.targets.ListForBinding(ctx, bindingID)
			if terr != nil {
				slog.Warn("CalDAV: remove-binding list targets failed", "binding_id", bindingID, "error", terr)
			}
			for _, t := range targets {
				if err := cli.DeleteEvent(ctx, b.CalendarPath, t.CalDAVUID); err != nil {
					slog.Warn("CalDAV: remove-binding remote delete failed", "binding_id", bindingID, "uid", t.CalDAVUID, "error", err)
					deletedFully = false
				}
			}
		} else {
			// No creds; treat as "remote untouched" and surface to caller.
			deletedFully = false
		}
	} else {
		deletedFully = false
	}

	if !deletedFully {
		// Flip enabled=false so a subsequent tick (after creds are fixed)
		// can resume the cleanup. The binding stays around so the user
		// can see it in the picker with a "needs cleanup" status.
		falseB := false
		if _, err := s.bindings.Update(ctx, userID, bindingID, UpdateBindingInput{Enabled: &falseB}); err != nil {
			return false, err
		}
		return false, nil
	}

	if err := s.bindings.Delete(ctx, userID, bindingID); err != nil {
		return false, err
	}
	return true, nil
}

// EnsureLoop spawns the per-user sync goroutine if it isn't running.
// Called after POST /api/caldav-bindings creates a binding for a user
// who didn't have any enabled bindings before this request.
func (s *CalDAVService) EnsureLoop(userID uuid.UUID) {
	s.spawnLoop(userID)
}

// --- Per-user sync goroutine ---

func (s *CalDAVService) spawnLoop(userID uuid.UUID) {
	if s.cipher == nil || s.rootCtx == nil {
		return
	}
	s.mu.Lock()
	if _, exists := s.cancels[userID]; exists {
		s.mu.Unlock()
		return
	}
	ctx, cancel := context.WithCancel(s.rootCtx)
	s.cancels[userID] = cancel
	s.mu.Unlock()

	s.wg.Add(1)
	go func() {
		defer s.wg.Done()
		s.userLoop(ctx, userID)
	}()
}

func (s *CalDAVService) stopLoop(userID uuid.UUID) {
	s.mu.Lock()
	cancel, ok := s.cancels[userID]
	if ok {
		delete(s.cancels, userID)
	}
	s.mu.Unlock()
	if ok {
		cancel()
	}
}

func (s *CalDAVService) userLoop(ctx context.Context, userID uuid.UUID) {
	t := time.NewTicker(60 * time.Second)
	defer t.Stop()

	// First sync runs immediately so the user sees results without waiting.
	s.runSyncOnce(ctx, userID)

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			s.runSyncOnce(ctx, userID)
		}
	}
}

// runSyncOnce runs every enabled binding for a user, writing one
// caldav_sync_log row per binding (binding_id populated post-Slice-2a)
// and rolling the per-user last_sync_at into user_caldav_config so the
// existing /api/caldav-config endpoint still shows aggregate status.
func (s *CalDAVService) runSyncOnce(ctx context.Context, userID uuid.UUID) {
	cfg, err := s.loadDecryptedConfig(ctx, userID)
	if err != nil || cfg == nil || !cfg.Enabled {
		return
	}
	cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)

	bindings, err := s.bindings.ListEnabled(ctx, userID)
	if err != nil {
		slog.Warn("CalDAV: list bindings failed", "user_id", userID, "error", err)
		return
	}
	if len(bindings) == 0 {
		return
	}

	var lastErr *string
	for i := range bindings {
		b := &bindings[i]
		start := time.Now()
		pushed, pulled, syncErr := s.syncBinding(ctx, cli, userID, b)
		dur := int(time.Since(start) / time.Millisecond)

		var errStr *string
		if syncErr != nil {
			es := syncErr.Error()
			errStr = &es
			lastErr = errStr
			slog.Warn("CalDAV: binding sync failed", "user_id", userID, "binding_id", b.ID, "error", es)
		}
		if err := s.bindings.SetSyncStatus(ctx, b.ID, errStr); err != nil {
			slog.Warn("CalDAV: write binding sync status", "binding_id", b.ID, "error", err)
		}
		if _, err := s.db.ExecContext(ctx,
			`INSERT INTO paliad.caldav_sync_log
			   (user_id, binding_id, direction, items_pushed, items_pulled, error, duration_ms)
			 VALUES ($1, $2, 'both', $3, $4, $5, $6)`,
			userID, b.ID, pushed, pulled, errStr, dur); err != nil {
			slog.Warn("CalDAV: write sync log", "error", err)
		}
	}

	// Roll the worst-case per-binding error up onto user_caldav_config so
	// the existing /api/caldav-config status surface still reflects sync
	// health. last_sync_error = NULL means "every binding synced cleanly".
	if _, err := s.db.ExecContext(ctx,
		`UPDATE paliad.user_caldav_config
		    SET last_sync_at = NOW(), last_sync_error = $1
		  WHERE user_id = $2`, lastErr, userID); err != nil {
		slog.Warn("CalDAV: write last_sync_at", "error", err)
	}
	// Trim sync log to last 5 entries per (user, binding) so multi-binding
	// users don't accumulate noise. Per-binding trim keeps each binding's
	// own history independently inspectable.
	if _, err := s.db.ExecContext(ctx,
		`DELETE FROM paliad.caldav_sync_log
		  WHERE user_id = $1
		    AND id NOT IN (
		      SELECT id FROM paliad.caldav_sync_log
		       WHERE user_id = $1
		       ORDER BY occurred_at DESC LIMIT 25
		    )`, userID); err != nil {
		slog.Warn("CalDAV: trim sync log", "error", err)
	}
}

// syncBinding runs one push + one pull for a single binding.
func (s *CalDAVService) syncBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, int, error) {
	pushed, pushErr := s.pushBinding(ctx, cli, userID, b)
	pulled, pullErr := s.pullBinding(ctx, cli, userID, b)

	if pushErr != nil && pullErr != nil {
		return pushed, pulled, fmt.Errorf("push: %v; pull: %v", pushErr, pullErr)
	}
	if pushErr != nil {
		return pushed, pulled, fmt.Errorf("push: %w", pushErr)
	}
	if pullErr != nil {
		return pushed, pulled, fmt.Errorf("pull: %w", pullErr)
	}
	return pushed, pulled, nil
}

// pushBinding uploads every in-scope Appointment to one binding's calendar
// and cleans up target rows for events that have left the scope. Best
// effort: a single failed PUT logs and continues; the next tick retries.
func (s *CalDAVService) pushBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
	appointments, err := s.appointments.ForBinding(ctx, userID, b)
	if err != nil {
		if errors.Is(err, ErrUnsupportedScope) {
			// Slice 2a sees a Slice-3 scope_kind. Skip cleanly — the UI
			// shouldn't have let the user create this binding, but a
			// direct SQL insert could; we don't want to crash the loop.
			return 0, nil
		}
		return 0, err
	}
	inScope := make(map[uuid.UUID]struct{}, len(appointments))
	pushed := 0
	for i := range appointments {
		t := &appointments[i]
		inScope[t.ID] = struct{}{}
		body := formatAppointment(t)
		uid := terminUID(t.ID.String())
		etag, err := cli.PutEvent(ctx, b.CalendarPath, uid, body)
		if err != nil {
			slog.Warn("CalDAV: push appointment failed", "binding_id", b.ID, "id", t.ID, "error", err)
			continue
		}
		if err := s.targets.UpsertAfterPush(ctx, t.ID, b.ID, uid, etag); err != nil {
			slog.Warn("CalDAV: write target failed", "binding_id", b.ID, "id", t.ID, "error", err)
			continue
		}
		// Back-compat: keep the scalar uid/etag on the appointment row
		// fresh as a denormalised pointer to "some" target. Other code
		// paths still read these in Slice 2a; Slice 4 drops them.
		if err := s.appointments.SetCalDAVMeta(ctx, t.ID, uid, etag); err != nil {
			slog.Warn("CalDAV: write meta failed", "id", t.ID, "error", err)
		}
		pushed++
	}

	// Stale-target cleanup: events that used to be in this binding's
	// scope but no longer are (project unshared, scope_kind PATCHed)
	// get DELETEd from the remote calendar so the user's calendar app
	// doesn't show ghost events.
	currentIDs := make([]uuid.UUID, 0, len(appointments))
	for id := range inScope {
		currentIDs = append(currentIDs, id)
	}
	stale, err := s.targets.StaleForBinding(ctx, b.ID, currentIDs)
	if err != nil {
		slog.Warn("CalDAV: stale-target lookup failed", "binding_id", b.ID, "error", err)
		return pushed, nil
	}
	for _, t := range stale {
		if err := cli.DeleteEvent(ctx, b.CalendarPath, t.CalDAVUID); err != nil {
			slog.Warn("CalDAV: stale delete failed", "binding_id", b.ID, "uid", t.CalDAVUID, "error", err)
			continue
		}
		if err := s.targets.DeleteByAppointmentAndBinding(ctx, t.AppointmentID, b.ID); err != nil {
			slog.Warn("CalDAV: stale target delete failed", "binding_id", b.ID, "uid", t.CalDAVUID, "error", err)
		}
	}
	return pushed, nil
}

// pullBinding inspects the remote calendar for one binding and reconciles
// local Appointments using REPORT calendar-multiget. UIDs outside the
// Paliad namespace (paliad-appointment-*@paliad.de) are ignored.
//
// Reconciliation rules (per (appointment, binding) target row):
//   - UID matches a known Appointment + ETag changed → ApplyRemoteUpdate
//     and upsert target.caldav_etag
//   - UID matches a known Appointment + ETag unchanged → no-op
//   - Locally-known UID NOT in remote list → DeleteByCalDAVUID + drop
//     target row
//
// Foreign-UID events are intentionally not imported — Paliad "owns" its
// UIDs.
func (s *CalDAVService) pullBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
	entries, err := cli.PropfindCalendar(ctx, b.CalendarPath)
	if err != nil {
		return 0, err
	}

	// Diff against stored target etags. Hrefs whose etag matches the
	// stored target are skipped; others get fetched via multiget in
	// one round-trip.
	storedByUID := map[string]models.AppointmentCalDAVTarget{}
	storedTargets, err := s.targets.ListForBinding(ctx, b.ID)
	if err == nil {
		for _, t := range storedTargets {
			storedByUID[t.CalDAVUID] = t
		}
	}
	hrefsToFetch := make([]string, 0, len(entries))
	for _, e := range entries {
		// We can't determine UID from href alone; fetch every entry
		// whose etag we haven't seen. The first multiget seeds the
		// uid↔href mapping for future ticks.
		hrefsToFetch = append(hrefsToFetch, e.Href)
	}

	multi, err := cli.ReportMultiget(ctx, b.CalendarPath, hrefsToFetch)
	if err != nil {
		return 0, err
	}

	remoteByUID := map[string]MultigetEvent{}
	pulled := 0

	for _, m := range multi {
		events, err := parseICalendar(m.CalendarData)
		if err != nil {
			slog.Warn("CalDAV: parse event failed", "href", m.Href, "binding_id", b.ID, "error", err)
			continue
		}
		for _, ev := range events {
			id := extractAppointmentID(ev.UID)
			if id == "" {
				continue
			}
			remoteByUID[ev.UID] = m

			if _, err := uuid.Parse(id); err != nil {
				continue
			}

			// Per-binding etag check: if we have a stored target with
			// the same etag, nothing changed remotely for this binding.
			if stored, ok := storedByUID[ev.UID]; ok && stored.CalDAVEtag != nil && *stored.CalDAVEtag == m.ETag {
				continue
			}

			local, err := s.appointments.FindByCalDAVUID(ctx, ev.UID)
			if err != nil {
				continue // local row not yet created or deleted
			}
			if local.CreatedBy == nil || *local.CreatedBy != userID {
				// Pulled an event owned by another user — that user's own
				// goroutine reconciles it against their own binding(s).
				continue
			}
			var titlePtr, descPtr, locPtr *string
			if ev.Summary != "" {
				ts := ev.Summary
				titlePtr = &ts
			}
			if ev.Description != "" {
				ds := ev.Description
				descPtr = &ds
			}
			if ev.Location != "" {
				ls := ev.Location
				locPtr = &ls
			}
			changed, err := s.appointments.ApplyRemoteUpdate(ctx, local.ID, titlePtr, descPtr, locPtr, ev.DTStart, ev.DTEnd, m.ETag)
			if err != nil {
				slog.Warn("CalDAV: apply remote update failed", "id", local.ID, "binding_id", b.ID, "error", err)
				continue
			}
			if err := s.targets.UpsertAfterPush(ctx, local.ID, b.ID, ev.UID, m.ETag); err != nil {
				slog.Warn("CalDAV: refresh target etag failed", "id", local.ID, "binding_id", b.ID, "error", err)
			}
			if changed {
				_ = s.appointments.LogConflict(ctx, local.ID, "Appointment from external calendar synced (last-write-wins)")
			}
			pulled++
		}
	}

	// Detect remote deletions: target rows for this binding whose UID
	// is no longer in the remote list. Delete the Appointment locally
	// (matches Phase F semantics) and drop the target row.
	for uidStr, t := range storedByUID {
		if _, ok := remoteByUID[uidStr]; ok {
			continue
		}
		if err := s.appointments.DeleteByCalDAVUID(ctx, uidStr); err != nil {
			slog.Warn("CalDAV: pull-delete failed", "uid", uidStr, "binding_id", b.ID, "error", err)
			continue
		}
		if err := s.targets.DeleteByAppointmentAndBinding(ctx, t.AppointmentID, b.ID); err != nil {
			slog.Warn("CalDAV: pull-delete target cleanup failed", "uid", uidStr, "binding_id", b.ID, "error", err)
		}
		pulled++
	}
	return pulled, nil
}

// --- Push hooks (AppointmentCalDAVPusher) ---

// OnAppointmentCreated, OnAppointmentUpdated, OnAppointmentDeleted satisfy the
// AppointmentCalDAVPusher interface. They schedule a one-shot best-effort sync
// for the relevant user on a fresh background goroutine so the
// user-facing request returns immediately.
func (s *CalDAVService) OnAppointmentCreated(_ context.Context, userID uuid.UUID, t *models.Appointment) {
	s.fireSync(userID, t, "create")
}

func (s *CalDAVService) OnAppointmentUpdated(_ context.Context, userID uuid.UUID, t *models.Appointment) {
	s.fireSync(userID, t, "update")
}

// OnAppointmentDeleted fires a DELETE against every binding that
// previously held a target for this appointment, regardless of current
// scope — the appointment is gone from Paliad and shouldn't survive in
// any user calendar.
func (s *CalDAVService) OnAppointmentDeleted(_ context.Context, userID uuid.UUID, t *models.Appointment) {
	if !s.Enabled() {
		return
	}
	appointmentID := t.ID
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		cfg, err := s.loadDecryptedConfig(ctx, userID)
		if err != nil || cfg == nil || !cfg.Enabled {
			return
		}
		cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
		uid := terminUID(appointmentID.String())

		bindings, err := s.bindings.ListEnabled(ctx, userID)
		if err != nil {
			slog.Warn("CalDAV: hook list bindings failed", "user_id", userID, "error", err)
			return
		}
		for i := range bindings {
			b := &bindings[i]
			if err := cli.DeleteEvent(ctx, b.CalendarPath, uid); err != nil {
				slog.Warn("CalDAV: hook delete failed", "uid", uid, "binding_id", b.ID, "error", err)
			}
			if err := s.targets.DeleteByAppointmentAndBinding(ctx, appointmentID, b.ID); err != nil {
				slog.Warn("CalDAV: hook target cleanup failed", "uid", uid, "binding_id", b.ID, "error", err)
			}
		}
	}()
}

// fireSync pushes an appointment to every binding whose ForBinding()
// scope includes it. Best-effort per binding, identical 30s-timeout
// background goroutine to the Phase F single-binding hook.
func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Appointment, op string) {
	if !s.Enabled() {
		return
	}
	appointmentID := t.ID
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		cfg, err := s.loadDecryptedConfig(ctx, userID)
		if err != nil || cfg == nil || !cfg.Enabled {
			return
		}
		cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
		uid := terminUID(appointmentID.String())
		body := formatAppointment(t)

		bindings, err := s.bindings.ListEnabled(ctx, userID)
		if err != nil {
			slog.Warn("CalDAV: hook list bindings failed", "op", op, "user_id", userID, "error", err)
			return
		}
		for i := range bindings {
			b := &bindings[i]
			inScope, err := s.appointmentInBinding(ctx, userID, b, appointmentID)
			if err != nil {
				slog.Warn("CalDAV: hook scope check failed", "op", op, "binding_id", b.ID, "error", err)
				continue
			}
			if !inScope {
				continue
			}
			etag, err := cli.PutEvent(ctx, b.CalendarPath, uid, body)
			if err != nil {
				slog.Warn("CalDAV: hook push failed", "op", op, "id", appointmentID, "binding_id", b.ID, "error", err)
				continue
			}
			if err := s.targets.UpsertAfterPush(ctx, appointmentID, b.ID, uid, etag); err != nil {
				slog.Warn("CalDAV: hook target write failed", "id", appointmentID, "binding_id", b.ID, "error", err)
			}
		}
	}()
}

// appointmentInBinding returns true when the binding's ForBinding()
// scope contains the given appointment. Implemented by reusing
// ForBinding so the rules stay in one place; for personal/project
// bindings the result set is small so the linear scan is fine.
func (s *CalDAVService) appointmentInBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding, appointmentID uuid.UUID) (bool, error) {
	appointments, err := s.appointments.ForBinding(ctx, userID, b)
	if err != nil {
		if errors.Is(err, ErrUnsupportedScope) {
			return false, nil
		}
		return false, err
	}
	for i := range appointments {
		if appointments[i].ID == appointmentID {
			return true, nil
		}
	}
	return false, nil
}

// --- Internal helpers ---

type decryptedConfig struct {
	URL          string
	Username     string
	Password     string
	CalendarPath string
	Enabled      bool
}

func (s *CalDAVService) loadDecryptedConfig(ctx context.Context, userID uuid.UUID) (*decryptedConfig, error) {
	if s.cipher == nil {
		return nil, ErrCalDAVNoKey
	}
	var c models.UserCalDAVConfig
	err := s.db.GetContext(ctx, &c,
		`SELECT user_id, url, username, password_encrypted, calendar_path,
		        enabled, last_sync_at, last_sync_error, created_at, updated_at,
		        supports_mkcalendar, mkcalendar_probed_at
		   FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	pw, err := s.cipher.Decrypt(c.PasswordEncrypted)
	if err != nil {
		return nil, fmt.Errorf("decrypt caldav password for %s: %w", userID, err)
	}
	return &decryptedConfig{
		URL:          c.URL,
		Username:     c.Username,
		Password:     pw,
		CalendarPath: c.CalendarPath,
		Enabled:      c.Enabled,
	}, nil
}

func (s *CalDAVService) decryptedPassword(ctx context.Context, userID uuid.UUID) (string, error) {
	cfg, err := s.loadDecryptedConfig(ctx, userID)
	if err != nil {
		return "", err
	}
	if cfg == nil {
		return "", fmt.Errorf("%w: no stored CalDAV config", ErrInvalidInput)
	}
	return cfg.Password, nil
}

// userIDFromCtx is used by TestConnection. The handler stores the userID
// in the context; we look it up here to avoid changing the function
// signature on every caller. Defined as a private hook so tests can
// override it.
type ctxKey string

const ctxKeyCalDAVUser ctxKey = "caldav-user-id"

// WithCalDAVUser annotates a context with the userID for downstream
// CalDAVService calls that need it (TestConnection).
func WithCalDAVUser(ctx context.Context, userID uuid.UUID) context.Context {
	return context.WithValue(ctx, ctxKeyCalDAVUser, userID)
}

func userIDFromCtx(ctx context.Context) (uuid.UUID, bool) {
	v, ok := ctx.Value(ctxKeyCalDAVUser).(uuid.UUID)
	return v, ok
}
