package services

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

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

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

// DeadlineService reads and writes paliad.deadlines. Visibility inherits from the
// parent Project via ProjectService.GetByID — every read or write goes through
// that gate first.
//
// Audit: every mutation appends a paliad.project_events row via
// insertProjectEvent so the Project verlauf shows what changed.
//
// EventTypes: every Deadline can carry 0..N paliad.event_types via the
// paliad.deadline_event_types junction. The dependency is optional so
// the service stays runnable in isolated tests; in production main.go
// always wires it.
type DeadlineService struct {
	db         *sqlx.DB
	projects   *ProjectService
	eventTypes *EventTypeService
	approvals  *ApprovalService
}

// NewDeadlineService wires the service. eventTypes and approvals may be
// nil in tests that don't exercise those features; production wires both.
func NewDeadlineService(db *sqlx.DB, projects *ProjectService, eventTypes *EventTypeService) *DeadlineService {
	return &DeadlineService{db: db, projects: projects, eventTypes: eventTypes}
}

// SetApprovalService wires the optional 4-eye approval workflow
// (t-paliad-138). When set, every Create/Update/Complete/Delete consults
// paliad.approval_policies and may stage the change as a pending request
// instead of applying it directly. main.go wires this in production;
// tests that don't exercise the workflow can leave it unset.
func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
	s.approvals = a
}

// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
// approval_request id + required_role for an entity, so handlers can
// render a 409 body that points the UI at the blocking request. Falls
// back to the bare ErrConcurrentPending if approvals isn't wired or the
// lookup fails — the user still gets a 409, just without the structured
// hint.
func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uuid.UUID) error {
	if s.approvals == nil {
		return ErrConcurrentPending
	}
	rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeDeadline, deadlineID)
	if err != nil || rid == "" {
		return ErrConcurrentPending
	}
	return NewPendingApprovalError(rid, role)
}

// Slice B.4 (mig 140, t-paliad-305): rule_id column dropped from
// paliad.deadlines. sequencing_rule_id holds the same UUID and is the
// FK to paliad.sequencing_rules. SELECT-column lists below pull
// sequencing_rule_id into the Deadline.RuleID field (db tag adjusted in
// internal/models/models.go).
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
	warning_date, source, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
	notes, created_by, created_at, updated_at,
	approval_status, pending_request_id, approved_by, approved_at`

// CreateDeadlineInput is the payload for Create / bulk create entries.
type CreateDeadlineInput struct {
	Title           string      `json:"title"`
	Description     *string     `json:"description,omitempty"`
	DueDate         string      `json:"due_date"` // YYYY-MM-DD
	OriginalDueDate *string     `json:"original_due_date,omitempty"`
	RuleID          *uuid.UUID  `json:"rule_id,omitempty"`
	// RuleCode is the legal citation ("RoP.023") to display in REGEL.
	// Sent by the Fristenrechner save flow so the title can stay clean
	// instead of carrying the citation as a prefix.
	RuleCode        *string     `json:"rule_code,omitempty"`
	// CustomRuleText is the lawyer's free-text rule label when the
	// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
	// with RuleID at the application layer; the service trims and treats
	// an all-whitespace value as nil.
	CustomRuleText  *string     `json:"custom_rule_text,omitempty"`
	Source          string      `json:"source,omitempty"` // default "manual"
	Notes           *string     `json:"notes,omitempty"`
	EventTypeIDs    []uuid.UUID `json:"event_type_ids,omitempty"`
	// AgentTurnID, when non-nil, marks the create as a Paliadin-drafted
	// suggestion (t-paliad-161). The deadline lands as approval_status='pending'
	// with requester_kind='agent' on the approval_request, regardless of
	// whether a (project, deadline, create) policy applies. Default-zero
	// behaviour matches the user-direct path.
	AgentTurnID *uuid.UUID `json:"agent_turn_id,omitempty"`
}

// UpdateDeadlineInput is the partial-update payload for PATCH.
// EventTypeIDs uses pointer-to-slice semantics: nil = leave existing
// attachments untouched; non-nil (including empty) = replace.
//
// ProjectID, when non-nil, moves the deadline under a different project
// (t-paliad-140). The caller must be able to see the new project; the
// service emits a deadline_project_changed audit row on both the old and
// new project so each side's Verlauf still shows the move.
type UpdateDeadlineInput struct {
	Title        *string      `json:"title,omitempty"`
	Description  *string      `json:"description,omitempty"`
	DueDate      *string      `json:"due_date,omitempty"` // YYYY-MM-DD
	Notes        *string      `json:"notes,omitempty"`
	Status       *string      `json:"status,omitempty"`
	ProjectID    *uuid.UUID   `json:"project_id,omitempty"`
	EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`

	// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
	// shapes; the service rejects "both set":
	//   - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
	//     bind to the catalog rule, clear custom_rule_text.
	//   - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
	//     store free text, clear rule_id.
	//   - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
	//     clear both columns.
	// RuleSet=false leaves both columns untouched (the rest of the
	// PATCH body doesn't carry rule changes).
	RuleSet        bool       `json:"rule_set,omitempty"`
	RuleID         *uuid.UUID `json:"rule_id,omitempty"`
	CustomRuleText *string    `json:"custom_rule_text,omitempty"`
}

// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
//
// Bucket math (t-paliad-106): Heute / Diese Woche / Nächste Woche are disjoint
// by date; "this_week" runs from tomorrow through the upcoming Sunday
// inclusive, "next_week" from the following Monday through that Sunday
// inclusive. Items further out than next Sunday are not in any of the four
// active buckets — they only show up under "all" / "pending" / "upcoming"
// (the latter is kept as a server-side back-compat alias for "everything
// pending after this Sunday", but the dashboard + /deadlines summary cards
// no longer expose it).
type DeadlineStatusFilter string

const (
	DeadlineFilterAll       DeadlineStatusFilter = "all"
	DeadlineFilterOverdue   DeadlineStatusFilter = "overdue"
	DeadlineFilterToday     DeadlineStatusFilter = "today"
	DeadlineFilterThisWeek  DeadlineStatusFilter = "this_week"
	DeadlineFilterNextWeek  DeadlineStatusFilter = "next_week"
	DeadlineFilterLater     DeadlineStatusFilter = "later"
	DeadlineFilterUpcoming  DeadlineStatusFilter = "upcoming"
	DeadlineFilterCompleted DeadlineStatusFilter = "completed"
	DeadlineFilterPending   DeadlineStatusFilter = "pending"
)

// ListFilter narrows ListVisibleForUser results.
//
// EventTypeIDs / IncludeUntyped form the multi-select Typ filter on
// /deadlines and /agenda (t-paliad-088). The two flags compose with OR:
// a deadline matches if it has at least one of EventTypeIDs attached
// OR (IncludeUntyped && it has none). When BOTH are zero/false the
// filter is inactive.
//
// CreatedBy narrows to deadlines whose `created_by = id`. Backs the
// "Nur persönliche" filter on /events (t-paliad-128) — applied on top of
// the team-visibility predicate so a deadline a user created on a team
// they have since left still doesn'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 ListFilter struct {
	Status         DeadlineStatusFilter
	ProjectID      *uuid.UUID
	EventTypeIDs   []uuid.UUID
	IncludeUntyped bool
	CreatedBy      *uuid.UUID
	DirectOnly     bool
}

// ListVisibleForUser returns Deadlines on every Project the user can see,
// joined with parent-Project display fields. Sorted by due_date ascending.
func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.DeadlineWithProject, error) {
	user, err := s.users().GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return []models.DeadlineWithProject{}, nil
	}

	conds := []string{visibilityPredicate("p")}
	args := map[string]any{
		"user_id": userID,
	}
	if filter.ProjectID != nil {
		if filter.DirectOnly {
			conds = append(conds, `f.project_id = :project_id`)
		} else {
			conds = append(conds, projectDescendantPredicate("p"))
		}
		args["project_id"] = *filter.ProjectID
	}
	if filter.CreatedBy != nil {
		conds = append(conds, `f.created_by = :created_by`)
		args["created_by"] = *filter.CreatedBy
	}
	if etCond := buildEventTypeFilterClause(filter, args); etCond != "" {
		conds = append(conds, etCond)
	}

	b := computeDeadlineBucketBounds(time.Now().UTC())

	switch filter.Status {
	case DeadlineFilterOverdue:
		conds = append(conds, `f.status = 'pending' AND f.due_date < :today`)
		args["today"] = b.today
	case DeadlineFilterToday:
		// "Heute" includes pending due today AND items that were completed
		// today (rendered strikethrough/green client-side). m, 2026-05-22:
		// previously filtered out the moment a row was checked off, so a
		// user couldn't see their own progress on the day's deadlines.
		// Items completed on earlier days still drop out — the bucket
		// stays scoped to "today's work".
		// date(...) cast instead of `::date` — sqlx's named-parameter parser
		// reads `::date` as `::` + `:date` placeholder and rewrites it into
		// a syntax error against Postgres.
		conds = append(conds, `f.due_date = :today AND (f.status = 'pending' OR date(f.completed_at) = :today)`)
		args["today"] = b.today
	case DeadlineFilterThisWeek:
		conds = append(conds, `f.status = 'pending' AND f.due_date > :today AND f.due_date < :next_monday`)
		args["today"] = b.today
		args["next_monday"] = b.nextMonday
	case DeadlineFilterNextWeek:
		conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after`)
		args["next_monday"] = b.nextMonday
		args["week_after"] = b.weekAfter
	case DeadlineFilterLater:
		// "Später" — pending deadlines past next Sunday (t-paliad-110).
		// The card on /deadlines clicks through to this filter; the dropdown
		// also exposes it as the always-future-of-bucketed-window option.
		conds = append(conds, `f.status = 'pending' AND f.due_date >= :week_after`)
		args["week_after"] = b.weekAfter
	case DeadlineFilterUpcoming:
		// Back-compat: "upcoming" used to mean "anything pending past this week".
		// Kept so legacy bookmarks / third-party links don't 4xx; the new UI
		// surfaces the disjoint Heute / Diese Woche / Nächste Woche buckets
		// instead.
		conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday`)
		args["next_monday"] = b.nextMonday
	case DeadlineFilterCompleted:
		conds = append(conds, `f.status = 'completed'`)
	case DeadlineFilterPending:
		conds = append(conds, `f.status = 'pending'`)
	case DeadlineFilterAll, "":
		// no-op
	default:
		return nil, fmt.Errorf("%w: unknown status filter %q", ErrInvalidInput, filter.Status)
	}

	query := `
		SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
		       f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
		       f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
		       f.created_at, f.updated_at,
		       f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
		       p.reference AS project_reference,
		       p.title     AS project_title,
		       p.type      AS project_type,
		       r.name      AS rule_name,
		       r.name_en   AS rule_name_en,
		       ar.requester_kind AS requester_kind
		  FROM paliad.deadlines f
		  JOIN paliad.projects p ON p.id = f.project_id
		  LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.sequencing_rule_id
		  LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id
		 WHERE ` + strings.Join(conds, " AND ") + `
		 ORDER BY f.due_date ASC, f.created_at DESC`

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

	rows := []models.DeadlineWithProject{}
	if err := stmt.SelectContext(ctx, &rows, args); err != nil {
		return nil, fmt.Errorf("list deadlines: %w", err)
	}
	if len(rows) > 0 {
		ids := make([]uuid.UUID, len(rows))
		for i := range rows {
			ids[i] = rows[i].ID
		}
		etByID, err := s.hydrateEventTypes(ctx, ids)
		if err != nil {
			return nil, err
		}
		for i := range rows {
			rows[i].EventTypeIDs = etByID[rows[i].ID]
		}
	}
	return rows, nil
}

