package services

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

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

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

// AppointmentService reads and writes paliad.appointments.
//
// Visibility:
//   - project_id IS NULL → personal Appointment, visible/editable only to created_by
//   - project_id IS NOT NULL → follows ProjectService.GetByID team gate
//
// Audit: Project-attached mutations append project_events rows. Personal
// Appointments never touch project_events.
//
// CalDAV: optional hook (AppointmentCalDAVPusher) is called best-effort after
// each mutation.
type AppointmentService struct {
	db       *sqlx.DB
	projects *ProjectService

	caldav    AppointmentCalDAVPusher
	approvals *ApprovalService
}

// SetApprovalService wires the optional 4-eye approval workflow
// (t-paliad-138). See DeadlineService.SetApprovalService.
func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
	s.approvals = a
}

// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
// request id + required role for a 409 hint. Falls back to the bare
// ErrConcurrentPending when approvals is unwired or the lookup fails.
func (s *AppointmentService) pendingApprovalErr(ctx context.Context, appointmentID uuid.UUID) error {
	if s.approvals == nil {
		return ErrConcurrentPending
	}
	rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeAppointment, appointmentID)
	if err != nil || rid == "" {
		return ErrConcurrentPending
	}
	return NewPendingApprovalError(rid, role)
}

// AppointmentCalDAVPusher is the contract the CalDAV service implements so the
// AppointmentService can push individual appointment changes without importing the
// caldav package directly.
type AppointmentCalDAVPusher interface {
	OnAppointmentCreated(ctx context.Context, userID uuid.UUID, t *models.Appointment)
	OnAppointmentUpdated(ctx context.Context, userID uuid.UUID, t *models.Appointment)
	OnAppointmentDeleted(ctx context.Context, userID uuid.UUID, t *models.Appointment)
}

func NewAppointmentService(db *sqlx.DB, projects *ProjectService) *AppointmentService {
	return &AppointmentService{db: db, projects: projects}
}

// SetCalDAVPusher wires an optional CalDAV push hook.
func (s *AppointmentService) SetCalDAVPusher(p AppointmentCalDAVPusher) {
	s.caldav = p
}

const appointmentColumns = `id, project_id, title, description, start_at, end_at,
	location, appointment_type, caldav_uid, caldav_etag, created_by,
	created_at, updated_at, completed_at,
	approval_status, pending_request_id, approved_by, approved_at`

// CreateAppointmentInput is the payload for POST /api/appointments.
type CreateAppointmentInput struct {
	ProjectID   *uuid.UUID `json:"project_id,omitempty"`
	Title       string     `json:"title"`
	Description *string    `json:"description,omitempty"`
	StartAt     time.Time  `json:"start_at"`
	EndAt       *time.Time `json:"end_at,omitempty"`
	Location    *string    `json:"location,omitempty"`
	AppointmentType  *string    `json:"appointment_type,omitempty"`
	// AgentTurnID, when non-nil, marks the create as a Paliadin-drafted
	// suggestion (t-paliad-161). Same semantics as
	// CreateDeadlineInput.AgentTurnID.
	AgentTurnID *uuid.UUID `json:"agent_turn_id,omitempty"`
}

// UpdateAppointmentInput is the partial-update payload for PATCH /api/appointments/{id}.
//
// ProjectID + ClearProject control the project move (t-paliad-140). Both
// nil/false = leave project_id untouched. ClearProject=true unlinks the
// appointment from its current project (only the creator may do this,
// matching the personal-appointment edit gate). ProjectID set = move under
// that project (visibility on the new project is enforced).
type UpdateAppointmentInput struct {
	Title           *string    `json:"title,omitempty"`
	Description     *string    `json:"description,omitempty"`
	StartAt         *time.Time `json:"start_at,omitempty"`
	EndAt           *time.Time `json:"end_at,omitempty"`
	Location        *string    `json:"location,omitempty"`
	AppointmentType *string    `json:"appointment_type,omitempty"`
	ProjectID       *uuid.UUID `json:"project_id,omitempty"`
	ClearProject    bool       `json:"clear_project,omitempty"`
}

