package backend

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os/exec"
	"path"
	"slices"
	"strings"
	"time"

	"github.com/emersion/go-ical"
	"github.com/emersion/go-vcard"
	"github.com/emersion/go-webdav"
	"github.com/emersion/go-webdav/caldav"
	"github.com/emersion/go-webdav/carddav"
	"github.com/jackc/pgx/v5"

	"github.com/jackc/pgx/v5/pgxpool"
	"golang.org/x/crypto/bcrypt"
	"nxcaldav/internal/config"
)

// for ICS access
type publicInfo struct {
	InternalPath string
	Mode         string // e.g., "future-only"
}

// DBBackend = caldav.Backend interface (PostgreSQL) 
type DBBackend struct {
	pool          *pgxpool.Pool                // Connection pool to PostgreSQL
	prefix        string                       // Public URL base path prefix
	publicURL     string                       // Full public URL base (e.g. http://dav.example.com/)
	redactionText string                       // Text used to hide confidential event details (e.g. "[REDACED]")
	defaultClass  string                       // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL")
	emailDomain   string                       // Domain for email addresses (e.g., "example.com" if email is tony@example.com)
	smtp          config.SMTPConfig            
	aggregates    map[string]*config.Aggregate // In-memory maps...
	userAggs      map[string][]string
	publicAccess  map[string]publicInfo
}

func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
	pool, err := pgxpool.New(ctx, cfg.Database.URL)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to postgres: %v", err)
	}

	b := &DBBackend{
		pool:          pool,
		prefix:        cfg.Server.BasePath(),
		publicURL:     cfg.Server.PublicURL,
		redactionText: cfg.Server.Redaction,
		defaultClass:  cfg.Server.DefaultClass,
		emailDomain:   cfg.Server.EmailDomain,
		smtp:          cfg.SMTP,
		aggregates:    make(map[string]*config.Aggregate),
		userAggs:      make(map[string][]string),
		publicAccess:  make(map[string]publicInfo),
	}

	// Step 1: Ensure database tables are ready
	if err := b.initSchema(ctx); err != nil {
		return nil, err
	}

	// Step 2: Sync users/calendars from YAML to DB
	if err := b.syncConfig(ctx, cfg); err != nil {
		return nil, err
	}

	return b, nil
}

// initSchema runs the DDL queries to create the necessary tables if they don't exist.
// it uses ON DELETE CASCADE extensively
func (b *DBBackend) initSchema(ctx context.Context) error {
	queries := []string{
		`CREATE TABLE IF NOT EXISTS users (
			id SERIAL PRIMARY KEY,
			name TEXT UNIQUE NOT NULL,
			password TEXT NOT NULL
		)`,
		`CREATE TABLE IF NOT EXISTS calendars (
			id SERIAL PRIMARY KEY,
			owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
			path TEXT UNIQUE NOT NULL,
			name TEXT,
			description TEXT,
			color TEXT
		)`,
		`CREATE TABLE IF NOT EXISTS calendar_access (
			calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
			user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
			mode TEXT NOT NULL,
			PRIMARY KEY (calendar_id, user_id)
		)`,
		`CREATE TABLE IF NOT EXISTS calendar_objects (
		    id SERIAL PRIMARY KEY,
		    calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
		    path TEXT NOT NULL,
		    data TEXT NOT NULL,
		    etag TEXT NOT NULL,
		    UNIQUE (calendar_id, path)
		)`,
		`CREATE TABLE IF NOT EXISTS addressbooks (
		    id SERIAL PRIMARY KEY,
		    owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
		    path TEXT UNIQUE NOT NULL,
		    name TEXT,
		    description TEXT
		)`,
		`CREATE TABLE IF NOT EXISTS addressbook_access (
		    addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE,
		    user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
		    mode TEXT NOT NULL,
		    PRIMARY KEY (addressbook_id, user_id)
		)`,
		`CREATE TABLE IF NOT EXISTS addressbook_objects (
		    id SERIAL PRIMARY KEY,
		    addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE,
		    path TEXT NOT NULL,
		    data TEXT NOT NULL,
		    etag TEXT NOT NULL,
		    UNIQUE (addressbook_id, path)
		)`,
	}
	for _, q := range queries {
		if _, err := b.pool.Exec(ctx, q); err != nil {
			return fmt.Errorf("failed to execute schema query: %v", err)
		}
	}
	return nil
}

func (b *DBBackend) resolvePassword(u config.User) (string, error) {
	var raw string
	if u.PasswordCmd != "" {
		cmd := exec.Command("sh", "-c", u.PasswordCmd)
		out, err := cmd.Output()
		if err != nil {
			return "", fmt.Errorf("failed to run password command for %s: %v", u.Name, err)
		}
		raw = strings.TrimSpace(string(out))
	} else {
		raw = u.Password
	}

	// If it already looks like a bcrypt hash, return as is.
	if strings.HasPrefix(raw, "$2") {
		return raw, nil
	}

	// Hash the raw password before DB entry
	hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(hash), nil
}