// ListForProject returns Deadlines for a Project (visibility-checked).
//
// When directOnly is false (default), the result aggregates deadlines from
// the Project itself AND every descendant Project (per the t-paliad-139
// hierarchy aggregation contract). When directOnly is true, only deadlines
// whose project_id exactly equals the filter are returned — useful for
// edit / attribution surfaces that want exact narrowing.
//
// The descendant aggregation reuses the materialised path on
// paliad.projects (text-shaped, t-paliad-018). The visibility check on
// the filter Project is sufficient: paliad.can_see_project walks ancestors,
// so a user who can see Project P can see every descendant of P.
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Deadline, error) {
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}
	rows := []models.Deadline{}
	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 `+deadlineColumns+`
		   FROM paliad.deadlines
		  `+filter+`
		  ORDER BY due_date ASC, created_at DESC`, projectID); err != nil {
		return nil, fmt.Errorf("list deadlines for project: %w", err)
	}
	if len(rows) > 0 {
		ids := make([]uuid.UUID, len(rows))
		for i := range rows {
			ids[i] = rows[i].ID
		}
		etByID, err := s.hydrateEventTypes(ctx, ids)
		if err != nil {
			return nil, err
		}
		for i := range rows {
			rows[i].EventTypeIDs = etByID[rows[i].ID]
		}
	}
	return rows, nil
}

// GetByID returns a single Deadline, with parent Project visibility checked.
func (s *DeadlineService) GetByID(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
	projectID, err := s.parentProjectID(ctx, deadlineID)
	if err != nil {
		return nil, err
	}
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}
	var f models.Deadline
	if err := s.db.GetContext(ctx, &f,
		`SELECT `+deadlineColumns+` FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
		return nil, fmt.Errorf("fetch deadline: %w", err)
	}
	etIDs, err := s.hydrateEventTypes(ctx, []uuid.UUID{f.ID})
	if err != nil {
		return nil, err
	}
	f.EventTypeIDs = etIDs[f.ID]
	return &f, nil
}

// Create inserts a single Deadline under a Project.
func (s *DeadlineService) Create(ctx context.Context, userID, projectID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}
	if err := s.validateEventTypeIDs(ctx, userID, input.EventTypeIDs); err != nil {
		return nil, err
	}
	id, err := s.insert(ctx, userID, projectID, input)
	if err != nil {
		return nil, err
	}
	return s.GetByID(ctx, userID, id)
}