// AppointmentListFilter narrows ListVisibleForUser results.
//
// CreatedBy narrows to appointments whose `created_by = id`. Backs the
// "Nur persönliche" filter on /events (t-paliad-128). It composes with
// the team-visibility predicate (AND) rather than replacing it, so an
// appointment a user created on a team they have since left still
// won't leak through.
//
// DirectOnly narrows ProjectID from "this project + every descendant" (the
// t-paliad-139 subtree default) to "this project only" (t-paliad-152).
// Has no effect when ProjectID is nil.
type AppointmentListFilter struct {
	ProjectID  *uuid.UUID
	From       *time.Time
	To         *time.Time
	Type       *string
	CreatedBy  *uuid.UUID
	DirectOnly bool
}

// ListVisibleForUser returns all Appointments the user can see (personal +
// Project-attached they have visibility for), ordered by start_at ascending.
func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter AppointmentListFilter) ([]models.AppointmentWithProject, error) {
	user, err := s.users().GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return []models.AppointmentWithProject{}, nil
	}

	visibility := `(
		(t.project_id IS NULL AND t.created_by = :user_id)
		OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
	)`
	conds := []string{visibility}
	args := map[string]any{
		"user_id": userID,
	}

	if filter.ProjectID != nil {
		if filter.DirectOnly {
			conds = append(conds, `t.project_id = :project_id`)
		} else {
			conds = append(conds, projectDescendantPredicate("p"))
		}
		args["project_id"] = *filter.ProjectID
	}
	if filter.From != nil {
		conds = append(conds, `t.start_at >= :from`)
		args["from"] = *filter.From
	}
	if filter.To != nil {
		conds = append(conds, `t.start_at <= :to`)
		args["to"] = *filter.To
	}
	if filter.Type != nil {
		if !isValidAppointmentType(*filter.Type) {
			return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *filter.Type)
		}
		conds = append(conds, `t.appointment_type = :type`)
		args["type"] = *filter.Type
	}
	if filter.CreatedBy != nil {
		conds = append(conds, `t.created_by = :created_by`)
		args["created_by"] = *filter.CreatedBy
	}

	query := `
		SELECT t.id, t.project_id, t.title, t.description, t.start_at, t.end_at,
		       t.location, t.appointment_type, t.caldav_uid, t.caldav_etag,
		       t.created_by, t.created_at, t.updated_at,
		       t.completed_at,
		       t.approval_status, t.pending_request_id, t.approved_by, t.approved_at,
		       p.reference AS project_reference,
		       p.title     AS project_title,
		       p.type      AS project_type,
		       ar.requester_kind AS requester_kind
		  FROM paliad.appointments t
		  LEFT JOIN paliad.projects p ON p.id = t.project_id
		  LEFT JOIN paliad.approval_requests ar ON ar.id = t.pending_request_id
		 WHERE ` + strings.Join(conds, " AND ") + `
		 ORDER BY t.start_at ASC, t.created_at DESC`

	stmt, err := s.db.PrepareNamedContext(ctx, query)
	if err != nil {
		return nil, fmt.Errorf("prepare list appointments: %w", err)
	}
	defer stmt.Close()

	rows := []models.AppointmentWithProject{}
	if err := stmt.SelectContext(ctx, &rows, args); err != nil {
		return nil, fmt.Errorf("list appointments: %w", err)
	}
	return rows, nil
}