func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
	tx, err := b.pool.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)

	configUserNames := make(map[string]bool)
	configCalendarPaths := make(map[string]bool)
	groupMembers := make(map[string][]string)

	b.aggregates = make(map[string]*config.Aggregate)
	b.userAggs = make(map[string][]string)
	b.publicAccess = make(map[string]publicInfo)

	prefix := cfg.Server.BasePath()

	// --- Phase 1: User Sync ---
	// Inserts/Updates all users and their hashed passwords.
	for _, u := range cfg.Users {
		configUserNames[u.Name] = true
		for _, g := range u.Groups {
			groupMembers[g] = append(groupMembers[g], u.Name)
		}
		hashed, err := b.resolvePassword(u)
		if err != nil {
			return err
		}

		_, err = tx.Exec(ctx, `
			INSERT INTO users (name, password) VALUES ($1, $2)
			ON CONFLICT (name) DO UPDATE SET password = EXCLUDED.password`,
			u.Name, hashed)
		if err != nil {
			return err
		}
	}

	// --- Phase 2: Calendar & Access Sync ---
	// Inserts/Updates all calendars and their access/group rules.
	for _, c := range cfg.Calendars {
		path := prefix + fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
		configCalendarPaths[path] = true

		var ownerID int
		err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", c.Owner).Scan(&ownerID)
		if err != nil {
			return fmt.Errorf("owner %s not found: %v", c.Owner, err)
		}

		var calID int
		err = tx.QueryRow(ctx, `
			INSERT INTO calendars (owner_id, path, name, color) VALUES ($1, $2, $3, $4)
			ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name, color = EXCLUDED.color
			RETURNING id`,
			ownerID, path, c.ID, c.Color).Scan(&calID)
		if err != nil {
			return err
		}

		// Re-build access rules for this calendar
		_, err = tx.Exec(ctx, "DELETE FROM calendar_access WHERE calendar_id = $1", calID)
		if err != nil {
			return err
		}

		calendarAccessModes := make(map[string]string)
		for _, a := range c.Access {
			var tUsers []string
			if a.User != "" {
				tUsers = append(tUsers, a.User)
			}
			if a.Group != "" {
				tUsers = append(tUsers, groupMembers[a.Group]...)
			}
			if a.Groups != "" {
				tUsers = append(tUsers, groupMembers[a.Groups]...)
			}
			for _, u := range tUsers {
				calendarAccessModes[u] = a.Mode
			}
		}

		for uName, mode := range calendarAccessModes {
			var userID int
			err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", uName).Scan(&userID)
			if err != nil {
				return fmt.Errorf("access user %s not found: %v", uName, err)
			}
			_, err = tx.Exec(ctx, "INSERT INTO calendar_access (calendar_id, user_id, mode) VALUES ($1, $2, $3)",
				calID, userID, mode)
			if err != nil {
				return err
			}
		}

		// Check for public access (ICS)
		for _, a := range c.Access {
			if a.ICS != "" {
				pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", c.Owner, c.ID)
				b.publicAccess[pubPath] = publicInfo{
					InternalPath: path,
					Mode:         a.ICS,
				}
			}
		}
	}

	// --- Phase 2.5: Address Book & Access Sync ---
	configAddressBookPaths := make(map[string]bool)
	for _, ab := range cfg.AddressBooks {
		path := prefix + fmt.Sprintf("/%s/addressbooks/%s/", ab.Owner, ab.ID)
		configAddressBookPaths[path] = true

		var ownerID int
		err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", ab.Owner).Scan(&ownerID)
		if err != nil {
			return fmt.Errorf("owner %s not found: %v", ab.Owner, err)
		}

		var abID int
		err = tx.QueryRow(ctx, `
			INSERT INTO addressbooks (owner_id, path, name) VALUES ($1, $2, $3)
			ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name
			RETURNING id`,
			ownerID, path, ab.ID).Scan(&abID)
		if err != nil {
			return err
		}

		// Re-build access rules for this address book
		_, err = tx.Exec(ctx, "DELETE FROM addressbook_access WHERE addressbook_id = $1", abID)
		if err != nil {
			return err
		}

		addressBookAccessModes := make(map[string]string)
		for _, a := range ab.Access {
			var tUsers []string
			if a.User != "" {
				tUsers = append(tUsers, a.User)
			}
			if a.Group != "" {
				tUsers = append(tUsers, groupMembers[a.Group]...)
			}
			if a.Groups != "" {
				tUsers = append(tUsers, groupMembers[a.Groups]...)
			}
			for _, u := range tUsers {
				addressBookAccessModes[u] = a.Mode
			}
		}

		for uName, mode := range addressBookAccessModes {
			var userID int
			err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", uName).Scan(&userID)
			if err != nil {
				return fmt.Errorf("access user %s not found: %v", uName, err)
			}
			_, err = tx.Exec(ctx, "INSERT INTO addressbook_access (addressbook_id, user_id, mode) VALUES ($1, $2, $3)",
				abID, userID, mode)
			if err != nil {
				return err
			}
		}
	}

	// --- Phase 3: Aggregate Setup ---
	// Aggregates are virtual, so we only track them in memory for routing.
	for _, agg := range cfg.Aggregates {
		p_ := prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, agg.ID)
		if configCalendarPaths[p_] {
			return fmt.Errorf("aggregate %s collides with real calendar path", p_)
		}

		aggCopy := agg
		b.aggregates[p_] = &aggCopy

		aggAccess := make(map[string]bool)
		aggAccess[agg.Owner] = true
		for _, a := range agg.Access {
			if a.ICS != "" {
				pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", agg.Owner, agg.ID)
				b.publicAccess[pubPath] = publicInfo{
					InternalPath: p_,
					Mode:         a.ICS,
				}
			}

			var tUsers []string
			if a.User != "" {
				tUsers = append(tUsers, a.User)
			}
			if a.Group != "" {
				tUsers = append(tUsers, groupMembers[a.Group]...)
			}
			if a.Groups != "" {
				tUsers = append(tUsers, groupMembers[a.Groups]...)
			}
			for _, u := range tUsers {
				aggAccess[u] = true
			}
		}
		for uName := range aggAccess {
			b.userAggs[uName] = append(b.userAggs[uName], p_)
		}
	}

	// --- Phase 4: Orphan Checks ---
	// Logs a warning if the DB contains users/calendars no longer in the YAML.
	userRows, err := tx.Query(ctx, "SELECT name FROM users")
	if err == nil {
		for userRows.Next() {
			var name string
			if err := userRows.Scan(&name); err == nil && !configUserNames[name] {
				log.Printf("WARNING: Orphaned user found in database: %s", name)
			}
		}
		userRows.Close()
	}
	calRows, err := tx.Query(ctx, "SELECT path FROM calendars")
	if err == nil {
		for calRows.Next() {
			var p string
			if err := calRows.Scan(&p); err == nil && !configCalendarPaths[p] {
				log.Printf("WARNING: Orphaned calendar found in database: %s", p)
			}
		}
		calRows.Close()
	}
	return tx.Commit(ctx)
}