// validateEventTypeIDs returns nil if every id is visible to userID, or
// ErrNotVisible / ErrInvalidInput otherwise. No-op when ids is empty
// or when the eventTypes service is unwired (test harness).
func (s *DeadlineService) validateEventTypeIDs(ctx context.Context, userID uuid.UUID, ids []uuid.UUID) error {
	if len(ids) == 0 || s.eventTypes == nil {
		return nil
	}
	if _, err := s.eventTypes.ValidateForUser(ctx, userID, ids); err != nil {
		return err
	}
	return nil
}

// CreateBulk inserts multiple Deadlines under one Project in a single
// transaction (Fristenrechner "Als Deadline(en) speichern" flow).
func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projectID uuid.UUID, inputs []CreateDeadlineInput) ([]models.Deadline, error) {
	if len(inputs) == 0 {
		return nil, fmt.Errorf("%w: at least one Deadline is required", ErrInvalidInput)
	}
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}

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

	ids := make([]uuid.UUID, 0, len(inputs))
	for _, in := range inputs {
		if err := s.validateEventTypeIDs(ctx, userID, in.EventTypeIDs); err != nil {
			return nil, err
		}
		id, err := s.insertTx(ctx, tx, userID, projectID, in)
		if err != nil {
			return nil, err
		}
		if s.eventTypes != nil && len(in.EventTypeIDs) > 0 {
			if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, id, in.EventTypeIDs); err != nil {
				return nil, err
			}
		}
		ids = append(ids, id)
	}

	// Description carries the value-only payload (the import count); the
	// frontend renders via the localized event.description.deadlines_imported
	// template.
	desc := strconv.Itoa(len(inputs))
	descPtr := &desc
	if err := insertProjectEvent(ctx, tx, projectID, userID, "deadlines_imported", "Deadlines imported", descPtr); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit bulk create: %w", err)
	}

	out := make([]models.Deadline, 0, len(ids))
	for _, id := range ids {
		f, err := s.GetByID(ctx, userID, id)
		if err != nil {
			return nil, err
		}
		out = append(out, *f)
	}
	return out, nil
}