// ListForProject returns Appointments for a Project (visibility-checked).
//
// When directOnly is false (default), the result aggregates appointments
// from the Project itself AND every descendant Project (per the
// t-paliad-139 hierarchy aggregation contract). When directOnly is true,
// only appointments whose project_id exactly equals the filter are
// returned.
//
// The descendant aggregation mirrors DeadlineService.ListForProject — see
// the doc comment there for the rationale.
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Appointment, error) {
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}
	rows := []models.Appointment{}
	var filter string
	if directOnly {
		filter = `WHERE project_id = $1`
	} else {
		filter = `WHERE project_id IN (
		    SELECT p.id FROM paliad.projects p
		     WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
	}
	if err := s.db.SelectContext(ctx, &rows,
		`SELECT `+appointmentColumns+`
		   FROM paliad.appointments
		  `+filter+`
		  ORDER BY start_at ASC, created_at DESC`, projectID); err != nil {
		return nil, fmt.Errorf("list appointments for project: %w", err)
	}
	return rows, nil
}

// CanSee reports whether the user has visibility on the Appointment. Returns
// (false, nil) for invisible or missing — handlers must not distinguish.
// Cheaper than GetByID when only the visibility bit is needed (no projection
// of the full row); used by sibling services (NoteService, etc.) to gate on
// the parent without paying for a full SELECT plus a follow-up project read.
func (s *AppointmentService) CanSee(ctx context.Context, userID, appointmentID uuid.UUID) (bool, error) {
	var visible bool
	query := `SELECT EXISTS (
	    SELECT 1
	      FROM paliad.appointments t
	      LEFT JOIN paliad.projects p ON p.id = t.project_id
	     WHERE t.id = $1 AND (
	         (t.project_id IS NULL AND t.created_by = $2)
	         OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 2) + `)
	     )
	)`
	if err := s.db.GetContext(ctx, &visible, query, appointmentID, userID); err != nil {
		return false, fmt.Errorf("check appointment visibility: %w", err)
	}
	return visible, nil
}

// GetByID returns a single Appointment if the user has visibility.
func (s *AppointmentService) GetByID(ctx context.Context, userID, appointmentID uuid.UUID) (*models.Appointment, error) {
	var t models.Appointment
	err := s.db.GetContext(ctx, &t,
		`SELECT `+appointmentColumns+` FROM paliad.appointments WHERE id = $1`, appointmentID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotVisible
	}
	if err != nil {
		return nil, fmt.Errorf("fetch appointment: %w", err)
	}

	if !s.canSee(ctx, userID, &t) {
		return nil, ErrNotVisible
	}
	return &t, nil
}

// requireMutationRole enforces the partner/admin gate on Project-linked
// Appointment mutations. The Appointment's own creator is also allowed.
func (s *AppointmentService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Appointment) error {
	if t.CreatedBy != nil && *t.CreatedBy == userID {
		return nil
	}
	user, err := s.users().GetByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil {
		return ErrNotVisible
	}
	if user.GlobalRole != "global_admin" {
		return fmt.Errorf("%w: only partners/admins can modify Appointments on a Project", ErrForbidden)
	}
	return nil
}

// canSee mirrors the SELECT visibility predicate for one in-memory Appointment.
func (s *AppointmentService) canSee(ctx context.Context, userID uuid.UUID, t *models.Appointment) bool {
	if t.ProjectID == nil {
		return t.CreatedBy != nil && *t.CreatedBy == userID
	}
	_, err := s.projects.GetByID(ctx, userID, *t.ProjectID)
	return err == nil
}

// Create inserts a Appointment. If project_id is set, ProjectService visibility
// is enforced and the Project's audit trail records the new appointment.
func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input CreateAppointmentInput) (*models.Appointment, error) {
	title := strings.TrimSpace(input.Title)
	if title == "" {
		return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
	}
	if input.StartAt.IsZero() {
		return nil, fmt.Errorf("%w: start_at is required", ErrInvalidInput)
	}
	if input.EndAt != nil && input.EndAt.Before(input.StartAt) {
		return nil, fmt.Errorf("%w: end_at must be after start_at", ErrInvalidInput)
	}
	if input.AppointmentType != nil && *input.AppointmentType != "" && !isValidAppointmentType(*input.AppointmentType) {
		return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *input.AppointmentType)
	}

	if input.ProjectID != nil {
		if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
			return nil, err
		}
	}

	id := uuid.New()
	now := time.Now().UTC()

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

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.appointments
		   (id, project_id, title, description, start_at, end_at, location,
		    appointment_type, created_by, created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`,
		id, input.ProjectID, title, input.Description, input.StartAt.UTC(),
		nullableUTC(input.EndAt), input.Location, input.AppointmentType, userID, now,
	); err != nil {
		return nil, fmt.Errorf("insert appointment: %w", err)
	}

	if input.ProjectID != nil {
		// Description carries value-only payload (the appointment title); frontend
		// renders via the localized event.description.appointment_* template. Same
		// pattern for updated/deleted below. Metadata carries the appointment id
		// so the Verlauf entry deep-links to /appointments/{id} (t-paliad-102).
		desc := title
		descPtr := &desc
		if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID, "appointment_created", "Appointment created", descPtr,
			map[string]any{"appointment_id": id}); err != nil {
			return nil, err
		}

		// Approval gate (t-paliad-138). No-op for personal appointments
		// (project_id IS NULL) and when no policy applies.
		//
		// Agent-suggested path (t-paliad-161): when input.AgentTurnID is
		// set, the row goes through the agent-create variant which always
		// creates a request (bypassing the policy gate) and stamps the
		// request with requester_kind='agent' + the originating turn id.
		if s.approvals != nil {
			payload := map[string]any{"title": title, "start_at": input.StartAt.UTC().Format(time.RFC3339)}
			if input.AgentTurnID != nil {
				if _, err := s.approvals.SubmitAgentCreate(ctx, tx, *input.ProjectID, id, userID, *input.AgentTurnID, EntityTypeAppointment, payload); err != nil {
					return nil, err
				}
			} else {
				if _, err := s.approvals.SubmitCreate(ctx, tx, *input.ProjectID, id, userID, EntityTypeAppointment, payload); err != nil {
					return nil, err
				}
			}
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit insert appointment: %w", err)
	}

	t, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	if s.caldav != nil {
		s.caldav.OnAppointmentCreated(ctx, userID, t)
	}
	return t, nil
}

// Update applies a partial update.
//
// Approval gate (t-paliad-138): only date-bearing fields (start_at,
// end_at) trigger 4-eye per Q4. Cosmetic edits (title, description,
// location, appointment_type) bypass approval. Personal appointments
// (project_id IS NULL) never gate — there's no project policy to consult.
func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID uuid.UUID, input UpdateAppointmentInput) (*models.Appointment, error) {
	current, err := s.GetByID(ctx, userID, appointmentID)
	if err != nil {
		return nil, err
	}
	if current.ProjectID == nil {
		if current.CreatedBy == nil || *current.CreatedBy != userID {
			return nil, fmt.Errorf("%w: only the creator can edit a personal Appointment", ErrForbidden)
		}
	} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
		return nil, err
	}
	if current.ApprovalStatus == ApprovalStatusPending {
		return nil, s.pendingApprovalErr(ctx, appointmentID)
	}

	sets := []string{}
	args := []any{}
	next := 1
	appendSet := func(col string, val any) {
		sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
		args = append(args, val)
		next++
	}

	preImage := map[string]any{}
	payload := map[string]any{}

	if input.Title != nil {
		title := strings.TrimSpace(*input.Title)
		if title == "" {
			return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
		}
		appendSet("title", title)
	}
	if input.Description != nil {
		appendSet("description", *input.Description)
	}
	if input.StartAt != nil {
		newStart := input.StartAt.UTC()
		if !newStart.Equal(current.StartAt) {
			preImage["start_at"] = current.StartAt.Format(time.RFC3339)
			payload["start_at"] = newStart.Format(time.RFC3339)
		}
		appendSet("start_at", newStart)
	}
	if input.EndAt != nil {
		newEnd := input.EndAt.UTC()
		oldEnd := time.Time{}
		if current.EndAt != nil {
			oldEnd = *current.EndAt
		}
		if !newEnd.Equal(oldEnd) {
			if current.EndAt != nil {
				preImage["end_at"] = current.EndAt.Format(time.RFC3339)
			} else {
				preImage["end_at"] = nil
			}
			payload["end_at"] = newEnd.Format(time.RFC3339)
		}
		appendSet("end_at", newEnd)
	}
	if input.Location != nil {
		appendSet("location", *input.Location)
	}
	if input.AppointmentType != nil {
		if *input.AppointmentType != "" && !isValidAppointmentType(*input.AppointmentType) {
			return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *input.AppointmentType)
		}
		appendSet("appointment_type", *input.AppointmentType)
	}

	// Project move (t-paliad-140). ClearProject takes precedence over
	// ProjectID so a payload that sets both falls into the unlink branch
	// rather than silently ignoring the contradiction. Visibility on the
	// destination is enforced via projects.GetByID (matches Create).
	// Unlinking to a personal appointment is creator-only — same gate
	// personal-only Update branches enforce above — so a non-creator who
	// can mutate the project-attached row can't strand it on someone else's
	// personal calendar.
	var movedFromProject *uuid.UUID
	var movedToProject *uuid.UUID
	if input.ClearProject {
		if current.ProjectID != nil {
			if current.CreatedBy == nil || *current.CreatedBy != userID {
				return nil, fmt.Errorf("%w: only the creator can convert this Appointment to personal", ErrForbidden)
			}
			from := *current.ProjectID
			movedFromProject = &from
			appendSet("project_id", nil)
		}
	} else if input.ProjectID != nil {
		if current.ProjectID == nil || *input.ProjectID != *current.ProjectID {
			if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
				return nil, err
			}
			to := *input.ProjectID
			movedToProject = &to
			if current.ProjectID != nil {
				from := *current.ProjectID
				movedFromProject = &from
			}
			appendSet("project_id", *input.ProjectID)
		}
	}

	if len(sets) == 0 {
		return current, nil
	}
	appendSet("updated_at", time.Now().UTC())
	args = append(args, appointmentID)

	query := fmt.Sprintf("UPDATE paliad.appointments SET %s WHERE id = $%d",
		strings.Join(sets, ", "), next)

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

	if _, err := tx.ExecContext(ctx, query, args...); err != nil {
		return nil, fmt.Errorf("update appointment: %w", err)
	}

	// Audit emission. Project moves (t-paliad-140) get their own
	// appointment_project_changed pair so the OLD and NEW project rows
	// keep an honest chronology. Edits to other fields land as
	// appointment_updated on whichever project the row sits on AFTER the
	// move (or on the source project if it was unlinked). Personal
	// appointments don't have audit history, so unlink/link rows on the
	// "personal" side are skipped.
	desc := current.Title
	descPtr := &desc
	if movedFromProject != nil || movedToProject != nil {
		moveMeta := map[string]any{"appointment_id": appointmentID}
		if movedFromProject != nil {
			moveMeta["from_project_id"] = *movedFromProject
		}
		if movedToProject != nil {
			moveMeta["to_project_id"] = *movedToProject
		}
		if movedFromProject != nil {
			if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
				"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
				return nil, err
			}
		}
		if movedToProject != nil {
			if err := insertProjectEventWithMeta(ctx, tx, *movedToProject, userID,
				"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
				return nil, err
			}
		}
	}
	otherFieldsTouched := input.Title != nil || input.Description != nil ||
		input.StartAt != nil || input.EndAt != nil || input.Location != nil ||
		input.AppointmentType != nil
	if otherFieldsTouched {
		// After-move project. If the row is now personal (unlink), no
		// audit row — personal appointments don't surface in any
		// project's Verlauf.
		var auditProject *uuid.UUID
		switch {
		case movedToProject != nil:
			auditProject = movedToProject
		case movedFromProject != nil:
			// Unlink: no audit project
		default:
			auditProject = current.ProjectID
		}
		if auditProject != nil {
			if err := insertProjectEventWithMeta(ctx, tx, *auditProject, userID, "appointment_updated", "Appointment updated", descPtr,
				map[string]any{"appointment_id": appointmentID}); err != nil {
				return nil, err
			}
		}
		if s.approvals != nil {
			if _, err := s.approvals.SubmitUpdate(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage, payload); err != nil {
				return nil, err
			}
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit update appointment: %w", err)
	}
	t, err := s.GetByID(ctx, userID, appointmentID)
	if err != nil {
		return nil, err
	}
	if s.caldav != nil {
		s.caldav.OnAppointmentUpdated(ctx, userID, t)
	}
	return t, nil
}

// Delete removes an Appointment.
//
// Approval gate (t-paliad-138): for project-attached appointments, if a
// (project, appointment, delete) policy applies, the row stays alive
// with approval_status='pending' until the approver hard-deletes
// (approve) or restores it (reject) — same stage-then-write exception
// as DeadlineService.Delete.
func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID uuid.UUID) error {
	current, err := s.GetByID(ctx, userID, appointmentID)
	if err != nil {
		return err
	}
	if current.ProjectID == nil {
		if current.CreatedBy == nil || *current.CreatedBy != userID {
			return fmt.Errorf("%w: only the creator can delete a personal Appointment", ErrForbidden)
		}
	} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
		return err
	}
	if current.ApprovalStatus == ApprovalStatusPending {
		return s.pendingApprovalErr(ctx, appointmentID)
	}

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

	// Approval gate runs first for project-attached appointments. If a
	// policy applies, SubmitDelete returns a non-nil request id and we
	// skip the hard delete + the deletion event emit.
	var pendingRequest *uuid.UUID
	if current.ProjectID != nil && s.approvals != nil {
		preImage := map[string]any{
			"title":    current.Title,
			"start_at": current.StartAt.Format(time.RFC3339),
		}
		req, err := s.approvals.SubmitDelete(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage)
		if err != nil {
			return err
		}
		pendingRequest = req
	}

	if pendingRequest == nil {
		if _, err := tx.ExecContext(ctx,
			`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
			return fmt.Errorf("delete appointment: %w", err)
		}
		if current.ProjectID != nil {
			desc := current.Title
			descPtr := &desc
			if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
				return err
			}
		}
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit delete appointment: %w", err)
	}
	if pendingRequest == nil && s.caldav != nil {
		s.caldav.OnAppointmentDeleted(ctx, userID, current)
	}
	return nil
}