// VerifyUser checks Basic Auth credentials against the database.
// It is called by the auth middleware in main.go for every HTTP request.
func (b *DBBackend) VerifyUser(ctx context.Context, username, password string) (bool, error) {
	var hash string
	err := b.pool.QueryRow(ctx, "SELECT password FROM users WHERE name = $1", username).Scan(&hash)
	if err != nil {
		return false, nil // User not found
	}
	// Securely compare bcrypt hash with the provided plain-text password
	err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil, nil
}

// --- UserPrincipalBackend Implementation ---

// CurrentUserPrincipal returns the principal path injected into the context during authentication.
func (b *DBBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
	principal, ok := ctx.Value("principal").(string)
	if !ok {
		return "", errors.New("no principal in context")
	}
	return principal, nil
}

// getUsername is a helper to extract the user name from a principal path (e.g. "/alice/").
func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
	principal, err := b.CurrentUserPrincipal(ctx)
	if err != nil {
		return "", nil // No principal means anonymous/public
	}
	return strings.Trim(principal, "/"), nil
}

// --- Internal Security & Privacy Helpers ---

// for a real calendars
// returns internal database ID if granted
func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, string, error) {
	username, err := b.getUsername(ctx)
	if err != nil {
		return 0, "", err
	}

	if !strings.HasSuffix(calendarPath, "/") {
		calendarPath += "/"
	}

	var calID int
	var ownerName string
	err = b.pool.QueryRow(ctx, `
		SELECT c.id, u.name 
		FROM calendars c 
		JOIN users u ON c.owner_id = u.id 
		WHERE c.path = $1`, calendarPath).Scan(&calID, &ownerName)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return 0, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
		}
		return 0, "", err
	}

	// Owners always have full access
	if ownerName == username {
		return calID, "owner", nil
	}

	// Check the calendar_access table for specific permissions
	var mode string
	err = b.pool.QueryRow(ctx, `
		SELECT mode FROM calendar_access 
		WHERE calendar_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
		calID, username).Scan(&mode)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
		}
		return 0, "", err
	}

	// Enforce Read-Only mode
	if requiredMode == "write" && mode != "read-write" {
		return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
	}

	return calID, mode, nil
}

// Privacy engine
func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode string, original *ical.Calendar) (*ical.Calendar, error) {
	username, err := b.getUsername(ctx)
	if err != nil {
		return nil, err
	}

	// Owners and users with read-write access see everything unredacted.
	// We check both username and mode for robustness.
	if username == ownerName || mode == "owner" || mode == "read-write" {
		return original, nil
	}

	filtered := ical.NewCalendar()
	filtered.Props = original.Props

	for _, child := range original.Children {
		if child.Name != "VEVENT" && child.Name != "VTODO" {
			filtered.Children = append(filtered.Children, child)
			continue
		}

		class := b.defaultClass
		if prop := child.Props.Get("CLASS"); prop != nil {
			class = strings.ToUpper(prop.Value)
		}

		switch class {
		case "PRIVATE":
			// Don't include private items in the response
			continue
		case "CONFIDENTIAL":
			// Strip all details except time/meta and set a generic summary
			redacted := ical.NewComponent(child.Name)
			propsToKeep := []string{
				"UID", "DTSTAMP", "DTSTART", "DTEND", "DURATION", "CLASS",
				"DUE", "COMPLETED", "STATUS", "PRIORITY", "PERCENT-COMPLETE",
			}
			for _, pName := range propsToKeep {
				if props, ok := child.Props[pName]; ok {
					redacted.Props[pName] = props
				}
			}
			redacted.Props.SetText("SUMMARY", b.redactionText)
			filtered.Children = append(filtered.Children, redacted)
		case "PUBLIC":
			filtered.Children = append(filtered.Children, child)
		}
	}

	if len(filtered.Children) == 0 {
		return nil, nil // Entire file is hidden
	}

	return filtered, nil
}

// ServePublicICS handles public GET requests for .ics files.
// It applies privacy redaction and optional time-range filtering (e.g. "future-only").
func (b *DBBackend) ServePublicICS(w http.ResponseWriter, r *http.Request) {
	info, ok := b.publicAccess[r.URL.Path]
	if !ok {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}

	// For public access, we use an empty context (no principal)
	// which ensures filterCalendar applies full redaction.
	ctx := r.Context()

	var objects []caldav.CalendarObject
	var err error

	if agg, ok := b.aggregates[info.InternalPath]; ok {
		objects, err = b.listAggregateObjects(ctx, info.InternalPath, agg)
	} else {
		var calID int
		var ownerName string
		err = b.pool.QueryRow(ctx, `
			SELECT c.id, u.name FROM calendars c 
			JOIN users u ON c.owner_id = u.id 
			WHERE c.path = $1`, info.InternalPath).Scan(&calID, &ownerName)
		if err == nil {
			objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName, "read")
		}
	}

	if err != nil {
		log.Printf("Error fetching public objects: %v", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	outCal := ical.NewCalendar()
	outCal.Props.SetText("PRODID", "-//nxcaldav//Public ICS//EN")
	outCal.Props.SetText("VERSION", "2.0")

	now := time.Now()

	for _, obj := range objects {
		for _, child := range obj.Data.Children {
			if child.Name != "VEVENT" && child.Name != "VTODO" {
				continue
			}

			if info.Mode == "future-only" {
				var end time.Time
				if prop := child.Props.Get("DTEND"); prop != nil {
					end, _ = prop.DateTime(time.UTC)
				} else if prop := child.Props.Get("DTSTART"); prop != nil {
					end, _ = prop.DateTime(time.UTC)
				}

				if !end.IsZero() && end.Before(now) {
					continue
				}
			}
			outCal.Children = append(outCal.Children, child)
		}
	}

	w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
	if err := ical.NewEncoder(w).Encode(outCal); err != nil {
		log.Printf("Error encoding public ICS: %v", err)
	}
}

// --- CalDAV Backend Implementation ---

// CalendarHomeSetPath returns the root path for a user's calendars (e.g., "/alice/calendars/").
// Called during CalDAV discovery.
func (b *DBBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
	username, err := b.getUsername(ctx)
	if err != nil {
		return "", err
	}
	return b.prefix + fmt.Sprintf("/%s/calendars/", username), nil
}

// ListCalendars returns all calendars (real and virtual) the user has access to.
// Called during PROPFIND on the user's HomeSetPath.
func (b *DBBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
	username, err := b.getUsername(ctx)
	if err != nil {
		return nil, err
	}

	// Find real calendars (Owned or Shared)
	rows, err := b.pool.Query(ctx, `
		SELECT path, name FROM calendars 
		WHERE owner_id = (SELECT id FROM users WHERE name = $1)
		OR id IN (SELECT calendar_id FROM calendar_access WHERE user_id = (SELECT id FROM users WHERE name = $1))`,
		username)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var res []caldav.Calendar
	for rows.Next() {
		var cal caldav.Calendar
		if err := rows.Scan(&cal.Path, &cal.Name); err != nil {
			return nil, err
		}
		cal.SupportedComponentSet = []string{"VEVENT", "VTODO"}
		res = append(res, cal)
	}

	// Add virtual aggregate calendars
	for _, p := range b.userAggs[username] {
		agg := b.aggregates[p]
		res = append(res, caldav.Calendar{
			Path:                  p,
			Name:                  agg.ID,
			SupportedComponentSet: []string{"VEVENT", "VTODO"},
		})
	}

	return res, nil
}

// GetCalendar retrieves metadata for a specific calendar.
// Called when a client wants to verify a calendar's settings.
func (b *DBBackend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
	if !strings.HasSuffix(p, "/") {
		p += "/"
	}

	// Check if this is an Aggregate route
	if agg, ok := b.aggregates[p]; ok {
		return &caldav.Calendar{
			Path:                  p,
			Name:                  agg.ID,
			SupportedComponentSet: []string{"VEVENT", "VTODO"},
		}, nil
	}

	// Normal database lookup
	var cal caldav.Calendar
	err := b.pool.QueryRow(ctx, "SELECT path, name FROM calendars WHERE path = $1", p).Scan(&cal.Path, &cal.Name)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
		}
		return nil, err
	}
	cal.SupportedComponentSet = []string{"VEVENT", "VTODO"}
	return &cal, nil
}

// CreateCalendar is disabled because calendars are managed via the config
func (b *DBBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
	return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config"))
}

// ListCalendarObjects returns the list of all events/tasks in a calendar.
// Called during initial sync (PROPFIND Depth:1) or full-calendar reports.
func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
	if !strings.HasSuffix(p, "/") {
		p += "/"
	}

	username, _ := b.getUsername(ctx)
	log.Printf("[user: %s] ListCalendarObjects %s", username, p)

	// Route to virtual aggregate logic if needed
	if agg, ok := b.aggregates[p]; ok {
		// Verify Diane/User can actually see this aggregate
		hasAccess := slices.Contains(b.userAggs[username], p)
		if !hasAccess {
			return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied to aggregate"))
		}
		return b.listAggregateObjects(ctx, p, agg)
	}

	// Normal calendar logic
	calID, mode, err := b.checkAccess(ctx, p, "read")
	if err != nil {
		return nil, err
	}

	var ownerName string
	err = b.pool.QueryRow(ctx, "SELECT u.name FROM users u JOIN calendars c ON c.owner_id = u.id WHERE c.id = $1", calID).Scan(&ownerName)
	if err != nil {
		return nil, err
	}

	return b.listCalendarObjectsRaw(ctx, calID, ownerName, mode)
}

func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, ownerName string, mode string) ([]caldav.CalendarObject, error) {
	rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var res []caldav.CalendarObject
	for rows.Next() {
		var obj caldav.CalendarObject
		var dataStr string
		if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil {
			return nil, err
		}

		calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
		if err != nil {
			continue
		}

		// Apply privacy filters (Private/Confidential)
		filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
		if err != nil || filteredData == nil {
			continue
		}
		obj.Data = filteredData
		res = append(res, obj)
	}
	return res, nil
}

// listAggregateObjects merges multiple real calendars into one virtual view.
// It rewrites paths so the client thinks items are in the virtual directory.
func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) {
	var res []caldav.CalendarObject
	username, _ := b.getUsername(ctx)

	for _, sourceID := range agg.Sources {
		sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)

		var calID int
		var ownerName string
		err := b.pool.QueryRow(ctx, `
			SELECT c.id, u.name FROM calendars c 
			JOIN users u ON c.owner_id = u.id 
			WHERE c.path = $1`, sourcePath).Scan(&calID, &ownerName)
		if err != nil {
			continue
		}

		// Determine access mode for this user on this source calendar
		mode := "read"
		if username == ownerName {
			mode = "owner"
		} else if username != "" {
			err = b.pool.QueryRow(ctx, `
				SELECT mode FROM calendar_access 
				WHERE calendar_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
				calID, username).Scan(&mode)
			if err != nil {
				mode = "read" // Assume read-only if no explicit entry
			}
		}

		objs, err := b.listCalendarObjectsRaw(ctx, calID, ownerName, mode)
		if err != nil {
			continue
		}

		for _, obj := range objs {
			// Prepend source name so Diane knows this is a "[calendar]" event
			for _, child := range obj.Data.Children {
				if child.Name == "VEVENT" || child.Name == "VTODO" {
					descr := child.Props.Get("DESCRIPTION")
					val := ""
					if descr != nil {
						val = descr.Value
					}
					child.Props.SetText("DESCRIPTION", fmt.Sprintf("[%s]\n%s", sourceID, val))
				}
			}

			// Virtualize the path so Thunderbird doesn't reject the file
			fileName := path.Base(obj.Path)
			obj.Path = aggPath + sourceID + "__" + fileName
			res = append(res, obj)
		}
	}
	return res, nil
}