// Update applies a partial update to a Deadline.
//
// Approval gate (t-paliad-138): if any date-bearing field actually changes
// (due_date / original_due_date / warning_date — Q4 allowlist), the change
// is applied immediately AND parked in paliad.approval_requests with
// approval_status='pending' on the row. Approver flips it to 'approved'
// or rejects (which reverts the row from the snapshotted pre_image).
//
// Refuses to mutate a row whose approval_status is already 'pending'
// (a different request is in flight) — caller must wait or revoke.
func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
	current, err := s.GetByID(ctx, userID, deadlineID)
	if err != nil {
		return nil, err
	}
	if current.ApprovalStatus == ApprovalStatusPending {
		return nil, s.pendingApprovalErr(ctx, deadlineID)
	}

	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++
	}

	// Capture pre_image / payload for the date-bearing allowlist as fields
	// are about to be set. Only populated when a field actually changes —
	// SubmitUpdate skips the approval flow entirely when nothing in the
	// allowlist moved.
	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.DueDate != nil {
		due, err := time.Parse("2006-01-02", *input.DueDate)
		if err != nil {
			return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
		}
		if !due.Equal(current.DueDate) {
			preImage["due_date"] = current.DueDate.Format("2006-01-02")
			payload["due_date"] = *input.DueDate
		}
		appendSet("due_date", due)
	}
	if input.Notes != nil {
		appendSet("notes", *input.Notes)
	}
	if input.Status != nil {
		if !isValidDeadlineStatus(*input.Status) {
			return nil, fmt.Errorf("%w: invalid status %q", ErrInvalidInput, *input.Status)
		}
		appendSet("status", *input.Status)
		if *input.Status == "completed" && current.CompletedAt == nil {
			appendSet("completed_at", time.Now().UTC())
		} else if *input.Status != "completed" {
			appendSet("completed_at", nil)
		}
	}
	if input.EventTypeIDs != nil {
		if err := s.validateEventTypeIDs(ctx, userID, *input.EventTypeIDs); err != nil {
			return nil, err
		}
	}

	// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
	// persistence boundary: setting one column NULLs the other.
	if input.RuleSet {
		if input.RuleID != nil && input.CustomRuleText != nil {
			return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
		}
		// Slice B.4 (t-paliad-305): rule_id column dropped; the FK
		// back-link now lives on sequencing_rule_id. Same UUID value.
		// The procedural_event_id mirror is derived in
		// syncDeadlineDualLinks below after the primary UPDATE lands.
		appendSet("sequencing_rule_id", input.RuleID)
		var customText *string
		if input.CustomRuleText != nil {
			trimmed := strings.TrimSpace(*input.CustomRuleText)
			if trimmed != "" {
				customText = &trimmed
			}
		}
		appendSet("custom_rule_text", customText)
	}

	// Project move (t-paliad-140). Visibility on the destination is enforced
	// the same way as on Create — a GetByID round-trip through ProjectService
	// returns ErrNotVisible if the user can't see the target. Same-project
	// "moves" are silently dropped so a UI that always sends project_id in
	// the PATCH payload doesn't churn the Verlauf.
	var movedFromProject *uuid.UUID
	if input.ProjectID != nil && *input.ProjectID != current.ProjectID {
		if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
			return nil, err
		}
		appendSet("project_id", *input.ProjectID)
		from := current.ProjectID
		movedFromProject = &from
	}

	if len(sets) == 0 && input.EventTypeIDs == nil {
		return current, nil
	}
	if len(sets) > 0 {
		appendSet("updated_at", 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 len(sets) > 0 {
		args = append(args, deadlineID)
		query := fmt.Sprintf("UPDATE paliad.deadlines SET %s WHERE id = $%d",
			strings.Join(sets, ", "), next)
		if _, err := tx.ExecContext(ctx, query, args...); err != nil {
			return nil, fmt.Errorf("update deadline: %w", err)
		}
		// Slice B.4 (mig 140, t-paliad-305): rule_id column gone;
		// sequencing_rule_id holds the back-link. When the patch updated
		// it (auto/custom swap from t-paliad-258), mirror the FK onto
		// procedural_event_id so the joined view continues to resolve.
		// Idempotent: no-op when sequencing_rule_id is unchanged.
		if input.RuleSet {
			if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
				return nil, err
			}
		}
	}

	if input.EventTypeIDs != nil && s.eventTypes != nil {
		if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, deadlineID, *input.EventTypeIDs); err != nil {
			return nil, err
		}
	}

	// Description carries value-only payload (the deadline title); frontend
	// renders via the localized event.description.deadline_updated template.
	// Same pattern below for completed/reopened/deleted/created.
	//
	// Audit shape for project moves (t-paliad-140): emit
	// deadline_project_changed on both old and new project rows so each
	// side's Verlauf still shows the move (the row is gone from the old
	// project, but its history shouldn't be). If the same PATCH also
	// touched other fields, the new project additionally records a
	// deadline_updated covering those edits.
	desc := current.Title
	descPtr := &desc
	if movedFromProject != nil {
		moveMeta := map[string]any{
			"deadline_id":     deadlineID,
			"from_project_id": *movedFromProject,
			"to_project_id":   *input.ProjectID,
		}
		if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
			"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
			return nil, err
		}
		if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID,
			"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
			return nil, err
		}
	}
	// Did the PATCH touch anything beyond the project move?
	otherFieldsTouched := input.Title != nil || input.Description != nil ||
		input.DueDate != nil || input.Notes != nil || input.Status != nil ||
		input.EventTypeIDs != nil || input.RuleSet
	if otherFieldsTouched {
		auditProject := current.ProjectID
		if movedFromProject != nil {
			auditProject = *input.ProjectID
		}
		if err := insertProjectEventWithMeta(ctx, tx, auditProject, userID, "deadline_updated", "Deadline updated", descPtr,
			map[string]any{"deadline_id": deadlineID}); err != nil {
			return nil, err
		}
	}

	// Approval gate (Q4 = date-bearing allowlist only). When preImage is
	// empty (no allowlisted field changed), SubmitUpdate is a no-op.
	if s.approvals != nil {
		if _, err := s.approvals.SubmitUpdate(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, payload); err != nil {
			return nil, err
		}
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit update deadline: %w", err)
	}
	return s.GetByID(ctx, userID, deadlineID)
}

// Complete marks a Deadline as completed.
//
// Approval gate (t-paliad-138): if a (project, deadline, complete) policy
// applies, the row is flipped to status='completed' immediately AND
// parked in approval_requests with approval_status='pending'. Reject
// reverts (status back to 'pending', completed_at cleared).
func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
	current, err := s.GetByID(ctx, userID, deadlineID)
	if err != nil {
		return nil, err
	}
	if current.Status == "completed" {
		return current, nil
	}
	if current.ApprovalStatus == ApprovalStatusPending {
		return nil, s.pendingApprovalErr(ctx, deadlineID)
	}

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

	now := time.Now().UTC()
	if _, err := tx.ExecContext(ctx,
		`UPDATE paliad.deadlines
		    SET status = 'completed', completed_at = $1, updated_at = $1
		  WHERE id = $2`, now, deadlineID); err != nil {
		return nil, fmt.Errorf("complete deadline: %w", err)
	}
	desc := current.Title
	descPtr := &desc
	if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_completed", "Deadline completed", descPtr,
		map[string]any{"deadline_id": deadlineID}); err != nil {
		return nil, err
	}

	if s.approvals != nil {
		preImage := map[string]any{
			"status":       current.Status,
			"completed_at": nil,
		}
		if _, err := s.approvals.SubmitComplete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, nil); err != nil {
			return nil, err
		}
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit complete: %w", err)
	}
	return s.GetByID(ctx, userID, deadlineID)
}