// AppointmentSummaryCounts buckets visible Appointments into today / this_week / later.
type AppointmentSummaryCounts struct {
	Today    int `json:"today"     db:"today"`
	ThisWeek int `json:"this_week" db:"this_week"`
	Later    int `json:"later"     db:"later"`
	Total    int `json:"total"     db:"total"`
}

// SummaryCounts aggregates Appointments by start-date bucket for the user's visible projects.
func (s *AppointmentService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*AppointmentSummaryCounts, error) {
	user, err := s.users().GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return &AppointmentSummaryCounts{}, nil
	}
	now := time.Now().UTC()
	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	tomorrow := today.AddDate(0, 0, 1)
	endOfWeek := today.AddDate(0, 0, 7)

	query := `
		SELECT
		  COUNT(*) FILTER (WHERE t.start_at >= :today    AND t.start_at < :tomorrow) AS today,
		  COUNT(*) FILTER (WHERE t.start_at >= :tomorrow AND t.start_at < :endweek)  AS this_week,
		  COUNT(*) FILTER (WHERE t.start_at >= :endweek)                              AS later,
		  COUNT(*) FILTER (WHERE t.start_at >= :today)                                AS total
		  FROM paliad.appointments t
		  LEFT JOIN paliad.projects p ON p.id = t.project_id
		 WHERE
		   (t.project_id IS NULL AND t.created_by = :user_id)
		   OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)`

	stmt, err := s.db.PrepareNamedContext(ctx, query)
	if err != nil {
		return nil, fmt.Errorf("prepare appointment summary: %w", err)
	}
	defer stmt.Close()

	var c AppointmentSummaryCounts
	if err := stmt.GetContext(ctx, &c, map[string]any{
		"today":    today,
		"tomorrow": tomorrow,
		"endweek":  endOfWeek,
		"user_id":  userID,
	}); err != nil {
		return nil, fmt.Errorf("appointment summary: %w", err)
	}
	return &c, nil
}