// GetCalendarObject retrieves a single event or task file.
// Called during GET, multiget REPORT, or when clicking an event in the client.
func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
	username, _ := b.getUsername(ctx)
	log.Printf("[user: %s] GetCalendarObject %s", username, p)

	dirPath := path.Dir(p) + "/"
	fileName := path.Base(p)

	// Step 1: Check if this is a request for a virtual item in an aggregate
	if agg, ok := b.aggregates[dirPath]; ok {

		hasAccess := slices.Contains(b.userAggs[username], dirPath)
		if !hasAccess {
			return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied to aggregate"))
		}

		// Parse the virtual path back to real source
		parts := strings.SplitN(fileName, "__", 2)
		if len(parts) < 2 {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("invalid aggregate item path"))
		}
		sourceID := parts[0]
		realFileName := parts[1]

		sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
		var calID int
		var ownerName string
		err := b.pool.QueryRow(ctx, "SELECT c.id, u.name FROM calendars c JOIN users u ON c.owner_id = u.id WHERE c.path = $1", sourcePath).Scan(&calID, &ownerName)
		if err != nil {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("source calendar not found"))
		}

		var obj caldav.CalendarObject
		var dataStr string
		realObjPath := sourcePath + realFileName
		err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, realObjPath).Scan(&obj.Path, &dataStr, &obj.ETag)
		if err != nil {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item not found in source"))
		}

		calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
		if err != nil {
			return nil, err
		}

		// Determine access mode for this user on this source calendar
		mode := "read"
		if username == ownerName {
			mode = "owner"
		} else if username != "" {
			err = b.pool.QueryRow(ctx, `
				SELECT mode FROM calendar_access 
				WHERE calendar_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
				calID, username).Scan(&mode)
			if err != nil {
				mode = "read" // Assume read-only if no explicit entry
			}
		}

		filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
		if err != nil || filteredData == nil {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item filtered out"))
		}

		// Re-apply labels for the virtual view
		for _, child := range filteredData.Children {
			if child.Name == "VEVENT" || child.Name == "VTODO" {
				descr := child.Props.Get("DESCRIPTION")
				val := ""
				if descr != nil {
					val = descr.Value
				}
				child.Props.SetText("DESCRIPTION", fmt.Sprintf("[%s]\n%s", sourceID, val))
			}
		}
		obj.Data = filteredData
		obj.Path = p
		return &obj, nil
	}

	// Step 2: Handle normal real calendar objects
	if !strings.HasSuffix(dirPath, "/") {
		dirPath += "/"
	}

	calID, mode, err := b.checkAccess(ctx, dirPath, "read")
	if err != nil {
		return nil, err
	}

	var ownerName string
	err = b.pool.QueryRow(ctx, "SELECT u.name FROM users u JOIN calendars c ON c.owner_id = u.id WHERE c.id = $1", calID).Scan(&ownerName)
	if err != nil {
		return nil, err
	}

	var obj caldav.CalendarObject
	var dataStr string
	err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, p).Scan(&obj.Path, &dataStr, &obj.ETag)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
		}
		return nil, err
	}

	calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
	if err != nil {
		return nil, err
	}

	// Apply privacy filtering
	filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
	if err != nil || filteredData == nil {
		return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
	}
	obj.Data = filteredData
	return &obj, nil
}

// PutCalendarObject saves or updates an event/task.
// Called during PUT requests when a user saves a change in their calendar app.
func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
	dirPath := path.Dir(p) + "/"

	// Aggregates are read-only
	if _, ok := b.aggregates[dirPath]; ok {
		return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
	}

	calID, _, err := b.checkAccess(ctx, dirPath, "write")
	if err != nil {
		return nil, err
	}

	var buf bytes.Buffer
	if err := ical.NewEncoder(&buf).Encode(calendar); err != nil {
		return nil, err
	}
	dataStr := buf.String()
	etag := fmt.Sprintf(`"%d"`, len(calendar.Events()))

	// Use Upsert logic to handle both creation and updates
	_, err = b.pool.Exec(ctx, `
		INSERT INTO calendar_objects (calendar_id, path, data, etag) VALUES ($1, $2, $3, $4)
		ON CONFLICT (calendar_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`,
		calID, p, dataStr, etag)
	if err != nil {
		return nil, err
	}

	// Step 3: Handle Status Propagation & Invitations
	username, _ := b.getUsername(ctx)
	userEmail := strings.ToLower(username + "@" + b.emailDomain)

	for _, event := range calendar.Events() {
		organizer := event.Props.Get("ORGANIZER")
		orgEmail := ""
		if organizer != nil {
			orgEmail = strings.TrimPrefix(strings.ToLower(organizer.Value), "mailto:")
		}

		// --- Case A: User is the ATTENDEE updating their status ---
		// If the user is NOT the organizer, but is an attendee, find the organizer's
		// original event and update the status there.
		if orgEmail != "" && orgEmail != userEmail {
			log.Printf("[scheduling] Attendee %s updated event. Propagating to organizer %s", userEmail, orgEmail)

			// Find the attendee's status in this version
			myStatus := "NEEDS-ACTION"
			for _, att := range event.Props["ATTENDEE"] {
				if strings.TrimPrefix(strings.ToLower(att.Value), "mailto:") == userEmail {
					if stat := att.Params.Get("PARTSTAT"); stat != "" {
						myStatus = stat
					}
					break
				}
			}

			// Find the UID of this event to locate the organizer's copy
			uid := ""
			if u := event.Props.Get("UID"); u != nil {
				uid = u.Value
			}

			if uid != "" {
				// Search for the organizer's copy in their calendars
				go b.propagateStatusToOrganizer(orgEmail, userEmail, uid, myStatus)
			}
			continue // Don't send invitations from an attendee's PUT
		}

		// --- Case B: User is the ORGANIZER (Sending Invitations) ---
		// Only send invites if the user is the organizer and we are in the owner's calendar
		isOwnerCalendar := strings.Contains(p, "/"+username+"/")
		if !isOwnerCalendar || (orgEmail != "" && orgEmail != userEmail) {
			continue
		}

		attendees := event.Props["ATTENDEE"]
		if len(attendees) == 0 {
			continue
		}

		summary := ""
		if s := event.Props.Get("SUMMARY"); s != nil {
			summary = s.Value
		}
		description := ""
		if d := event.Props.Get("DESCRIPTION"); d != nil {
			description = d.Value
		}
		start := ""
		if dtstart, err := event.DateTimeStart(time.UTC); err == nil {
			start = dtstart.Format(time.RFC1123)
		}
		end := ""
		if dtend, err := event.DateTimeEnd(time.UTC); err == nil {
			end = dtend.Format(time.RFC1123)
		}

		for _, attendee := range attendees {
			recipientEmail := strings.TrimPrefix(attendee.Value, "mailto:")
			if recipientEmail == "" {
				continue
			}
			// Only send if it's a valid looking email and not the sender themselves
			if strings.Contains(recipientEmail, "@") && !strings.HasPrefix(recipientEmail, username+"@") {
				go b.sendInvitation(username, recipientEmail, summary, description, start, end, p, dataStr)
			}
		}
	}

	return &caldav.CalendarObject{
		Path: p,
		Data: calendar,
		ETag: etag,
	}, nil
}

// DeleteCalendarObject removes an event or task.
// Called during DELETE requests.
func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
	dirPath := path.Dir(p) + "/"
	if _, ok := b.aggregates[dirPath]; ok {
		return webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
	}

	calID, _, err := b.checkAccess(ctx, dirPath, "write")
	if err != nil {
		return err
	}

	commandTag, err := b.pool.Exec(ctx, "DELETE FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, p)
	if err != nil {
		return err
	}
	if commandTag.RowsAffected() == 0 {
		return webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
	}
	return nil
}

// propagateStatusToOrganizer finds the original event in the organizer's calendar
// and updates the attendee's status.
func (b *DBBackend) propagateStatusToOrganizer(orgEmail, attendeeEmail, uid, status string) {
	ctx := context.Background()
	log.Printf("[scheduling] Searching for original event UID %s for organizer %s", uid, orgEmail)

	// 1. Find the organizer's user ID
	var orgUserID int
	err := b.pool.QueryRow(ctx, "SELECT id FROM users WHERE name = $1 OR name = $2",
		strings.Split(orgEmail, "@")[0], orgEmail).Scan(&orgUserID)
	if err != nil {
		log.Printf("[scheduling] Could not find organizer user %s: %v", orgEmail, err)
		return
	}

	// 2. Find the calendar object by UID within organizer's calendars
	rows, err := b.pool.Query(ctx, `
		SELECT co.path, co.data, co.calendar_id 
		FROM calendar_objects co
		JOIN calendars c ON co.calendar_id = c.id
		WHERE c.owner_id = $1 AND co.data LIKE '%' || $2 || '%'`,
		orgUserID, uid)
	if err != nil {
		log.Printf("[scheduling] Error searching for organizer's copy: %v", err)
		return
	}
	defer rows.Close()

	for rows.Next() {
		var p, dataStr string
		var calID int
		if err := rows.Scan(&p, &dataStr, &calID); err != nil {
			continue
		}

		// Verify UID (LIKE is just a hint)
		calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
		if err != nil {
			continue
		}

		found := false
		for _, event := range calendar.Events() {
			if u := event.Props.Get("UID"); u != nil && u.Value == uid {
				// Update attendee status
				for _, att := range event.Props["ATTENDEE"] {
					if strings.TrimPrefix(strings.ToLower(att.Value), "mailto:") == attendeeEmail {
						log.Printf("[scheduling] Updating %s status to %s in organizer's copy %s", attendeeEmail, status, p)
						att.Params.Set("PARTSTAT", status)
						found = true
					}
				}
			}
		}

		if found {
			var buf bytes.Buffer
			if err := ical.NewEncoder(&buf).Encode(calendar); err == nil {
				newEtag := fmt.Sprintf(`"%d-%d"`, time.Now().Unix(), buf.Len())
				b.pool.Exec(ctx, "UPDATE calendar_objects SET data = $1, etag = $2 WHERE calendar_id = $3 AND path = $4",
					buf.String(), newEtag, calID, p)
				log.Printf("[scheduling] Organizer's copy %s updated successfully", p)
			}
		}
	}
}

// RespondToInvitation handles an attendee's Accept/Decline response.
func (b *DBBackend) RespondToInvitation(ctx context.Context, p, attendeeEmail, status string) error {
	log.Printf("[email] Response for %s from %s: %s", p, attendeeEmail, status)

	// 1. Fetch the calendar object
	var dataStr string
	var calID int
	err := b.pool.QueryRow(ctx, "SELECT calendar_id, data FROM calendar_objects WHERE path = $1", p).Scan(&calID, &dataStr)
	if err != nil {
		return fmt.Errorf("failed to find calendar object: %v", err)
	}

	calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
	if err != nil {
		return fmt.Errorf("failed to decode calendar data: %v", err)
	}

	// 2. Update PARTSTAT for the attendee
	modified := false
	status = strings.ToUpper(status)
	attendeeEmail = strings.ToLower(strings.TrimSpace(attendeeEmail))

	for _, event := range calendar.Events() {
		attendees := event.Props["ATTENDEE"]
		for _, attendee := range attendees {
			email := strings.TrimPrefix(strings.ToLower(attendee.Value), "mailto:")
			if email == attendeeEmail {
				attendee.Params.Set("PARTSTAT", status)
				modified = true
			}
		}
	}

	if !modified {
		return fmt.Errorf("attendee %s not found in event", attendeeEmail)
	}

	// 3. Save back to DB
	var buf bytes.Buffer
	if err := ical.NewEncoder(&buf).Encode(calendar); err != nil {
		return err
	}
	newDataStr := buf.String()
	// Use a timestamp + length for a unique ETag
	newEtag := fmt.Sprintf(`"%d-%d"`, time.Now().Unix(), len(newDataStr))

	_, err = b.pool.Exec(ctx, "UPDATE calendar_objects SET data = $1, etag = $2 WHERE calendar_id = $3 AND path = $4",
		newDataStr, newEtag, calID, p)
	if err != nil {
		return fmt.Errorf("failed to update calendar object in DB: %v", err)
	}

	log.Printf("[email] Successfully updated status to %s for %s in %s", status, attendeeEmail, p)
	return nil
}

// QueryCalendarObjects filters items based on a CalDAV query (e.g. time range).
// Currently, it just lists all objects and lets the client filter, but
// we use it to enforce privacy rules for the initial report.
func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path_ string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
	username, _ := b.getUsername(ctx)
	log.Printf("[user: %s] QueryCalendarObjects %s", username, path_)
	return b.ListCalendarObjects(ctx, path_, nil)
}

func (b *DBBackend) GetColor(ctx context.Context, p string) string {
	// If p is a full URL, extract the path
	if strings.HasPrefix(p, "http") {
		if u, err := url.Parse(p); err == nil {
			p = u.Path
		}
	}

	if !strings.HasSuffix(p, "/") {
		p += "/"
	}

	// Check if it's an aggregate
	if agg, ok := b.aggregates[p]; ok {
		return agg.Color
	}

	// Check if it's a real calendar
	var color string
	err := b.pool.QueryRow(ctx, "SELECT color FROM calendars WHERE path = $1", p).Scan(&color)
	if err == nil {
		return color
	}
	return ""
}

// --- CardDAV ---

func (b *DBBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
	username, err := b.getUsername(ctx)
	if err != nil {
		return "", err
	}
	return b.prefix + fmt.Sprintf("/%s/addressbooks/", username), nil
}

func (b *DBBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) {
	username, err := b.getUsername(ctx)
	if err != nil {
		return nil, err
	}

	rows, err := b.pool.Query(ctx, `
	        SELECT path, name, COALESCE(description, '') FROM addressbooks 
	        WHERE owner_id = (SELECT id FROM users WHERE name = $1)
	        OR id IN (SELECT addressbook_id FROM addressbook_access WHERE user_id = (SELECT id FROM users WHERE name = $1))`,
		username)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var res []carddav.AddressBook
	for rows.Next() {
		var ab carddav.AddressBook
		if err := rows.Scan(&ab.Path, &ab.Name, &ab.Description); err != nil {
			return nil, err
		}
		ab.MaxResourceSize = 1000000
		ab.SupportedAddressData = []carddav.AddressDataType{
			{ContentType: "text/vcard", Version: "3.0"},
			{ContentType: "text/vcard", Version: "4.0"},
		}
		res = append(res, ab)
	}
	return res, nil
}

func (b *DBBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) {
	if !strings.HasSuffix(p, "/") {
		p += "/"
	}

	var ab carddav.AddressBook
	err := b.pool.QueryRow(ctx, "SELECT path, name, COALESCE(description, '') FROM addressbooks WHERE path = $1", p).Scan(&ab.Path, &ab.Name, &ab.Description)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found"))
		}
		return nil, err
	}
	ab.MaxResourceSize = 1000000
	ab.SupportedAddressData = []carddav.AddressDataType{
		{ContentType: "text/vcard", Version: "3.0"},
		{ContentType: "text/vcard", Version: "4.0"},
	}
	return &ab, nil
}

func (b *DBBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error {
	return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book creation only via config"))
}

func (b *DBBackend) DeleteAddressBook(ctx context.Context, p string) error {
	return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book deletion only via config"))
}

func (b *DBBackend) checkAddressBookAccess(ctx context.Context, abPath string, requiredMode string) (int, string, error) {
	username, err := b.getUsername(ctx)
	if err != nil {
		return 0, "", err
	}

	if !strings.HasSuffix(abPath, "/") {
		abPath += "/"
	}

	var abID int
	var ownerName string
	err = b.pool.QueryRow(ctx, `
	        SELECT a.id, u.name 
	        FROM addressbooks a 
	        JOIN users u ON a.owner_id = u.id 
	        WHERE a.path = $1`, abPath).Scan(&abID, &ownerName)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return 0, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found"))
		}
		return 0, "", err
	}

	if ownerName == username {
		return abID, "owner", nil
	}

	var mode string
	err = b.pool.QueryRow(ctx, `
	        SELECT mode FROM addressbook_access 
	        WHERE addressbook_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
		abID, username).Scan(&mode)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
		}
		return 0, "", err
	}

	if requiredMode == "write" && mode != "read-write" {
		return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
	}
	return abID, mode, nil
}