// Reopen flips a completed Deadline back to pending and clears completed_at.
// Authorization: global admin OR a member of the Project (or any ancestor)
// with project_teams.responsibility = 'lead'. Other authenticated viewers
// can see the Deadline but cannot reopen it.
func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
	current, err := s.GetByID(ctx, userID, deadlineID)
	if err != nil {
		return nil, err
	}
	if err := s.assertCanAdminProject(ctx, userID, current.ProjectID); err != nil {
		return nil, err
	}
	if current.Status != "completed" {
		return current, nil
	}

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

	now := time.Now().UTC()
	if _, err := tx.ExecContext(ctx,
		`UPDATE paliad.deadlines
		    SET status = 'pending', completed_at = NULL, updated_at = $1
		  WHERE id = $2`, now, deadlineID); err != nil {
		return nil, fmt.Errorf("reopen deadline: %w", err)
	}
	desc := current.Title
	descPtr := &desc
	if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_reopened", "Deadline reopened", descPtr,
		map[string]any{"deadline_id": deadlineID}); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit reopen: %w", err)
	}
	return s.GetByID(ctx, userID, deadlineID)
}

// assertCanAdminProject returns nil if the user may perform admin-level
// actions on the Project (reopen, future bulk ops). Pass-conditions:
//   - users.global_role = 'global_admin', or
//   - direct/inherited project_teams membership with responsibility = 'lead'.
//
// Returns ErrForbidden otherwise. Visibility must be checked separately
// (callers do this via GetByID before calling here).
//
// t-paliad-148: switched from `role IN ('admin','lead')` to
// `responsibility = 'lead'`. The legacy 'admin' value was already dead
// since t-paliad-051 (project_teams.role never had an 'admin' value;
// only the legacy users.role enum did, before it was split into
// global_role).
func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, projectID uuid.UUID) error {
	user, err := s.users().GetByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil {
		return ErrNotVisible
	}
	if user.GlobalRole == "global_admin" {
		return nil
	}
	var ok bool
	err = s.db.GetContext(ctx, &ok,
		`SELECT EXISTS (
		    SELECT 1
		      FROM paliad.projects p
		      JOIN paliad.project_teams pt
		        ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
		     WHERE p.id = $1
		       AND pt.user_id = $2
		       AND pt.responsibility = 'lead'
		)`, projectID, userID)
	if err != nil {
		return fmt.Errorf("check project admin: %w", err)
	}
	if !ok {
		return fmt.Errorf("%w: only project leads can reopen Deadlines", ErrForbidden)
	}
	return nil
}