// SetCalDAVMeta is called by the CalDAV service after a successful push.
func (s *AppointmentService) SetCalDAVMeta(ctx context.Context, appointmentID uuid.UUID, uid, etag string) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE paliad.appointments
		    SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW()
		  WHERE id = $3`, uid, etag, appointmentID)
	if err != nil {
		return fmt.Errorf("update appointment caldav meta: %w", err)
	}
	return nil
}

// AllForUser returns every Appointment (personal + visible Project-attached) the
// user owns. Used by the CalDAV push loop.
func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) ([]models.Appointment, error) {
	user, err := s.users().GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, nil
	}
	rows := []models.Appointment{}
	query := `
		SELECT ` + appointmentColumns + `
		  FROM paliad.appointments t
		  LEFT JOIN paliad.projects p ON p.id = t.project_id
		 WHERE
		   (t.project_id IS NULL AND t.created_by = $1)
		   OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)`
	if err := s.db.SelectContext(ctx, &rows, query, userID); err != nil {
		return nil, fmt.Errorf("all appointments for user: %w", err)
	}
	return rows, nil
}

// ErrUnsupportedScope is returned by ForBinding when the binding's
// scope_kind is one of the hierarchy scopes (client / litigation /
// patent / case) — those land in Slice 3 of t-paliad-212. Slice 2
// only supports all_visible / personal_only / project.
var ErrUnsupportedScope = errors.New("binding scope_kind not yet supported")

// ForBinding returns the slice of the user's appointments that belongs
// in this binding's calendar. Implements the §2.3 scope filter from
// docs/design-caldav-slice-2-2026-05-20.md.
//
//   - all_visible   → AllForUser(userID)
//   - personal_only → personal (project_id IS NULL) appointments
//                     created by this user
//   - project       → appointments attached to scope_id, gated by the
//                     same visibility predicate as AllForUser. Hidden
//                     projects return an empty slice (the binding stays
//                     in place but receives no events). If
//                     include_personal is true, the user's personal
//                     appointments are unioned in.
//
// Hierarchy scopes (client / litigation / patent / case) return
// ErrUnsupportedScope; Slice 3 wires them via the existing path-based
// descendant predicate.
func (s *AppointmentService) ForBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding) ([]models.Appointment, error) {
	if b == nil {
		return nil, fmt.Errorf("%w: nil binding", ErrInvalidInput)
	}
	switch b.ScopeKind {
	case models.BindingScopeAllVisible:
		return s.AllForUser(ctx, userID)

	case models.BindingScopePersonalOnly:
		rows := []models.Appointment{}
		if err := s.db.SelectContext(ctx, &rows,
			`SELECT `+appointmentColumns+`
			   FROM paliad.appointments t
			  WHERE t.project_id IS NULL
			    AND t.created_by = $1`, userID); err != nil {
			return nil, fmt.Errorf("for-binding personal_only: %w", err)
		}
		return rows, nil

	case models.BindingScopeProject:
		if b.ScopeID == nil {
			return nil, fmt.Errorf("%w: project binding missing scope_id", ErrInvalidInput)
		}
		var query string
		if b.IncludePersonal {
			query = `
				SELECT ` + appointmentColumns + `
				  FROM paliad.appointments t
				  LEFT JOIN paliad.projects p ON p.id = t.project_id
				 WHERE (
				   t.project_id = $2
				   AND ` + visibilityPredicatePositional("p", 1) + `
				 ) OR (
				   t.project_id IS NULL AND t.created_by = $1
				 )`
		} else {
			query = `
				SELECT ` + appointmentColumns + `
				  FROM paliad.appointments t
				  JOIN paliad.projects p ON p.id = t.project_id
				 WHERE t.project_id = $2
				   AND ` + visibilityPredicatePositional("p", 1)
		}
		rows := []models.Appointment{}
		if err := s.db.SelectContext(ctx, &rows, query, userID, *b.ScopeID); err != nil {
			return nil, fmt.Errorf("for-binding project: %w", err)
		}
		return rows, nil

	case models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
		return nil, ErrUnsupportedScope

	default:
		return nil, fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, b.ScopeKind)
	}
}

// FindByCalDAVUID resolves a Appointment from its external UID.
func (s *AppointmentService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Appointment, error) {
	var t models.Appointment
	err := s.db.GetContext(ctx, &t,
		`SELECT `+appointmentColumns+` FROM paliad.appointments WHERE caldav_uid = $1`, uid)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotVisible
	}
	if err != nil {
		return nil, fmt.Errorf("find appointment by caldav uid: %w", err)
	}
	return &t, nil
}

// ApplyRemoteUpdate writes pulled CalDAV changes into the local row.
func (s *AppointmentService) ApplyRemoteUpdate(ctx context.Context, appointmentID uuid.UUID, title, description, location *string, startAt, endAt *time.Time, etag string) (bool, error) {
	sets := []string{"caldav_etag = $1", "updated_at = NOW()"}
	args := []any{etag}
	next := 2
	changed := false
	if title != nil {
		sets = append(sets, fmt.Sprintf("title = $%d", next))
		args = append(args, *title)
		next++
		changed = true
	}
	if description != nil {
		sets = append(sets, fmt.Sprintf("description = $%d", next))
		args = append(args, *description)
		next++
		changed = true
	}
	if location != nil {
		sets = append(sets, fmt.Sprintf("location = $%d", next))
		args = append(args, *location)
		next++
		changed = true
	}
	if startAt != nil {
		sets = append(sets, fmt.Sprintf("start_at = $%d", next))
		args = append(args, startAt.UTC())
		next++
		changed = true
	}
	if endAt != nil {
		sets = append(sets, fmt.Sprintf("end_at = $%d", next))
		args = append(args, endAt.UTC())
		next++
		changed = true
	}
	args = append(args, appointmentID)
	query := fmt.Sprintf("UPDATE paliad.appointments SET %s WHERE id = $%d",
		strings.Join(sets, ", "), next)
	if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
		return false, fmt.Errorf("apply remote appointment update: %w", err)
	}
	return changed, nil
}

// DeleteByCalDAVUID removes an Appointment pulled-deleted from the remote calendar.
func (s *AppointmentService) DeleteByCalDAVUID(ctx context.Context, uid string) error {
	_, err := s.db.ExecContext(ctx,
		`DELETE FROM paliad.appointments WHERE caldav_uid = $1`, uid)
	if err != nil {
		return fmt.Errorf("delete appointment by caldav uid: %w", err)
	}
	return nil
}

// LogConflict appends a conflict event to the parent Project's audit trail.
// No-op for personal Appointments.
func (s *AppointmentService) LogConflict(ctx context.Context, appointmentID uuid.UUID, msg string) error {
	var row struct {
		ProjectID *uuid.UUID `db:"project_id"`
		CreatedBy *uuid.UUID `db:"created_by"`
	}
	err := s.db.GetContext(ctx, &row,
		`SELECT project_id, created_by FROM paliad.appointments WHERE id = $1`, appointmentID)
	if err != nil || row.ProjectID == nil {
		return nil //nolint:nilerr
	}
	now := time.Now().UTC()
	desc := msg
	_, err = s.db.ExecContext(ctx,
		`INSERT INTO paliad.project_events
		   (id, project_id, event_type, title, description, event_date,
		    created_by, metadata, created_at, updated_at)
		 VALUES ($1, $2, 'caldav_conflict', 'CalDAV conflict', $3, $4, $5, '{}', $4, $4)`,
		uuid.New(), *row.ProjectID, desc, now, row.CreatedBy)
	if err != nil {
		return fmt.Errorf("insert caldav conflict event: %w", err)
	}
	return nil
}

// users returns the shared user service via the Project handle.
func (s *AppointmentService) users() *UserService {
	return s.projects.Users()
}

func nullableUTC(t *time.Time) any {
	if t == nil {
		return nil
	}
	u := t.UTC()
	return u
}

func isValidAppointmentType(t string) bool {
	switch t {
	case "hearing", "meeting", "consultation", "deadline_hearing":
		return true
	}
	return false
}