func (b *DBBackend) ListAddressObjects(ctx context.Context, p string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
	if !strings.HasSuffix(p, "/") {
		p += "/"
	}

	abID, _, err := b.checkAddressBookAccess(ctx, p, "read")
	if err != nil {
		return nil, err
	}

	rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1", abID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var res []carddav.AddressObject
	for rows.Next() {
		var obj carddav.AddressObject
		var dataStr string
		if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil {
			return nil, err
		}

		card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode()
		if err != nil {
			continue
		}
		obj.Card = card
		res = append(res, obj)
	}
	return res, nil
}

func (b *DBBackend) GetAddressObject(ctx context.Context, p string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
	dirPath := path.Dir(p) + "/"
	abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "read")
	if err != nil {
		return nil, err
	}

	var obj carddav.AddressObject
	var dataStr string
	err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p).Scan(&obj.Path, &dataStr, &obj.ETag)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found"))
		}
		return nil, err
	}

	card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode()
	if err != nil {
		return nil, err
	}
	obj.Card = card
	return &obj, nil
}

func (b *DBBackend) PutAddressObject(ctx context.Context, p string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) {
	dirPath := path.Dir(p) + "/"
	abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write")
	if err != nil {
		return nil, err
	}

	var buf bytes.Buffer
	if err := vcard.NewEncoder(&buf).Encode(card); err != nil {
		return nil, err
	}
	dataStr := buf.String()
	etag := fmt.Sprintf(`"%d"`, len(dataStr))

	_, err = b.pool.Exec(ctx, `
                INSERT INTO addressbook_objects (addressbook_id, path, data, etag) VALUES ($1, $2, $3, $4)
                ON CONFLICT (addressbook_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`,
		abID, p, dataStr, etag)
	if err != nil {
		return nil, err
	}

	return &carddav.AddressObject{
		Path: p,
		Card: card,
		ETag: etag,
	}, nil
}

func (b *DBBackend) DeleteAddressObject(ctx context.Context, p string) error {
	dirPath := path.Dir(p) + "/"
	abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write")
	if err != nil {
		return err
	}

	commandTag, err := b.pool.Exec(ctx, "DELETE FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p)
	if err != nil {
		return err
	}
	if commandTag.RowsAffected() == 0 {
		return webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found"))
	}
	return nil
}

func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
	return b.ListAddressObjects(ctx, p, nil)
}