// Delete removes a Deadline. Partner/admin only.
//
// Approval gate (t-paliad-138): if a (project, deadline, delete) policy
// applies, this is the one stage-then-write exception in the otherwise
// write-then-approve architecture. The row stays alive with
// approval_status='pending' until the approver hard-deletes (approve) or
// restores it (reject).
func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UUID) error {
	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 delete Deadlines", ErrForbidden)
	}
	current, err := s.GetByID(ctx, userID, deadlineID)
	if err != nil {
		return err
	}
	if current.ApprovalStatus == ApprovalStatusPending {
		return s.pendingApprovalErr(ctx, deadlineID)
	}

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

	// Approval gate runs FIRST (before the actual delete). If a policy
	// applies, SubmitDelete returns a non-nil request id and we skip the
	// hard delete — the row is now flagged pending. The approver's
	// Approve flips it to a real delete; their Reject clears the marker.
	var pendingRequest *uuid.UUID
	if s.approvals != nil {
		preImage := map[string]any{
			"title":    current.Title,
			"due_date": current.DueDate.Format("2006-01-02"),
			"status":   current.Status,
		}
		req, err := s.approvals.SubmitDelete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage)
		if err != nil {
			return err
		}
		pendingRequest = req
	}

	if pendingRequest == nil {
		// No policy applied — proceed with the immediate hard-delete.
		if _, err := tx.ExecContext(ctx,
			`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
			return fmt.Errorf("delete deadline: %w", err)
		}
		desc := current.Title
		descPtr := &desc
		if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
			return err
		}
	}
	return tx.Commit()
}

// SummaryCounts returns traffic-light counts across the user's visible Deadlines.
//
// The five buckets are disjoint (t-paliad-106):
//   - Overdue:  pending AND due_date < today
//   - Today:    pending AND due_date == today
//   - ThisWeek: pending AND today < due_date <= upcoming Sunday (inclusive)
//   - NextWeek: pending AND Mon next week <= due_date <= Sun next week
//   - Completed: status = 'completed' (no time bound — full history)
//
// "Total" is the count of all visible Deadlines (any status). Items with a
// pending due_date past next Sunday are not in any of the four pending buckets;
// they're still counted in Total and in the all-pending list.
type SummaryCounts struct {
	Overdue   int `json:"overdue"   db:"overdue"`
	Today     int `json:"today"     db:"today"`
	ThisWeek  int `json:"this_week" db:"this_week"`
	NextWeek  int `json:"next_week" db:"next_week"`
	Completed int `json:"completed" db:"completed"`
	Total     int `json:"total"     db:"total"`
}

// SummaryCounts aggregates Deadlines by due-date bucket for the user's visible
// projects, optionally scoped to a single Project.
func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID) (*SummaryCounts, error) {
	user, err := s.users().GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return &SummaryCounts{}, nil
	}
	b := computeDeadlineBucketBounds(time.Now().UTC())

	conds := []string{visibilityPredicate("p")}
	args := map[string]any{
		"user_id":     userID,
		"today":       b.today,
		"tomorrow":    b.tomorrow,
		"next_monday": b.nextMonday,
		"week_after":  b.weekAfter,
	}
	if projectID != nil {
		conds = append(conds, projectDescendantPredicate("p"))
		args["project_id"] = *projectID
	}

	query := `
		SELECT
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date < :today)                                            AS overdue,
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date = :today)                                            AS today,
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= :tomorrow    AND f.due_date < :next_monday)       AS this_week,
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= :next_monday AND f.due_date < :week_after)        AS next_week,
		  COUNT(*) FILTER (WHERE f.status = 'completed')                                                                   AS completed,
		  COUNT(*)                                                                                                         AS total
		  FROM paliad.deadlines f
		  JOIN paliad.projects p ON p.id = f.project_id
		 WHERE ` + strings.Join(conds, " AND ")

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

	var c SummaryCounts
	if err := stmt.GetContext(ctx, &c, args); err != nil {
		return nil, fmt.Errorf("deadline summary: %w", err)
	}
	return &c, nil
}

// deadlineBucketBounds carries the date pivots used by both
// SummaryCounts and ListVisibleForUser to keep the cutoff math in
// exactly one place. All values are UTC-day boundaries (00:00:00 UTC),
// matching the existing convention — see the package-level note on
// timezone in the original SummaryCounts implementation.
type deadlineBucketBounds struct {
	today      time.Time
	tomorrow   time.Time
	nextMonday time.Time // Monday of next week (= Sunday-of-this-week + 1d)
	weekAfter  time.Time // Monday of the week after next (exclusive end of NextWeek)
}

// computeDeadlineBucketBounds derives the four pivots from `now`.
//
// Encoding of weekdays in Go: 0=Sunday, 1=Monday, …, 6=Saturday.
// "Sunday of this week" is the upcoming Sunday including today, so when
// today is Sunday daysToSunday = 0 (Diese Woche has no future-eligible
// entries beyond Heute, which is exactly what the spec calls for).
func computeDeadlineBucketBounds(now time.Time) deadlineBucketBounds {
	today := now.Truncate(24 * time.Hour)
	tomorrow := today.AddDate(0, 0, 1)
	daysToSunday := (7 - int(today.Weekday())) % 7
	sunday := today.AddDate(0, 0, daysToSunday)
	nextMonday := sunday.AddDate(0, 0, 1)
	weekAfter := nextMonday.AddDate(0, 0, 7)
	return deadlineBucketBounds{
		today:      today,
		tomorrow:   tomorrow,
		nextMonday: nextMonday,
		weekAfter:  weekAfter,
	}
}

// insert performs one INSERT in its own transaction.
func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUID, input CreateDeadlineInput) (uuid.UUID, error) {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return uuid.Nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	id, err := s.insertTx(ctx, tx, userID, projectID, input)
	if err != nil {
		return uuid.Nil, err
	}

	if s.eventTypes != nil && len(input.EventTypeIDs) > 0 {
		if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, id, input.EventTypeIDs); err != nil {
			return uuid.Nil, err
		}
	}

	desc := strings.TrimSpace(input.Title)
	descPtr := &desc
	if err := insertProjectEventWithMeta(ctx, tx, projectID, userID, "deadline_created", "Deadline created", descPtr,
		map[string]any{"deadline_id": id}); err != nil {
		return uuid.Nil, err
	}

	// Approval gate: if a (project, deadline, create) policy applies, this
	// flips the just-inserted row's approval_status to 'pending' and emits
	// a 'deadline_approval_requested' audit event. No-op when no policy is
	// configured or when the approval service isn't wired (test harness).
	//
	// 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":    desc,
			"due_date": input.DueDate,
		}
		if input.AgentTurnID != nil {
			if _, err := s.approvals.SubmitAgentCreate(ctx, tx, projectID, id, userID, *input.AgentTurnID, EntityTypeDeadline, payload); err != nil {
				return uuid.Nil, err
			}
		} else {
			if _, err := s.approvals.SubmitCreate(ctx, tx, projectID, id, userID, EntityTypeDeadline, payload); err != nil {
				return uuid.Nil, err
			}
		}
	}

	if err := tx.Commit(); err != nil {
		return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err)
	}
	return id, nil
}

// insertTx writes one deadlines row in an existing transaction.
func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projectID uuid.UUID, input CreateDeadlineInput) (uuid.UUID, error) {
	title := strings.TrimSpace(input.Title)
	if title == "" {
		return uuid.Nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
	}
	if input.DueDate == "" {
		return uuid.Nil, fmt.Errorf("%w: due_date is required", ErrInvalidInput)
	}
	due, err := time.Parse("2006-01-02", input.DueDate)
	if err != nil {
		return uuid.Nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
	}
	var orig *time.Time
	if input.OriginalDueDate != nil && *input.OriginalDueDate != "" {
		o, err := time.Parse("2006-01-02", *input.OriginalDueDate)
		if err != nil {
			return uuid.Nil, fmt.Errorf("%w: original_due_date must be YYYY-MM-DD", ErrInvalidInput)
		}
		orig = &o
	}
	source := input.Source
	if source == "" {
		source = "manual"
	}

	var ruleCode *string
	if input.RuleCode != nil {
		trimmed := strings.TrimSpace(*input.RuleCode)
		if trimmed != "" {
			ruleCode = &trimmed
		}
	}

	// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
	// mutually exclusive. If the caller passes both, the catalog rule
	// wins and the free-text is dropped — keeps the invariant simple at
	// the persistence boundary.
	var customRuleText *string
	if input.CustomRuleText != nil && input.RuleID == nil {
		trimmed := strings.TrimSpace(*input.CustomRuleText)
		if trimmed != "" {
			customRuleText = &trimmed
		}
	}

	id := uuid.New()
	now := time.Now().UTC()
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.deadlines
		   (id, project_id, title, description, due_date, original_due_date,
		    source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
		id, projectID, title, input.Description, due, orig,
		source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
	); err != nil {
		return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
	}
	return id, nil
}

// parentProjectID resolves a Deadline's parent Project ID without a visibility
// check. Internal only — callers must then gate via ProjectService.GetByID.
func (s *DeadlineService) parentProjectID(ctx context.Context, deadlineID uuid.UUID) (uuid.UUID, error) {
	var projectID uuid.UUID
	err := s.db.GetContext(ctx, &projectID,
		`SELECT project_id FROM paliad.deadlines WHERE id = $1`, deadlineID)
	if errors.Is(err, sql.ErrNoRows) {
		return uuid.Nil, ErrNotVisible
	}
	if err != nil {
		return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err)
	}
	return projectID, nil
}

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

func isValidDeadlineStatus(st string) bool {
	switch st {
	case "pending", "completed", "cancelled", "waived":
		return true
	}
	return false
}

// buildEventTypeFilterClause returns the WHERE-fragment that enforces
// ListFilter.EventTypeIDs / ListFilter.IncludeUntyped against the
// `paliad.deadlines` row aliased `f`. Caller is using a sqlx named-args
// map; this function injects the params directly into that map and
// returns a fragment usable with the named-statement compiler. Returns
// empty when no Typ filter is active.
func buildEventTypeFilterClause(filter ListFilter, args map[string]any) string {
	if len(filter.EventTypeIDs) == 0 && !filter.IncludeUntyped {
		return ""
	}
	parts := []string{}
	if len(filter.EventTypeIDs) > 0 {
		// sqlx.PrepareNamedContext doesn't expand IN-with-slice; build
		// per-element named placeholders manually instead.
		phs := make([]string, 0, len(filter.EventTypeIDs))
		for i, id := range filter.EventTypeIDs {
			key := fmt.Sprintf("event_type_id_%d", i)
			args[key] = id
			phs = append(phs, ":"+key)
		}
		parts = append(parts, `EXISTS (
			SELECT 1 FROM paliad.deadline_event_types det
			 WHERE det.deadline_id = f.id
			   AND det.event_type_id IN (`+strings.Join(phs, ", ")+`)
		)`)
	}
	if filter.IncludeUntyped {
		parts = append(parts, `NOT EXISTS (
			SELECT 1 FROM paliad.deadline_event_types det
			 WHERE det.deadline_id = f.id
		)`)
	}
	if len(parts) == 1 {
		return parts[0]
	}
	return "(" + strings.Join(parts, " OR ") + ")"
}

// hydrateEventTypes loads the attached event_type_ids for each Deadline
// (or DeadlineWithProject) in rows. No-op when eventTypes service is
// unset (test fixtures).
func (s *DeadlineService) hydrateEventTypes(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) {
	if s.eventTypes == nil {
		out := make(map[uuid.UUID][]uuid.UUID, len(ids))
		for _, id := range ids {
			out[id] = []uuid.UUID{}
		}
		return out, nil
	}
	return s.eventTypes.ListForDeadlines(ctx, ids)
}
